#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""convert_docx_v39.py

DOCX -> MkDocs converter (Word2HTML project)
Version : V58

V4 additions (vs previous working V3 behavior):
  - --audit : analyze the DOCX without generating a full site; outputs audit.json (+ optional audit.txt).
  - --max-h1 N : generate only the first N level-1 headings/pages (quick smoke test on large docs).
  - report.txt : end-of-run anomaly report (unsupported objects, missing relationships, list fallbacks, etc.).

Core goals preserved:
  - Convert .docx directly to MkDocs structure (no LibreOffice / ODT).
  - Robust multi-level lists (numbering.xml) + fallback lists by paragraph style name.
  - Heading "rebasing" if the document starts at Heading2/Titre2 (no Heading1 present).
  - Extract inline and floating images (wp:inline and wp:anchor) referenced via rId.

Usage:
  python convert_docx_v39.py <file.docx> <config.py|config.json> [--audit] [--max-h1 N]
"""

import sys
import os
import re
import json
import html
import shutil
import zipfile
import importlib.util
import argparse
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlsplit, urlunsplit

from unidecode import unidecode

try:
    from lxml import etree
except Exception as e:
    raise SystemExit("Erreur: lxml est requis (pip install lxml).") from e

VERSION = "V59"

# -------------------------
# Namespaces
# -------------------------

NS = {
    "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
    "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
    "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
    "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
    "v": "urn:schemas-microsoft-com:vml",
    "o": "urn:schemas-microsoft-com:office:office",
}


# -------------------------
# Config (compatible with convert.py)
# -------------------------

def load_configuration(config_path: str) -> dict:
    default_config = {
        "toc": {
            "home_label": "Accueil",
        },
        "mkdocs": {
            "site_name": "Documentation",
            "site_url": "",
            "repo_url": "",
            "repo_name": "GitHub",
            "use_directory_urls": False,
        },
        "footer": "<footer>Pied de page</footer>",
        "code": {
            "style_keywords": ["code", "consolas", "source"],
            "default_language": "text",
            "detection_rules": {},
            "rich_line_height": "theme",
            "rich_font_family": "theme",
            "rich_font_size": "theme",

            # --- Bouton [Copier] dans les blocs de code (optionnel) ---
            # Active l'injection d'un bouton "Copier" dans les blocs de code.
            "copy_button": False,
            # Libellés
            "copy_label": "Copier",
            "copy_copied_label": "Copié",
            # N'afficher le bouton que si la langue est "reconnue" (≠ text/plain)
            "copy_only_recognized_language": True,
            # N'afficher le bouton que si le bloc contient au moins N lignes (0 = pas de filtre)
            "copy_min_lines": 0,
            # Heuristique: si la coloration Pygments est visible (spans token), considérer le langage "reconnu"
            "copy_allow_pygments_heuristic": True,
            # Style "Material-like" (CSS inline)
            "copy_style": {
                "container": "position: relative;",
                "btn": "position: absolute; top: .4rem; right: .4rem; padding: .15rem .5rem; font-size: .72rem; line-height: 1.2; border-radius: .4rem; border: 1px solid rgba(0,0,0,.2); background: rgba(255,255,255,.85); cursor: pointer; z-index: 5;",
                "btn_hover": "background: rgba(255,255,255,.98);",
                "btn_copied": "opacity: .85;",
            },
        },
        "document_title": {
            "style_names": ["Title", "Titre", "Titre principal"],
            "css": "font-size: 28px; font-weight: bold; margin-bottom: 1em;",
        },
        "images": {
            "shadow": {
                "enabled": True,
                "border_radius": "8px",
                "zoomable": "0 8px 24px rgba(0,0,0,.28), 0 18px 60px rgba(0,0,0,.20)",
                "zoomable_hover": "0 12px 30px rgba(0,0,0,.32), 0 26px 80px rgba(0,0,0,.22)",
                "lightbox": "0 24px 80px rgba(0,0,0,.65)",
            }
        },
        "frame": {
            "header_bg_light": "",
            "header_bg_dark": "",
            "header_top_border_light": "",
            "header_top_border_dark": "",
            "header_top_border_width": "0px",
        },
        "debug": False,
        "files_to_copy": [],
        "extra": {},
    }

    if not config_path or not os.path.exists(config_path):
        return default_config

    user_config: dict = {}
    try:
        if config_path.endswith(".py"):
            config_abspath = os.path.abspath(config_path)
            config_dir = os.path.dirname(config_abspath)
            added_to_syspath = False
            try:
                if config_dir and config_dir not in sys.path:
                    sys.path.insert(0, config_dir)
                    added_to_syspath = True

                spec = importlib.util.spec_from_file_location("user_config_module", config_abspath)
                module = importlib.util.module_from_spec(spec)
                assert spec.loader is not None
                spec.loader.exec_module(module)
                if hasattr(module, "config") and isinstance(module.config, dict):
                    user_config = module.config
            finally:
                if added_to_syspath:
                    try:
                        sys.path.remove(config_dir)
                    except ValueError:
                        pass
        else:
            with open(config_path, "r", encoding="utf-8") as f:
                user_config = json.load(f)
    except Exception as e:
        print(f"[WARN] Erreur lors du chargement de la config: {e}. Utilisation des défauts.")

    # Merge
    if "mkdocs" in user_config:
        default_config["mkdocs"].update(user_config["mkdocs"])
        exts = default_config["mkdocs"].get(
            "markdown_extensions", user_config["mkdocs"].get("markdown_extensions", [])
        )
        if isinstance(exts, list):
            def has_ext(name: str) -> bool:
                for x in exts:
                    if x == name:
                        return True
                    if isinstance(x, dict) and name in x:
                        return True
                return False

            if not has_ext("md_in_html"):
                exts.append("md_in_html")
            if not has_ext("footnotes"):
                exts.append("footnotes")
        default_config["mkdocs"]["markdown_extensions"] = exts

    for k in ("footer", "code", "document_title", "images", "frame", "debug", "files_to_copy", "extra", "toc"):
        if k in user_config:
            if isinstance(default_config.get(k), dict) and isinstance(user_config[k], dict):
                default_config[k].update(user_config[k])
            else:
                default_config[k] = user_config[k]

    return default_config


def create_directory(path: str) -> None:
    os.makedirs(path, exist_ok=True)


def sanitize_anchor_id(text_: str) -> str:
    if not text_:
        return ""
    return re.sub(r"[^a-z0-9\-]", "", unidecode(str(text_)).lower().replace(" ", "-"))


def normalize_href_to_html(href: Optional[str]) -> Optional[str]:
    """Normalize internal doc links so they target generated HTML, not MkDocs source (.md).

    - Keep external URLs unchanged (http/https/mailto/tel/ftp/data).
    - Keep pure fragment links (#anchor) unchanged.
    - Convert path endings: *.md -> *.html (preserving query + fragment).
    """
    if not href:
        return href
    href_s = str(href).strip()
    low = href_s.lower()
    if low.startswith(("http://", "https://", "mailto:", "tel:", "ftp://", "data:")):
        return href_s
    if href_s.startswith("#"):
        return href_s
    parts = urlsplit(href_s)
    path = parts.path or ""
    if path.lower().endswith(".md"):
        path = path[:-3] + ".html"
    return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))


def generate_safe_filename(raw_title: str) -> str:
    base = re.sub(r"[^a-z0-9\-]", "", unidecode(raw_title.lower().replace(" ", "-")))
    return (base or "page") + ".md"


def escape_markdown_text(s: str) -> str:
    if s is None:
        return ""
    s = s.replace("\\", "\\\\")
    s = s.replace("*", "\\*").replace("_", "\\_")
    return s


def escape_text_for_md_display(s: str) -> str:
    """Escape plain text for safe display inside a Markdown page.

    Rationale: A DOCX paragraph can contain expressions like `a*b` where `*`
    would be interpreted by Markdown as emphasis and disappear.

    We mainly emit inline HTML for formatting; for unformatted text we still
    concatenate raw text segments. To prevent Markdown from re-interpreting the
    content, we encode Markdown-sensitive characters as HTML entities.

    This is applied only to *plain text segments* (not code blocks).
    """
    if s is None:
        return ""
    # First escape HTML special chars
    out = html.escape(str(s))
    # Then neutralize Markdown metacharacters in plain text.
    out = (out
           .replace("*", "&#42;")
           .replace("_", "&#95;")
           .replace("[", "&#91;")
           .replace("]", "&#93;")
           .replace("`", "&#96;"))
    return out


# -------------------------
# Footnotes (DOCX)
# -------------------------

def init_footnotes_ctx():
    return {"counter": 0, "defs": [], "seen": set(), "docx_id_to_fid": {}}


def register_docx_footnote(footnotes_ctx, docx_id: str, body_text: str) -> str:
    """Register a DOCX footnote id -> MkDocs footnote id, storing definition once per page."""
    if footnotes_ctx is None:
        return ""
    docx_id = str(docx_id)
    if docx_id in footnotes_ctx["docx_id_to_fid"]:
        return footnotes_ctx["docx_id_to_fid"][docx_id]
    footnotes_ctx["counter"] += 1
    fid = f"fn{footnotes_ctx['counter']}"
    footnotes_ctx["docx_id_to_fid"][docx_id] = fid
    if fid in footnotes_ctx["seen"]:
        return fid
    footnotes_ctx["seen"].add(fid)
    body = (body_text or "").strip()
    footnotes_ctx["defs"].append((fid, body))
    return fid


def write_footnotes(current_file, footnotes_ctx):
    if not footnotes_ctx:
        return
    defs = footnotes_ctx.get("defs") or []
    if not defs:
        return
    current_file.write("\n\n---\n\n")
    for fid, body in defs:
        body = (body or "").strip()
        lines = body.splitlines() if body else [""]
        current_file.write(f"[^%s]: %s\n" % (fid, lines[0] if lines else ""))
        for ln in lines[1:]:
            current_file.write(f"    {ln}\n")


# -------------------------
# Code detection (same idea as convert.py)
# -------------------------

def detect_language(code_content: str, code_config: dict) -> str:
    if not code_content:
        return code_config.get("default_language", "text")
    content_stripped = code_content.strip()
    if content_stripped.startswith("<?php"):
        return "php"
    if content_stripped.startswith("<?xml"):
        return "xml"

    rules = code_config.get("detection_rules", {}) or {}
    case_insensitive_languages = {
        str(x).strip().lower()
        for x in (code_config.get("case_insensitive_languages", ["sql", "html", "xml"]) or [])
        if str(x).strip()
    }

    scores: Dict[str, int] = {}
    code_content_lower = code_content.lower()

    for lang, keywords in rules.items():
        if not keywords:
            continue

        lang_key = str(lang).strip().lower()
        if lang_key in case_insensitive_languages:
            count = sum(
                1
                for k in keywords
                if str(k).lower() in code_content_lower
            )
        else:
            count = sum(
                1
                for k in keywords
                if str(k) in code_content
            )

        if count:
            scores[lang] = count

    if scores:
        return max(scores, key=scores.get)
    if "function" in code_content or "var " in code_content:
        return "javascript"
    return code_config.get("default_language", "text")


def get_fence(content: str) -> str:
    matches = re.findall(r"`+", content or "")
    longest = max((len(m) for m in matches), default=0)
    return "`" * max(3, longest + 1)


def is_code_paragraph_by_style(style_id: Optional[str], code_cfg: dict) -> bool:
    if not style_id:
        return False
    kws = [unidecode(s).lower() for s in (code_cfg.get("style_keywords") or [])]
    s = unidecode(style_id).lower()
    return any(k in s for k in kws)


# -------------------------
# Rich code (DOCX) helpers
# -------------------------

_HIGHLIGHT_TO_CSS = {
    "yellow": "#fff59d",
    "green": "#c8e6c9",
    "cyan": "#b2ebf2",
    "magenta": "#f8bbd0",
    "blue": "#bbdefb",
    "red": "#ffcdd2",
    "darkBlue": "#90caf9",
    "darkRed": "#ef9a9a",
    "darkYellow": "#fff176",
    "darkGreen": "#a5d6a7",
    "lightGray": "#eeeeee",
    "darkGray": "#bdbdbd",
    "black": "#000000",
    "white": "#ffffff",
}


def _emu_to_px(emu: int) -> int:
    # Word uses EMU: 914400 EMU per inch. Assume 96 dpi for CSS px.
    try:
        return max(1, int(round(int(emu) / 9525)))
    except Exception:
        return 0


def _run_style_css(r) -> str:
    """Inline CSS capturing run-level formatting (bold/italic/underline/color/highlight/shading)."""
    rpr = r.find("./w:rPr", namespaces=NS)
    if rpr is None:
        return ""
    css = []

    if rpr.find("./w:b", namespaces=NS) is not None:
        css.append("font-weight:bold")
    if rpr.find("./w:i", namespaces=NS) is not None:
        css.append("font-style:italic")

    u = rpr.find("./w:u", namespaces=NS)
    if u is not None:
        uval = u.get(etree.QName(NS["w"], "val"))
        if not uval or str(uval).lower() != "none":
            css.append("text-decoration:underline")

    col = rpr.find("./w:color", namespaces=NS)
    if col is not None:
        v = col.get(etree.QName(NS["w"], "val"))
        if v and str(v).lower() not in ("auto",):
            vv = str(v).strip()
            if re.fullmatch(r"[0-9a-fA-F]{6}", vv):
                css.append(f"color:#{vv}")

    # Highlight (w:highlight uses named colors) + Shading (w:shd fill uses hex)
    bg = None
    hi = rpr.find("./w:highlight", namespaces=NS)
    if hi is not None:
        v = hi.get(etree.QName(NS["w"], "val"))
        if v:
            vv = str(v).strip()
            bg = _HIGHLIGHT_TO_CSS.get(vv, None)
            if not bg and re.fullmatch(r"[0-9a-fA-F]{6}", vv):
                bg = "#" + vv

    if not bg:
        shd = rpr.find("./w:shd", namespaces=NS)
        if shd is not None:
            fill = shd.get(etree.QName(NS["w"], "fill"))
            if fill:
                ff = str(fill).strip()
                if re.fullmatch(r"[0-9a-fA-F]{6}", ff):
                    bg = "#" + ff

    if bg:
        css.append(f"background-color:{bg}")

    return ";".join(css)


def paragraph_has_rich_code_formatting(p) -> bool:
    """True if paragraph contains run-level formatting we should preserve."""
    for r in p.findall(".//w:r", namespaces=NS):
        if _run_style_css(r):
            return True
    return False


def runs_to_rich_code_html_line(p) -> str:
    """Render a paragraph as an HTML line preserving run styling."""
    parts: List[str] = []
    # iterate in-document order for runs/hyperlinks
    for node in p:
        tag = etree.QName(node).localname
        if tag == "r":
            css = _run_style_css(node)
            t = _run_text_preserve(node) or ""
            t = html.escape(t)
            if css:
                parts.append(f'<span style="{css}">{t}</span>')
            else:
                parts.append(t)
        elif tag == "hyperlink":
            t = _run_text_preserve(node) or ""
            parts.append(html.escape(t))
        elif tag == "br":
            parts.append("<br>")
        else:
            t = _run_text_preserve(node) or ""
            if t:
                parts.append(html.escape(t))
    return "".join(parts)


def render_rich_code_block(lines_html: List[str], lang: str, start_line: int = 1, show_linenums: bool = False,
                           copy_button: bool = False, copy_label: str = "Copier") -> str:
    lang_class = f"language-{lang}" if lang else "language-text"
    start = int(start_line) if start_line and int(start_line) > 0 else 1
    counter_reset = start - 1
    linenums_attr = "true" if show_linenums else "false"
    wrapped = "\n".join(f'<span class="docx-code-line">{ln}</span>' for ln in lines_html)
    # Copy button is injected by JS (focus.js) to allow the same rules as ODT V368.
    btn_html = ""
    extra_cls = ""

    return (
        f'<div class="docx-code-rich" data-linenums="{linenums_attr}" '
        f'style="counter-reset: docxline {counter_reset};">'
        f'<pre><code class="{lang_class}">\n{wrapped}\n</code></pre></div>'
    )


# -------------------------
# Numbering model
# -------------------------

@dataclass
class LevelDef:
    num_fmt: str = "bullet"  # decimal, bullet, lowerLetter, upperRoman, ...
    start: int = 1


@dataclass
class Numbering:
    numId_to_abs: Dict[str, str]
    abs_levels: Dict[str, Dict[int, LevelDef]]
    # Per-numId overrides (e.g., Word "Set Numbering Value" creates <w:lvlOverride><w:startOverride/>)
    num_overrides: Dict[Tuple[str, int], LevelDef]


def _zip_read_text(z: zipfile.ZipFile, path: str) -> Optional[bytes]:
    try:
        return z.read(path)
    except KeyError:
        return None


def parse_numbering(z: zipfile.ZipFile) -> Numbering:
    xml = _zip_read_text(z, "word/numbering.xml")
    if not xml:
        return Numbering(numId_to_abs={}, abs_levels={}, num_overrides={})
    root = etree.fromstring(xml)

    abs_levels: Dict[str, Dict[int, LevelDef]] = {}
    for absnum in root.findall(".//w:abstractNum", namespaces=NS):
        abs_id = absnum.get(etree.QName(NS["w"], "abstractNumId"))
        if not abs_id:
            continue
        levels: Dict[int, LevelDef] = {}
        for lvl in absnum.findall("./w:lvl", namespaces=NS):
            ilvl = lvl.get(etree.QName(NS["w"], "ilvl"))
            if ilvl is None:
                continue
            try:
                i = int(ilvl)
            except Exception:
                continue
            numfmt = lvl.find("./w:numFmt", namespaces=NS)
            fmt = (numfmt.get(etree.QName(NS["w"], "val")) if numfmt is not None else None) or "bullet"
            start = 1
            start_el = lvl.find("./w:start", namespaces=NS)
            if start_el is not None:
                try:
                    start = int(start_el.get(etree.QName(NS["w"], "val")) or "1")
                except Exception:
                    start = 1
            levels[i] = LevelDef(num_fmt=fmt, start=start)
        abs_levels[str(abs_id)] = levels

    numId_to_abs: Dict[str, str] = {}
    num_overrides: Dict[Tuple[str, int], LevelDef] = {}

    for num in root.findall(".//w:num", namespaces=NS):
        numId = num.get(etree.QName(NS["w"], "numId"))
        abs_el = num.find("./w:abstractNumId", namespaces=NS)
        abs_id = abs_el.get(etree.QName(NS["w"], "val")) if abs_el is not None else None
        if numId and abs_id:
            numId_to_abs[str(numId)] = str(abs_id)

        # Overrides: <w:lvlOverride w:ilvl="0"><w:startOverride w:val="5"/></w:lvlOverride>
        if numId:
            for lvl_ov in num.findall("./w:lvlOverride", namespaces=NS):
                ilvl = lvl_ov.get(etree.QName(NS["w"], "ilvl"))
                if ilvl is None:
                    continue
                try:
                    i = int(ilvl)
                except Exception:
                    continue

                start = None
                fmt = None

                so = lvl_ov.find("./w:startOverride", namespaces=NS)
                if so is not None:
                    try:
                        start = int(so.get(etree.QName(NS["w"], "val")) or "1")
                    except Exception:
                        start = None

                # Some docs embed lvl in override; if present, pick up numFmt too
                lvl = lvl_ov.find("./w:lvl", namespaces=NS)
                if lvl is not None:
                    numfmt = lvl.find("./w:numFmt", namespaces=NS)
                    fmt = (numfmt.get(etree.QName(NS["w"], "val")) if numfmt is not None else None)

                    if start is None:
                        start_el = lvl.find("./w:start", namespaces=NS)
                        if start_el is not None:
                            try:
                                start = int(start_el.get(etree.QName(NS["w"], "val")) or "1")
                            except Exception:
                                start = None

                if start is not None or fmt is not None:
                    # Merge with base level def if needed
                    abs_base_id = numId_to_abs.get(str(numId), "")
                    base = abs_levels.get(abs_base_id, {}).get(i, LevelDef())
                    num_overrides[(str(numId), i)] = LevelDef(
                        num_fmt=(fmt or base.num_fmt),
                        start=(start if start is not None else base.start),
                    )

    return Numbering(numId_to_abs=numId_to_abs, abs_levels=abs_levels, num_overrides=num_overrides)


def parse_styles(z: zipfile.ZipFile) -> Dict[str, Dict[str, object]]:
    """Parse word/styles.xml to resolve numbering that is attached to paragraph styles.

    Returns a dict keyed by styleId with:
      - basedOn: optional base styleId
      - name: optional human-friendly name
      - numId: optional int
      - ilvl: optional int
    """
    xml = _zip_read_text(z, "word/styles.xml")
    if not xml:
        return {}
    try:
        root = etree.fromstring(xml)
    except Exception:
        return {}

    styles: Dict[str, Dict[str, object]] = {}
    for st in root.findall(".//w:style", namespaces=NS):
        style_id = st.get(etree.QName(NS["w"], "styleId"))
        if not style_id:
            continue

        info: Dict[str, object] = {"basedOn": None, "name": None, "numId": None, "ilvl": None, "p_shading": None}

        name_el = st.find("./w:name", namespaces=NS)
        if name_el is not None:
            info["name"] = name_el.get(etree.QName(NS["w"], "val"))

        based_el = st.find("./w:basedOn", namespaces=NS)
        if based_el is not None:
            info["basedOn"] = based_el.get(etree.QName(NS["w"], "val"))

        pPr = st.find("./w:pPr", namespaces=NS)
        if pPr is not None:
            numPr = pPr.find("./w:numPr", namespaces=NS)
            if numPr is not None:
                numId_el = numPr.find("./w:numId", namespaces=NS)
                ilvl_el = numPr.find("./w:ilvl", namespaces=NS)
                try:
                    if numId_el is not None:
                        info["numId"] = int(numId_el.get(etree.QName(NS["w"], "val")) or "0")
                    if ilvl_el is not None:
                        info["ilvl"] = int(ilvl_el.get(etree.QName(NS["w"], "val")) or "0")
                except Exception:
                    pass

            shd = pPr.find("./w:shd", namespaces=NS)
            if shd is not None:
                fill = shd.get(etree.QName(NS["w"], "fill"))
                if fill:
                    ff = str(fill).strip()
                    if re.fullmatch(r"[0-9a-fA-F]{6}", ff):
                        info["p_shading"] = "#" + ff

        styles[str(style_id)] = info
    return styles


def resolve_style_numPr(styles: Dict[str, Dict[str, object]], style_id: Optional[str]) -> Tuple[
    Optional[int], Optional[int]]:
    """Resolve (numId, ilvl) from a styleId following basedOn chain."""
    if not style_id or not styles:
        return None, None
    cur = style_id
    seen = set()
    for _ in range(32):
        if cur in seen:
            break
        seen.add(cur)
        info = styles.get(cur)
        if not info:
            break
        numId = info.get("numId")
        ilvl = info.get("ilvl")
        if isinstance(numId, int) and isinstance(ilvl, int):
            return numId, ilvl
        base = info.get("basedOn")
        if isinstance(base, str) and base:
            cur = base
        else:
            break
    return None, None


def resolve_style_paragraph_background(styles: Dict[str, Dict[str, object]], style_id: Optional[str]) -> Optional[str]:
    """Resolve paragraph background color from a styleId following basedOn chain."""
    if not style_id or not styles:
        return None
    cur = style_id
    seen = set()
    for _ in range(32):
        if cur in seen:
            break
        seen.add(cur)
        info = styles.get(cur)
        if not info:
            break
        bg = info.get("p_shading")
        if isinstance(bg, str) and bg:
            return bg
        base = info.get("basedOn")
        if isinstance(base, str) and base:
            cur = base
        else:
            break
    return None


# -------------------------
# Relationships & media
# -------------------------

def parse_relationships(z: zipfile.ZipFile) -> Dict[str, str]:
    xml = _zip_read_text(z, "word/_rels/document.xml.rels")
    if not xml:
        return {}
    root = etree.fromstring(xml)
    out: Dict[str, str] = {}
    for rel in root.findall(".//{*}Relationship"):
        rid = rel.get("Id")
        target = rel.get("Target")
        if rid and target:
            out[rid] = target
    return out


def parse_footnotes_db(z: zipfile.ZipFile) -> Dict[str, str]:
    """Parse word/footnotes.xml into {docx_footnote_id: plain_text}.

    Note: python-docx does not expose footnotes reliably, so we read XML directly.
    We keep plain text (with line breaks) for MkDocs footnote definitions.
    """
    xml = _zip_read_text(z, "word/footnotes.xml")
    if not xml:
        return {}
    root = etree.fromstring(xml)
    out: Dict[str, str] = {}
    for fn in root.findall(".//w:footnote", namespaces=NS):
        fid = fn.get(etree.QName(NS["w"], "id"))
        if fid is None:
            continue
        try:
            if int(fid) <= 0:
                continue  # skip separators
        except Exception:
            continue
        # Collect displayed text from paragraphs
        parts: List[str] = []
        for p in fn.findall(".//w:p", namespaces=NS):
            t = (etree.tostring(p, method="text", encoding="unicode") or "").strip()
            if t:
                parts.append(t)
        out[str(fid)] = "\n".join(parts).strip()
    return out


def extract_media(z: zipfile.ZipFile, images_dir: str) -> Dict[str, str]:
    """Extract word/media/* into docs/images and return map docx_path->site_relative_src."""
    create_directory(images_dir)
    media_map: Dict[str, str] = {}
    for info in z.infolist():
        if info.filename.startswith("word/media/") and not info.is_dir():
            base = os.path.basename(info.filename)
            safe = re.sub(r"[^a-zA-Z0-9._-]", "_", base)
            target_path = os.path.join(images_dir, safe)
            with open(target_path, "wb") as f:
                f.write(z.read(info))
            media_map[info.filename] = f"images/{safe}"
    return media_map


# -------------------------
# DOCX paragraph helpers
# -------------------------

def _p_style(p) -> Optional[str]:
    pPr = p.find("./w:pPr", namespaces=NS)
    if pPr is None:
        return None
    pStyle = pPr.find("./w:pStyle", namespaces=NS)
    if pStyle is None:
        return None
    return pStyle.get(etree.QName(NS["w"], "val"))


def _outline_level(p) -> Optional[int]:
    pPr = p.find("./w:pPr", namespaces=NS)
    if pPr is None:
        return None
    ol = pPr.find("./w:outlineLvl", namespaces=NS)
    if ol is None:
        return None
    try:
        return int(ol.get(etree.QName(NS["w"], "val")) or "0") + 1
    except Exception:
        return None


def _heading_from_style(style_id: Optional[str]) -> Optional[int]:
    if not style_id:
        return None
    s = unidecode(style_id).lower()
    # Word built-ins: Heading1..Heading9
    m = re.match(r"heading\s*([1-9])$", s)
    if m:
        return int(m.group(1))
    # French/LO-ish styles: Titre1 / Titre 1 / Titre2
    m = re.match(r"titre\s*([1-9])$", s)
    if m:
        return int(m.group(1))
    # Generic: H1, H2
    m = re.match(r"h\s*([1-9])$", s)
    if m:
        return int(m.group(1))
    return None


def _p_num_info(p, styles: Optional[Dict[str, Dict[str, object]]] = None) -> Tuple[
    Optional[int], Optional[int], Optional[int]]:
    """Returns (numId, ilvl, start_override) for a paragraph.

    Priority:
      1) Paragraph-level <w:pPr><w:numPr> (includes <w:start w:val="..."/> restart)
      2) Style-level numbering (<w:style><w:pPr><w:numPr>) following basedOn chain
    """
    pPr = p.find("./w:pPr", namespaces=NS)
    if pPr is not None:
        numPr = pPr.find("./w:numPr", namespaces=NS)
        if numPr is not None:
            numId_el = numPr.find("./w:numId", namespaces=NS)
            ilvl_el = numPr.find("./w:ilvl", namespaces=NS)

            numId = numId_el.get(etree.QName(NS["w"], "val")) if numId_el is not None else None
            ilvl = ilvl_el.get(etree.QName(NS["w"], "val")) if ilvl_el is not None else None

            start_override = None
            start_el = numPr.find("./w:start", namespaces=NS)
            if start_el is not None:
                try:
                    start_override = int(start_el.get(etree.QName(NS["w"], "val")) or "1")
                except Exception:
                    start_override = None

            try:
                return (
                    (int(numId) if numId is not None else None),
                    (int(ilvl) if ilvl is not None else None),
                    start_override,
                )
            except Exception:
                return None, None, start_override

    # Style-based numbering
    if styles is not None:
        sid = _p_style(p)
        n, l = resolve_style_numPr(styles, sid)
        if n is not None and l is not None:
            return n, l, None

    return None, None, None


def _p_text_preserve(p) -> str:
    """Extract paragraph text preserving spaces and tabs (critical for code blocks)."""
    out: List[str] = []
    for node in p.iter():
        if not isinstance(node.tag, str):
            continue
        tag = etree.QName(node).localname
        if tag == "t":
            out.append(node.text or "")
        elif tag == "tab":
            out.append("\t")
        elif tag in ("br", "cr"):
            out.append("\n")
    return "".join(out)


def _run_text_preserve(r) -> str:
    """Extract run text preserving spaces and tabs."""
    out: List[str] = []
    for node in r.iter():
        if not isinstance(node.tag, str):
            continue
        tag = etree.QName(node).localname
        if tag == "t":
            out.append(node.text or "")
        elif tag == "tab":
            out.append("\t")
        elif tag in ("br", "cr"):
            out.append("\n")
    return "".join(out)


def _p_borders_css(p) -> Tuple[Optional[str], Optional[str]]:
    """Return (border_top_css, border_bottom_css) for paragraph, if any.

    Word stores borders in w:pPr/w:pBdr/(w:top,w:bottom) with attributes:
      - w:val (single, double, etc.)  -> we map most to 'solid'
      - w:sz  (eighths of a point)
      - w:color (hex or 'auto')
    """
    pPr = p.find("./w:pPr", namespaces=NS)
    if pPr is None:
        return (None, None)
    bdr = pPr.find("./w:pBdr", namespaces=NS)
    if bdr is None:
        return (None, None)

    def _one(side: str) -> Optional[str]:
        el = bdr.find(f"./w:{side}", namespaces=NS)
        if el is None:
            return None
        val = (el.get(etree.QName(NS["w"], "val")) or "").lower()
        if val in ("nil", "none", "0"):
            return None
        sz = el.get(etree.QName(NS["w"], "sz"))
        color = el.get(etree.QName(NS["w"], "color")) or "000000"
        if str(color).lower() == "auto":
            color = "000000"
        try:
            # w:sz is in 1/8 pt
            pt = (int(sz) / 8.0) if sz is not None else 1.0
        except Exception:
            pt = 1.0
        style = "solid"
        return f"{pt:.2f}pt {style} #{color}"

    return (_one("top"), _one("bottom"))


def _p_background_css(p, styles: Optional[Dict[str, Dict[str, object]]] = None) -> Optional[str]:
    """Return paragraph background color (#RRGGBB) from direct formatting or inherited style.

    Only paragraph-level shading is considered here (w:pPr/w:shd and style inheritance).
    Run-level highlighting remains handled elsewhere.
    """
    pPr = p.find("./w:pPr", namespaces=NS)
    if pPr is not None:
        shd = pPr.find("./w:shd", namespaces=NS)
        if shd is not None:
            fill = shd.get(etree.QName(NS["w"], "fill"))
            if fill:
                ff = str(fill).strip()
                if re.fullmatch(r"[0-9a-fA-F]{6}", ff):
                    return "#" + ff
    if styles is not None:
        return resolve_style_paragraph_background(styles, _p_style(p))
    return None


def _p_plain_text(p) -> str:
    return (etree.tostring(p, method="text", encoding="unicode") or "").strip()


def _html_wrap(txt: str, *, bold: bool = False, italic: bool = False, underline: bool = False,
               color_hex: Optional[str] = None, highlight_css: Optional[str] = None,
               wrap_plain_in_span: bool = False) -> str:
    """Apply run-level formatting using HTML tags.

    We intentionally prefer HTML over Markdown for inline styling to avoid
    Markdown eating characters (e.g., '*' in arithmetic expressions).
    When wrap_plain_in_span=True and the run has no explicit styling, we wrap the
    escaped text in a <span> to prevent Markdown from re-interpreting it.
    """
    if txt is None:
        txt = ""

    # For plain text runs we must neutralize Markdown metacharacters (e.g. '*', '_', '[', ']', '`')
    # because those would otherwise be parsed by Markdown.
    if wrap_plain_in_span and (not bold) and (not italic) and (not underline) and (not color_hex) and (
            not highlight_css):
        base = escape_text_for_md_display(txt)
        return f"<span>{base}</span>"

    # Styled runs are emitted as HTML. We still neutralize Markdown metacharacters
    # because some Markdown configurations may parse inside inline HTML.
    s = html.escape(str(txt))
    s = (
        s.replace('*', '&#42;').replace('_', '&#95;').replace('[', '&#91;').replace(']', '&#93;').replace('`', '&#96;'))

    if underline:
        s = f"<u>{s}</u>"
    if italic:
        s = f"<i>{s}</i>"
    if bold:
        s = f"<b>{s}</b>"
    if color_hex:
        s = f'<span style="color:{color_hex}">{s}</span>'
    if highlight_css:
        s = f'<mark style="background-color:{highlight_css}">{s}</mark>'

    return s


def _fix_literal_backslash_newlines(s: str) -> str:
    """Convert visible literal \\n sequences into real newlines.

    Some DOCX content may contain the two-character sequence '\\n' (and '\\r\\n')
    where actual paragraph breaks were intended. If left as-is, browsers display
    the backslashes instead of breaking lines/paragraphs.

    This normalization is only applied to non-code text contexts.
    """
    if not s:
        return ""
    # Convert visible Windows-style first
    s = s.replace('\\r\\n', '\n')
    # Then visible 

    s = s.replace('\\n', '\n')
    return s


def _run_to_inline_html(r, footnotes_ctx=None, footnotes_db=None, wrap_plain_in_span: bool = False) -> str:
    """Render one <w:r> (run) to inline HTML with formatting + links + footnotes."""
    # Footnote reference
    fnr = r.find("./w:footnoteReference", namespaces=NS)
    if fnr is not None:
        fn_id = fnr.get(etree.QName(NS["w"], "id"))
        if fn_id is None:
            return ""
        body = ""
        if isinstance(footnotes_db, dict):
            body = footnotes_db.get(str(fn_id), "") or ""
        fid = register_docx_footnote(footnotes_ctx, str(fn_id), body)
        return f"[^{fid}]" if fid else ""

    rPr = r.find("./w:rPr", namespaces=NS)
    bold = bool(rPr is not None and rPr.find("./w:b", namespaces=NS) is not None)
    italic = bool(rPr is not None and rPr.find("./w:i", namespaces=NS) is not None)

    underline = False
    if rPr is not None:
        u = rPr.find("./w:u", namespaces=NS)
        if u is not None:
            uval = u.get(etree.QName(NS["w"], "val"))
            underline = (not uval) or (str(uval).lower() != "none")

    color_hex = None
    if rPr is not None:
        col = rPr.find("./w:color", namespaces=NS)
        if col is not None:
            v = col.get(etree.QName(NS["w"], "val"))
            if v and str(v).lower() not in ("auto",):
                vv = str(v).strip()
                if re.fullmatch(r"[0-9a-fA-F]{6}", vv):
                    color_hex = "#" + vv

    # Highlight: w:highlight (named) or w:shd fill (hex)
    highlight_css = None
    if rPr is not None:
        hi = rPr.find("./w:highlight", namespaces=NS)
        if hi is not None:
            v = hi.get(etree.QName(NS["w"], "val"))
            if v:
                vv = str(v).strip()
                highlight_css = _HIGHLIGHT_TO_CSS.get(vv, None)
                if (not highlight_css) and re.fullmatch(r"[0-9a-fA-F]{6}", vv):
                    highlight_css = "#" + vv

        if not highlight_css:
            shd = rPr.find("./w:shd", namespaces=NS)
            if shd is not None:
                fill = shd.get(etree.QName(NS["w"], "fill"))
                if fill:
                    ff = str(fill).strip()
                    if re.fullmatch(r"[0-9a-fA-F]{6}", ff):
                        highlight_css = "#" + ff

    # text nodes
    t_nodes = r.findall("./w:t", namespaces=NS)
    if t_nodes:
        txt = "".join((t.text or "") for t in t_nodes)
        return _html_wrap(txt, bold=bold, italic=italic, underline=underline, color_hex=color_hex,
                          highlight_css=highlight_css, wrap_plain_in_span=wrap_plain_in_span)

    # tabs / breaks
    if r.find("./w:tab", namespaces=NS) is not None:
        return "&nbsp;&nbsp;&nbsp;&nbsp;"
    if r.find("./w:br", namespaces=NS) is not None:
        return "<br>"
    if r.find("./w:cr", namespaces=NS) is not None:
        return "<br>"

    instr = r.findall("./w:instrText", namespaces=NS)
    if instr:
        txt = "".join((t.text or "") for t in instr)
        return _html_wrap(txt, bold=bold, italic=italic, underline=underline, color_hex=color_hex,
                          highlight_css=highlight_css, wrap_plain_in_span=wrap_plain_in_span)

    return ""


def _render_hyperlink_inline(h, rels: Dict[str, str], targets_map: Dict[str, str], current_filename: str,
                             footnotes_ctx=None, footnotes_db=None) -> str:
    """Render <w:hyperlink> to <a href=...>...</a>.

    Supports:
      - external links via r:id (document.xml.rels)
      - internal links via w:anchor (bookmarks)
    """
    rid = h.get(etree.QName(NS["r"], "id"))
    anchor = h.get(etree.QName(NS["w"], "anchor"))
    href = None

    if rid and rid in rels:
        href = rels[rid]
        # normalize typical docx rel target
        if href.startswith("../"):
            href = href[3:]
    elif anchor:
        anchor_id = sanitize_anchor_id(anchor)
        target_file = targets_map.get(anchor)
        if target_file and target_file != current_filename:
            href = f"{target_file}#{anchor_id}"
        else:
            href = f"#{anchor_id}"

    inner_parts: List[str] = []
    for node in h:
        lname = etree.QName(node).localname
        if lname == "r":
            inner_parts.append(_run_to_inline_html(node, footnotes_ctx, footnotes_db))
        elif lname == "fldSimple":
            inner_parts.append(_p_plain_text(node))
        else:
            # best-effort
            inner_parts.append(html.escape(etree.tostring(node, method="text", encoding="unicode") or ""))
    inner = "".join(inner_parts).strip()

    if not href:
        return inner  # fallback: keep visible text
    href = normalize_href_to_html(href)
    return f'<a href="{html.escape(href, quote=True)}">{inner}</a>'


def paragraph_to_inline_html(p, rels: Dict[str, str], targets_map: Dict[str, str], current_filename: str,
                             footnotes_ctx=None, footnotes_db=None) -> str:
    """Render a Word paragraph (<w:p>) to inline HTML with:
      - bold/italic/underline
      - highlight + font color
      - hyperlinks (external + internal bookmarks)
      - bookmark anchors (<a id=...></a>)
      - field-code cross refs (REF/PAGEREF/HYPERLINK)
      - footnote refs (w:footnoteReference)
      - line breaks/tabs

    Important: To avoid Markdown interpreting characters like '*' in plain text,
    we wrap text segments in <span>...</span> and escape HTML safely.
    """

    def _span_txt(s: str) -> str:
        # escape_text_for_md_display already HTML-escapes and neutralizes MD meta chars
        return f"<span>{escape_text_for_md_display(s)}</span>"

    parts: List[str] = []

    # Complex field state machine
    in_field = False
    instr_buf: List[str] = []
    in_result = False
    result_buf: List[str] = []

    def _flush_field():
        nonlocal in_field, instr_buf, in_result, result_buf
        if not in_field:
            return
        instr = " ".join(instr_buf).strip()
        href = _href_from_field_instr(instr, rels, targets_map, current_filename)
        href = normalize_href_to_html(href)
        body = "".join(result_buf).strip()
        if href and body:
            parts.append(f'<a href="{html.escape(href, quote=True)}">{body}</a>')
        else:
            # If we can't resolve, fall back to the rendered result (or nothing)
            if body:
                parts.append(body)
        in_field = False
        in_result = False
        instr_buf = []
        result_buf = []

    # Iterate direct children of <w:p>
    for child in list(p):
        tag = etree.QName(child).localname

        # Bookmarks
        if tag == "bookmarkStart":
            name = child.get(f"{{{NS['w']}}}name")
            if name:
                parts.append(f'<a id="{sanitize_anchor_id(name)}"></a>')
            continue
        if tag == "bookmarkEnd":
            continue

        # Simple fields (w:fldSimple instr="...")
        if tag == "fldSimple":
            instr = child.get(f"{{{NS['w']}}}instr") or ""
            href = _href_from_field_instr(instr, rels, targets_map, current_filename)
            # fldSimple content is the visible result
            inner_parts: List[str] = []
            for r in child.findall(".//w:r", namespaces=NS):
                inner_parts.append(_run_to_inline_html(r, footnotes_ctx, footnotes_db, wrap_plain_in_span=True))
            body = "".join(inner_parts).strip()
            if href and body:
                parts.append(f'<a href="{html.escape(href, quote=True)}">{body}</a>')
            else:
                parts.append(body)
            continue

        # Runs
        if tag == "r":
            # Field chars inside run?
            fldChar = child.find("w:fldChar", namespaces=NS)
            if fldChar is not None:
                ftype = fldChar.get(f"{{{NS['w']}}}fldCharType")
                if ftype == "begin":
                    _flush_field()
                    in_field = True
                    instr_buf = []
                    in_result = False
                    result_buf = []
                elif ftype == "separate":
                    # start reading displayed result
                    in_result = True
                elif ftype == "end":
                    _flush_field()
                continue

            instrText = child.find("w:instrText", namespaces=NS)
            if instrText is not None and in_field and not in_result:
                instr_buf.append(instrText.text or "")
                continue

            # When we are in result part of a field, render runs into result buffer
            if in_field and in_result:
                result_buf.append(_run_to_inline_html(child, footnotes_ctx, footnotes_db, wrap_plain_in_span=True))
                continue

            # Normal run
            parts.append(_run_to_inline_html(child, footnotes_ctx, footnotes_db, wrap_plain_in_span=True))
            continue

        # Hyperlink elements can wrap runs
        if tag == "hyperlink":
            anchor = child.get(f"{{{NS['w']}}}anchor")
            rid = child.get(f"{{{NS['r']}}}id")
            href = None
            if rid and rid in rels:
                href = rels[rid]
            elif anchor:
                target_file = targets_map.get(anchor)
                a_id = sanitize_anchor_id(anchor)
                if (not target_file) or (target_file == current_filename):
                    href = f"#{a_id}"
                else:
                    href = f"{target_file}#{a_id}"

            href = normalize_href_to_html(href)

            inner_parts: List[str] = []
            for r in child.findall(".//w:r", namespaces=NS):
                inner_parts.append(_run_to_inline_html(r, footnotes_ctx, footnotes_db, wrap_plain_in_span=True))
            body = "".join(inner_parts).strip()
            if href and body:
                parts.append(f'<a href="{html.escape(href, quote=True)}">{body}</a>')
            else:
                parts.append(body)
            continue

        # Anything else: ignore (or could recurse)
        # Note: we do NOT auto-flush field here to allow fields spanning multiple children.

    # End paragraph: flush unfinished field if any
    _flush_field()

    # Join and clean up
    out = "".join(parts)
    # Word sometimes yields empty spans; keep output minimal
    return out


def _href_from_field_instr(instr: str, rels: Dict[str, str], targets_map: Dict[str, str], current_filename: str) -> \
        Optional[str]:
    """Convert a Word field instruction string to an href when possible.

    Supports common cross-reference patterns:
      - REF Bookmark \\h
      - PAGEREF Bookmark \\h
      - HYPERLINK "http://..."
      - HYPERLINK \\l Bookmark
    """
    if not instr:
        return None
    s = str(instr).strip()

    # HYPERLINK field
    if re.search(r"\bHYPERLINK\b", s, flags=re.I):
        # external link in quotes
        m = re.search(r'HYPERLINK\s+\"([^\"]+)\"', s, flags=re.I)
        if m:
            return m.group(1)
        # internal link: \l Bookmark (may be quoted or not)
        m = re.search(r"HYPERLINK\s+\\l\s+\"([^\"]+)\"", s, flags=re.I)
        if not m:
            m = re.search(r"HYPERLINK\s+\\l\s+([^\s\\]+)", s, flags=re.I)
        if m:
            bm = m.group(1)
            anchor_id = sanitize_anchor_id(bm)
            target_file = targets_map.get(bm)
            if target_file and target_file != current_filename:
                return f"{target_file}#{anchor_id}"
            return f"#{anchor_id}"
        return None

    # REF / PAGEREF (cross-reference to bookmark)
    m = re.search(r"\bREF\s+([^\s\\]+)", s, flags=re.I)
    if not m:
        m = re.search(r"\bPAGEREF\s+([^\s\\]+)", s, flags=re.I)
    if m:
        bm = m.group(1)
        anchor_id = sanitize_anchor_id(bm)
        target_file = targets_map.get(bm)
        if target_file and target_file != current_filename:
            return f"{target_file}#{anchor_id}"
        return f"#{anchor_id}"

    return None


def _extract_image_rIds_from_p(p) -> List[str]:
    # both wp:inline and wp:anchor contain a:blip with r:embed
    rids: List[str] = []
    blips = p.xpath(".//a:blip", namespaces=NS)
    for b in blips:
        rid = b.get(etree.QName(NS["r"], "embed"))
        if rid:
            rids.append(rid)
    return rids


def _extract_images_from_p_with_dims(p) -> List[Tuple[str, Optional[int], Optional[int]]]:
    """Return (rId, width_px, height_px) for images referenced in this paragraph.

    We read wp:extent (cx, cy) to preserve resized dimensions from the DOCX.
    """
    out: List[Tuple[str, Optional[int], Optional[int]]] = []
    drawings = p.xpath(".//w:drawing", namespaces=NS)
    for d in drawings:
        blip = d.find(".//a:blip", namespaces=NS)
        if blip is None:
            continue
        rid = blip.get(etree.QName(NS["r"], "embed"))
        if not rid:
            continue
        wpx = hpx = None
        extent = d.find(".//wp:extent", namespaces=NS)
        if extent is not None:
            cx = extent.get("cx")
            cy = extent.get("cy")
            if cx and cy:
                try:
                    wpx = _emu_to_px(int(cx))
                    hpx = _emu_to_px(int(cy))
                except Exception:
                    wpx = hpx = None
        out.append((rid, wpx, hpx))

    # Legacy VML images (common inside tables): <w:pict><v:shape ...><v:imagedata r:id="rIdX"/></v:shape></w:pict>
    # We also try to read width/height from the VML shape style (pt/px/in/cm/mm).
    def _vml_style_dim_px(style_str: Optional[str]) -> Tuple[Optional[int], Optional[int]]:
        if not style_str:
            return (None, None)
        s = str(style_str)

        def _len_to_px(token: str) -> Optional[int]:
            token = token.strip().lower()
            m = re.match(r"^([0-9]*\.?[0-9]+)\s*(px|pt|in|cm|mm)?$", token)
            if not m:
                return None
            v = float(m.group(1))
            unit = m.group(2) or "px"
            if unit == "px":
                return int(round(v))
            if unit == "pt":
                return int(round(v * 96.0 / 72.0))
            if unit == "in":
                return int(round(v * 96.0))
            if unit == "cm":
                return int(round(v * 96.0 / 2.54))
            if unit == "mm":
                return int(round(v * 96.0 / 25.4))
            return None

        wpx = hpx = None
        # style: "width:123pt;height:45pt" or "margin-left:...;width:..."
        for part in s.split(";"):
            if ":" not in part:
                continue
            k, v = part.split(":", 1)
            k = k.strip().lower()
            v = v.strip()
            if k == "width":
                wpx = _len_to_px(v) or wpx
            elif k == "height":
                hpx = _len_to_px(v) or hpx
        return (wpx, hpx)

    try:
        imagedatas = p.xpath(".//w:pict//v:imagedata", namespaces=NS)
    except Exception:
        imagedatas = []
    for imd in imagedatas:
        rid = imd.get(etree.QName(NS["r"], "id")) or imd.get(etree.QName(NS["r"], "embed"))
        if not rid:
            # sometimes o:relid
            rid = imd.get(etree.QName(NS.get("o", ""), "relid")) if NS.get("o") else None
        if not rid:
            continue
        # dims from nearest v:shape or v:rect style
        shape = imd.getparent()
        # climb a little to find a v:shape
        for _ in range(4):
            if shape is None:
                break
            if etree.QName(shape).localname in ("shape", "rect"):
                break
            shape = shape.getparent()
        wpx = hpx = None
        if shape is not None:
            wpx, hpx = _vml_style_dim_px(shape.get("style"))
        out.append((rid, wpx, hpx))

    if not out:
        for rid in _extract_image_rIds_from_p(p):
            out.append((rid, None, None))
    return out


# -------------------------
# List fallback by style name
# -------------------------

_LIST_STYLE_RE = re.compile(r"(liste|list)")


def infer_list_from_style(style_id: Optional[str]) -> Tuple[int, Optional[str]]:
    """Fallback when w:numPr is absent.

    Returns (list_level, list_type) where:
      - list_level: 0 means not a list; otherwise 1..9
      - list_type: 'unordered' or 'ordered'
    """
    if not style_id:
        return 0, None
    s = unidecode(style_id).lower().replace(" ", "")
    if not _LIST_STYLE_RE.search(s):
        return 0, None

    # Determine level from digits in style name (Liste3puces -> 3)
    lvl = 1
    m = re.search(r"(\d+)", s)
    if m:
        try:
            lvl = max(1, min(9, int(m.group(1))))
        except Exception:
            lvl = 1

    # Determine ordered/unordered
    if any(k in s for k in ("puce", "bullet", "bulle")):
        return lvl, "unordered"
    if any(k in s for k in ("num", "number", "numero", "décimal", "decimal")):
        return lvl, "ordered"

    # As a conservative fallback: treat 'liste' as unordered
    return lvl, "unordered"


def resolve_list_type_from_numbering(numbering: Numbering, numId: int, ilvl: int) -> Optional[str]:
    abs_id = numbering.numId_to_abs.get(str(numId))
    lvl_def = (numbering.abs_levels.get(abs_id or "", {}) or {}).get(int(ilvl))
    fmt = (lvl_def.num_fmt if lvl_def else "bullet")
    fmt_l = (fmt or "bullet").lower()
    return "unordered" if fmt_l == "bullet" else "ordered"


# -------------------------
# MkDocs files
# -------------------------

def generate_focus_assets(docs_dir: str, config: dict) -> None:
    js_dir = os.path.join(docs_dir, "javascripts")
    css_dir = os.path.join(docs_dir, "stylesheets")
    create_directory(js_dir)
    create_directory(css_dir)

    code_cfg = (config.get("code", {}) or {}) if isinstance(config, dict) else {}

    def _pick(v, theme_var, default_val):
        if v is None:
            return default_val
        s = str(v).strip()
        if s.lower() in ("theme", "default", ""):
            return theme_var
        return s

    rich_lh_css = _pick(code_cfg.get("rich_line_height", "theme"), "var(--md-code-line-height, 1.4)", "var(--md-code-line-height, 1.4)")
    rich_ff_css = _pick(code_cfg.get("rich_font_family", "theme"), "var(--md-code-font-family)", "var(--md-code-font-family)")
    rich_fs_css = _pick(code_cfg.get("rich_font_size", "theme"), "var(--md-code-font-size)", "var(--md-code-font-size)")

    img_shadow_cfg = (config.get("images", {}) or {}).get("shadow", {}) if isinstance(config, dict) else {}
    if not isinstance(img_shadow_cfg, dict):
        img_shadow_cfg = {}
    img_shadow_enabled = bool(img_shadow_cfg.get("enabled", True))
    img_shadow_radius = str(img_shadow_cfg.get("border_radius", "8px"))
    img_shadow_zoomable = str(img_shadow_cfg.get("zoomable", "0 8px 24px rgba(0,0,0,.28), 0 18px 60px rgba(0,0,0,.20)"))
    img_shadow_zoomable_hover = str(img_shadow_cfg.get("zoomable_hover", "0 12px 30px rgba(0,0,0,.32), 0 26px 80px rgba(0,0,0,.22)"))
    img_shadow_lightbox = str(img_shadow_cfg.get("lightbox", "0 24px 80px rgba(0,0,0,.65)"))

    frame_cfg = (config.get("frame", {}) or {}) if isinstance(config, dict) else {}
    if not isinstance(frame_cfg, dict):
        frame_cfg = {}
    frame_bg_light = str(frame_cfg.get("header_bg_light", "") or "").strip()
    frame_bg_dark = str(frame_cfg.get("header_bg_dark", "") or "").strip()
    frame_border_light = str(frame_cfg.get("header_top_border_light", "") or "").strip()
    frame_border_dark = str(frame_cfg.get("header_top_border_dark", "") or "").strip()
    frame_border_width = str(frame_cfg.get("header_top_border_width", "0px") or "0px").strip()

    copy_style_cfg = code_cfg.get("copy_style", {}) if isinstance(code_cfg, dict) else {}
    if not isinstance(copy_style_cfg, dict):
        copy_style_cfg = {}
    copy_btn_css = str(copy_style_cfg.get("btn", "position: absolute; top: .4rem; right: .4rem; padding: .15rem .5rem; font-size: .72rem; line-height: 1.2; border-radius: .4rem; border: 1px solid rgba(0,0,0,.2); background: rgba(255,255,255,.85); cursor: pointer; z-index: 5;"))
    copy_btn_hover_css = str(copy_style_cfg.get("btn_hover", "background: rgba(255,255,255,.98);"))
    copy_btn_copied_css = str(copy_style_cfg.get("btn_copied", "opacity: .85;"))
    copy_container_css = str(copy_style_cfg.get("container", "position: relative;"))

    css_parts = [
        "body.md-focus .md-sidebar--primary { display: none; } ",
        "body.md-focus .md-sidebar--secondary { display: none; } ",
        "body.md-focus .md-content { max-width: 95%; margin: 0 auto; } ",
        ".focus-toggle-btn { cursor: pointer; margin-left: 0.5rem; } ",
        f".odt-table img {{ max-width: 100%; height: auto; }} ",
        f".docx-zoomable {{ cursor: zoom-in; box-shadow: {img_shadow_zoomable if img_shadow_enabled else 'none'}; border-radius: {img_shadow_radius}; transition: transform .15s ease, box-shadow .15s ease; }} ",
        f".docx-zoomable:hover {{ transform: scale(1.02); box-shadow: {img_shadow_zoomable_hover if img_shadow_enabled else 'none'}; }} ",
        ".docx-lightbox-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.78); display: none; align-items: center; justify-content: center; z-index: 9999; padding: 24px; } ",
        ".docx-lightbox-overlay.is-open { display: flex; } ",
        ".docx-lightbox-content { position: relative; max-width: 96vw; max-height: 96vh; } ",
        f".docx-lightbox-img {{ max-width: 96vw; max-height: 96vh; width: auto; height: auto; display: block; box-shadow: {img_shadow_lightbox if img_shadow_enabled else 'none'}; border-radius: {img_shadow_radius}; cursor: zoom-out; }} ",
        ".docx-lightbox-close { position: absolute; top: -14px; right: -14px; width: 36px; height: 36px; border-radius: 999px; border: 0; background: rgba(255,255,255,0.92); box-shadow: 0 4px 18px rgba(0,0,0,0.35); font-size: 22px; line-height: 36px; text-align: center; cursor: pointer; } ",
        ".docx-lightbox-close:hover { background: #ffffff; } ",
        "body.docx-lightbox-open { overflow: hidden; } ",
        f".docx-code-rich pre, .docx-code-rich code, .docx-code-rich .docx-code-line {{ font-family: {rich_ff_css}; font-size: {rich_fs_css}; line-height: {rich_lh_css}; }} ",
        ".docx-code-rich pre { margin: 0.75em 0; } ",
        ".docx-code-rich code { display: block; } ",
        ".docx-code-rich .docx-code-line { display: block; } ",
        ".docx-code-rich[data-linenums='true'] .docx-code-line::before { counter-increment: docxline; content: counter(docxline); display: inline-block; width: 3em; margin-right: 1em; opacity: 0.6; text-align: right; } ",
        ".docx-code-rich[data-linenums='false'] .docx-code-line::before { content: ''; } ",
        ".md-content ol { list-style-type: decimal; } ",
        ".md-content ol ol { list-style-type: lower-alpha; } ",
        ".md-content ol ol ol { list-style-type: lower-roman; } ",
        f".docx-code-copy-btn {{ {copy_btn_css} }} ",
        f".docx-code-copy-btn:hover {{ {copy_btn_hover_css} }} ",
        f".docx-code-copy-btn.is-copied {{ {copy_btn_copied_css} }} ",
        f".docx-code-copy-container {{ {copy_container_css} }} ",
    ]

    def _header_rule(selector: str, bg: str, border: str) -> str:
        props = []
        if bg:
            props.append(f"background-color: {bg};")
        if border:
            props.append(f"border-top: {frame_border_width} solid {border};")
        return f"{selector} {{ {' '.join(props)} }} " if props else ""

    css_parts.append(_header_rule('[data-md-color-scheme="default"] .md-header', frame_bg_light, frame_border_light))
    css_parts.append(_header_rule('[data-md-color-scheme="slate"] .md-header', frame_bg_dark or frame_bg_light, frame_border_dark or frame_border_light))

    with open(os.path.join(css_dir, "focus.css"), "w", encoding="utf-8") as f:
        f.write("".join(css_parts))

    with open(os.path.join(js_dir, "focus.js"), "w", encoding="utf-8") as f:
        f.write(
            """document.addEventListener("DOMContentLoaded",function(){
  // Focus toggle button (existing behavior)
  if(!document.querySelector(".focus-toggle-btn")){
    var e=document.createElement("label");
    e.className="md-icon md-header__button focus-toggle-btn";
    e.title="Mode Focus";
    e.innerHTML='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5m12 7h-3v2h5v-5h-2v3M14 5v2h3v3h2V5h-5Z"></path></svg>';
    var t=document.querySelector(".md-header__inner");
    if(t) t.appendChild(e);
    e.addEventListener("click",function(){
      document.body.classList.toggle("md-focus");
    });
  }

  // Image lightbox (DOCX): all images with .docx-zoomable are clickable
  if(document.querySelector(".docx-lightbox-overlay")) return;

  var overlay=document.createElement("div");
  overlay.className="docx-lightbox-overlay";
  overlay.setAttribute("role","dialog");
  overlay.setAttribute("aria-modal","true");

  var content=document.createElement("div");
  content.className="docx-lightbox-content";

  var img=document.createElement("img");
  img.className="docx-lightbox-img";
  img.alt="Image agrandie";

  var closeBtn=document.createElement("button");
  closeBtn.className="docx-lightbox-close";
  closeBtn.type="button";
  closeBtn.setAttribute("aria-label","Fermer");
  closeBtn.textContent="×";

  content.appendChild(closeBtn);
  content.appendChild(img);
  overlay.appendChild(content);
  document.body.appendChild(overlay);

  function openLightbox(src){
    if(!src) return;
    img.src=src;
    overlay.classList.add("is-open");
    document.body.classList.add("docx-lightbox-open");
  }
  function closeLightbox(){
    overlay.classList.remove("is-open");
    document.body.classList.remove("docx-lightbox-open");
    img.removeAttribute("src");
  }

  document.addEventListener("click",function(ev){
    var target=ev.target;
    if(target && target.classList && target.classList.contains("docx-zoomable")){
      ev.preventDefault();
      openLightbox(target.getAttribute("src"));
    }
  });

  closeBtn.addEventListener("click",function(ev){
    ev.preventDefault();
    closeLightbox();
  });

  overlay.addEventListener("click",function(ev){
    if(ev.target===overlay){
      closeLightbox();
    }
  });

  document.addEventListener("keydown",function(ev){
    if(ev.key==="Escape" && overlay.classList.contains("is-open")){
      closeLightbox();
    }
  });

  img.addEventListener("click",function(){
    closeLightbox();
  });
});"""
        )

        code_cfg = (config.get("code", {}) or {})
        if bool(code_cfg.get("copy_button", False)):
            copy_label = str(code_cfg.get("copy_label", "Copier"))
            copied_label = str(code_cfg.get("copy_copied_label", code_cfg.get("copied_label", "Copié")))
            copy_label_js = copy_label.replace("\\", "\\\\").replace("\"", "\\\"")
            copied_label_js = copied_label.replace("\\", "\\\\").replace("\"", "\\\"")

            only_recognized = bool(code_cfg.get("copy_only_recognized_language", True))
            min_lines = int(code_cfg.get("copy_min_lines", 0) or 0)
            pyg_heur = bool(code_cfg.get("copy_allow_pygments_heuristic", True))

            f.write(f"""
// --- Copy-to-clipboard for code blocks (DOCX converter) ---
(function() {{
  var COPY_LABEL = "{copy_label_js}";
  var COPIED_LABEL = "{copied_label_js}";
  var ONLY_RECOGNIZED = {str(only_recognized).lower()};
  var MIN_LINES = {min_lines};
  var PYGMENTS_HEURISTIC = {str(pyg_heur).lower()};

  var EXCLUDED = {{ "text":1, "plaintext":1, "plain":1, "txt":1, "none":1, "pygments":0, "hljs":0 }};

  function fallbackCopyText(text) {{
    var ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.left = "-9999px";
    ta.style.top = "0";
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    try {{ document.execCommand("copy"); }} catch (e) {{}}
    document.body.removeChild(ta);
  }}

  function copyText(text) {{
    if (navigator.clipboard && navigator.clipboard.writeText) {{
      return navigator.clipboard.writeText(text).catch(function () {{
        fallbackCopyText(text);
      }});
    }}
    fallbackCopyText(text);
    return Promise.resolve();
  }}

  function setButtonState(btn) {{
    if (!btn) return;
    btn.textContent = COPIED_LABEL;
    btn.classList.add("is-copied");
    window.setTimeout(function () {{
      btn.textContent = COPY_LABEL;
      btn.classList.remove("is-copied");
    }}, 1200);
  }}

  function getLangFromClass(el) {{
    if (!el || !el.classList) return "";
    for (var i = 0; i < el.classList.length; i++) {{
      var c = el.classList[i];
      if (c.indexOf("language-") === 0) return c.substring(9);
    }}
    return "";
  }}

  function detectLanguage(el) {{
    if (!el) return "";
    var code = el.querySelector ? (el.querySelector("code") || null) : null;
    var pre = el.querySelector ? (el.querySelector("pre") || null) : null;

    var lang = "";
    if (code) lang = getLangFromClass(code);
    if (!lang && pre) lang = getLangFromClass(pre);
    if (!lang) lang = getLangFromClass(el);

    if (!lang) {{
      var cur = el;
      for (var k = 0; k < 4 && cur; k++) {{
        lang = getLangFromClass(cur);
        if (lang) break;
        cur = cur.parentElement;
      }}
    }}
    if (!lang) {{
      var attr = (code && (code.getAttribute("data-language") || code.getAttribute("data-lang"))) ||
                 (pre && (pre.getAttribute("data-language") || pre.getAttribute("data-lang"))) ||
                 (el.getAttribute && (el.getAttribute("data-language") || el.getAttribute("data-lang")));
      if (attr) lang = attr;
    }}
    if (!lang && PYGMENTS_HEURISTIC) {{
      var hasTok = el.querySelector && el.querySelector("span.k,span.kt,span.kn,span.kd,span.kc,span.nb,span.nc,span.nn,span.nf,span.nt,span.na,span.s,span.s1,span.s2,span.mi,span.mf,span.c,span.c1,span.cp,span.o");
      if (hasTok) lang = "pygments";
    }}
    return (lang || "").toLowerCase();
  }}

  function countLinesFromText(text) {{
    if (!text) return 0;
    text = text.replace(/\s+$/,"");
    if (!text) return 0;
    return text.split("\n").length;
  }}

  function extractCodeFromRich(container) {{
    var code = container.querySelector("pre code") || container.querySelector("code") || container.querySelector("pre");
    if (!code) return "";
    var clone = code.cloneNode(true);
    clone.querySelectorAll(".docx-code-lineno,.lineno,.linenos,.linenodiv,td.linenos,span.linenos,.hljs-ln-numbers").forEach(function (n) {{
      n.remove();
    }});
    return (clone.textContent || "").replace(/\s+$/,"");
  }}

  function extractCodeGeneric(scope) {{
    var table = scope.closest ? scope.closest("table.highlighttable") : null;
    if (table) {{
      var codeCell = table.querySelector("td.code pre, td.code code, td.code");
      if (codeCell) {{
        return (codeCell.textContent || "").replace(/\s+$/,"");
      }}
    }}

    var pre = scope.tagName && scope.tagName.toLowerCase() === "pre" ? scope : (scope.querySelector ? (scope.querySelector("pre") || scope) : scope);
    var code = pre && pre.querySelector ? (pre.querySelector("code") || pre) : pre;
    if (!code) return "";

    var clone = code.cloneNode(true);
    clone.querySelectorAll(".docx-code-lineno,.lineno,.linenos,.linenodiv,td.linenos,span.linenos,.hljs-ln-numbers").forEach(function (n) {{
      n.remove();
    }});
    return (clone.textContent || "").replace(/\s+$/,"");
  }}

  function shouldAddButton(container, codeText, opts) {{
    opts = opts || {{}};
    if (!container) return false;
    if (container.querySelector && container.querySelector(".docx-code-copy-btn")) return false;
    if (opts.force) return true;
    if (!opts.rich) {{
      var lang = detectLanguage(container);
      if (ONLY_RECOGNIZED) {{
        if (!lang || EXCLUDED[lang]) return false;
      }}
    }}
    if (MIN_LINES && MIN_LINES > 0) {{
      var n = countLinesFromText(codeText);
      if (n < MIN_LINES) return false;
    }}
    return true;
  }}

  function ensureButton(container, extractor, opts) {{
    if (!container) return;
    opts = opts || {{}};
    var text = extractor(container);
    if (!shouldAddButton(container, text, opts)) return;
    container.classList.add("docx-code-copy-container");
    var btn = container.querySelector(".docx-code-copy-btn");
    if (btn && btn.getAttribute("data-copy-bound") === "1") return;
    if (!btn) {{
      btn = document.createElement("button");
      btn.className = "docx-code-copy-btn";
      btn.type = "button";
      btn.textContent = COPY_LABEL;
      container.appendChild(btn);
    }} else {{
      btn.classList.add("docx-code-copy-btn");
      if (!btn.type) btn.type = "button";
      if (!btn.textContent) btn.textContent = COPY_LABEL;
    }}
    btn.setAttribute("data-copy-bound","1");
    btn.addEventListener("click", function (ev) {{
      ev.preventDefault();
      ev.stopPropagation();
      var t = extractor(container);
      copyText(t).then(function () {{
        setButtonState(btn);
      }});
    }});
  }}

  function initCopy(root) {{
    var scope = root || document;
    scope.querySelectorAll(".docx-code-rich").forEach(function (block) {{
      ensureButton(block, extractCodeFromRich, {{ rich: true }});
    }});
    scope.querySelectorAll("table.highlighttable").forEach(function (tbl) {{
      var cell = tbl.querySelector("td.code");
      if (!cell) return;
      var wrap = null;
      if (tbl.closest) {{
        wrap = tbl.closest("div.highlight, div.codehilite");
      }}
      if (!wrap) wrap = tbl;
      ensureButton(wrap, function () {{ return extractCodeGeneric(cell); }});
    }});
    scope.querySelectorAll("div.highlight, div.codehilite").forEach(function (wrap) {{
      if (wrap.querySelector && wrap.querySelector("table.highlighttable")) return;
      ensureButton(wrap, function () {{ return extractCodeGeneric(wrap); }});
    }});
    scope.querySelectorAll("pre").forEach(function (pre) {{
      if (pre.closest && pre.closest(".docx-code-rich")) return;
      if (pre.closest && pre.closest("table.highlighttable")) return;
      var container = pre;
      var p = pre.parentElement;
      if (p && p.classList && (p.classList.contains("highlight") || p.classList.contains("codehilite"))) {{
        container = p;
      }} else if (pre.classList && (pre.classList.contains("highlight") || pre.classList.contains("codehilite"))) {{
        container = pre;
      }} else if (pre.closest) {{
        var wrap = pre.closest("div.highlight,div.codehilite,figure.highlight,figure.codehilite");
        if (wrap) container = wrap;
      }}
      ensureButton(container, function () {{ return extractCodeGeneric(container); }});
    }});
  }}

  function boot() {{
    try {{ initCopy(document); }} catch (e) {{}}
  }}

  if (document.readyState === "loading") {{
    document.addEventListener("DOMContentLoaded", boot);
  }} else {{
    boot();
  }}

  if (window.document$ && typeof window.document$.subscribe === "function") {{
    window.document$.subscribe(function () {{
      window.setTimeout(boot, 0);
    }});
  }} else {{
    var target = document.querySelector(".md-content");
    if (target && window.MutationObserver) {{
      var mo = new MutationObserver(function () {{
        window.setTimeout(boot, 0);
      }});
      mo.observe(target, {{ childList: true, subtree: true }});
    }}
  }}
}})();
""")

def generate_mkdocs_yml(nav_structure: List[dict], config_data: dict) -> None:
    mk = config_data.get("mkdocs", {}) or {}
    use_dir = "true" if mk.get("use_directory_urls", False) else "false"

    out = ""
    out += f"site_name: \"{mk.get('site_name', 'My Site')}\"\n"
    out += f"site_url: \"{mk.get('site_url', '')}\"\n"
    out += f"site_description: \"{mk.get('site_description', '')}\"\n"
    out += f"site_author: \"{mk.get('site_author', '')}\"\n"
    out += f"use_directory_urls: {use_dir}\n"
    out += f"repo_url: \"{mk.get('repo_url', '')}\"\n"
    out += f"repo_name: \"{mk.get('repo_name', 'GitHub')}\"\n\n"

    theme_conf = mk.get("theme", {}) or {}
    out += "theme:\n"
    out += f"  name: {theme_conf.get('name', 'material')}\n"
    if "custom_dir" in theme_conf:
        out += f"  custom_dir: {theme_conf['custom_dir']}\n"
    if "features" in theme_conf:
        out += "  features:\n"
        for f in theme_conf["features"]:
            out += f"    - {f}\n"
    if "palette" in theme_conf:
        out += "  palette:\n"
        for pal in theme_conf["palette"]:
            out += "    - media: \"" + pal.get("media", "") + "\"\n"
            out += "      scheme: " + pal.get("scheme", "default") + "\n"
            out += "      primary: " + pal.get("primary", "teal") + "\n"
            out += "      accent: " + pal.get("accent", "purple") + "\n"
            if "toggle" in pal:
                out += "      toggle:\n"
                out += "        icon: " + pal["toggle"].get("icon", "") + "\n"
                out += "        name: " + pal["toggle"].get("name", "") + "\n"
    out += "\n"

    md_exts = mk.get("markdown_extensions", []) or []
    if md_exts:
        out += "markdown_extensions:\n"
        for ext in md_exts:
            if isinstance(ext, str):
                out += f"  - {ext}\n"
            elif isinstance(ext, dict):
                for k, v in ext.items():
                    out += f"  - {k}:\n"
                    if isinstance(v, dict):
                        for sub_k, sub_v in v.items():
                            val = str(sub_v).lower() if isinstance(sub_v, bool) else sub_v
                            out += f"      {sub_k}: {val}\n"
    out += "\n"
    out += "extra_javascript: [javascripts/focus.js]\n"
    out += "extra_css: [stylesheets/focus.css]\n\n"

    out += "nav:\n"
    for item in nav_structure:
        for k, v in item.items():
            safe_key = json.dumps(k, ensure_ascii=False)
            out += f"  - {safe_key}: {v}\n"

    extra_data = config_data.get("extra", {}) or {}
    if extra_data:
        out += "\nextra:\n"
        if "analytics" in extra_data:
            an = extra_data["analytics"]
            out += (
                "  analytics:\n"
                f"    provider: {an.get('provider', 'google')}\n"
                f"    property: {an.get('property', '')}\n"
            )

    with open("mkdocs.yml", "w", encoding="utf-8") as f:
        f.write(out)


# -------------------------
# Audit & reporting
# -------------------------
def write_audit(audit: dict, out_json: str = "audit.json", out_txt: str = "audit.txt") -> None:
    with open(out_json, "w", encoding="utf-8") as f:
        json.dump(audit, f, ensure_ascii=False, indent=2)

    # a small readable summary
    try:
        lines = []
        lines.append(f"Version: {VERSION}")
        lines.append(f"Paragraphs: {audit.get('counts', {}).get('paragraphs', 0)}")
        lines.append(f"Tables: {audit.get('counts', {}).get('tables', 0)}")
        lines.append(f"Images (blips): {audit.get('counts', {}).get('image_blips', 0)}")
        lines.append(f"Headings detected (raw): {audit.get('counts', {}).get('headings_raw', 0)}")
        lines.append(f"Min heading level detected (raw): {audit.get('heading', {}).get('min_level_raw')}")
        lines.append(f"Rebase offset applied: {audit.get('heading', {}).get('rebase_offset')}")
        lines.append("\nTop paragraph styles:")
        for s, n in (audit.get('top_styles', []) or [])[:20]:
            lines.append(f"  - {s}: {n}")
        lines.append("\nList paragraphs:")
        lp = audit.get('lists', {})
        lines.append(f"  - with numPr: {lp.get('with_numpr', 0)}")
        lines.append(f"  - by style fallback: {lp.get('by_style', 0)}")
        lines.append(f"  - not recognized: {lp.get('unrecognized', 0)}")
        with open(out_txt, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + "\n")
    except Exception:
        pass


_LEVELS = {"error": 3, "warn": 2, "info": 1}


class ReportLogger:
    """Collects report entries and aggregates repetitive info (notably list-by-style fallbacks)."""

    def __init__(self, report_level: str = "info", verbose: bool = False) -> None:
        self.threshold = _LEVELS.get((report_level or "info").lower(), 1)
        self.verbose = bool(verbose)
        self.entries: List[Tuple[str, str]] = []  # (level, message)
        self.list_style_fallback_counts: Dict[Tuple[str, int, str], int] = {}
        self.block_ignored_counts: Dict[str, int] = {}

    def _add(self, level: str, msg: str) -> None:
        lvl = (level or "info").lower()
        if lvl not in _LEVELS:
            lvl = "info"
        self.entries.append((lvl, msg))

    def info(self, msg: str) -> None:
        if self.verbose:
            self._add("info", msg)

    def warn(self, msg: str) -> None:
        self._add("warn", msg)

    def error(self, msg: str) -> None:
        self._add("error", msg)

    def count_list_fallback(self, style_id: Optional[str], level: int, list_type: Optional[str]) -> None:
        s = style_id or "(no-style)"
        t = list_type or "unordered"
        key = (s, int(level or 1), t)
        self.list_style_fallback_counts[key] = self.list_style_fallback_counts.get(key, 0) + 1

    def count_ignored_block(self, tag: str) -> None:
        if not tag:
            return
        self.block_ignored_counts[tag] = self.block_ignored_counts.get(tag, 0) + 1

    def write(self, path: str = "report.txt") -> None:
        lines: List[str] = []

        if self.list_style_fallback_counts:
            lines.append("[SUMMARY] Listes détectées via fallback \"par style\" (agrégé)")
            for (style, lvl, t), n in sorted(
                    self.list_style_fallback_counts.items(),
                    key=lambda kv: (-kv[1], kv[0][0], kv[0][1], kv[0][2]),
            ):
                lines.append(f"  - {style} -> level={lvl} type={t}: {n}")

        if self.block_ignored_counts:
            lines.append("\n[SUMMARY] Blocs Word ignorés (agrégé)")
            for tag, n in sorted(self.block_ignored_counts.items(), key=lambda kv: (-kv[1], kv[0])):
                lines.append(f"  - <w:{tag}>: {n}")

        filtered = [
            (lvl, msg)
            for (lvl, msg) in self.entries
            if _LEVELS.get(lvl, 1) >= self.threshold
        ]
        if filtered:
            lines.append("\n[DETAIL]")
            for lvl, msg in filtered:
                lines.append(f"[{lvl.upper()}] {msg}")

        if not lines:
            lines = ["Aucune anomalie détectée."]

        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + "\n")


# -------------------------
# Main conversion
# -------------------------

def convert_docx_to_site(docx_file: str, config_file: str, *, audit_only: bool = False, max_h1: Optional[int] = None,
                         start_h1: int = 1, report_out: str = "report.txt", report_level: str = "info",
                         verbose: bool = False, audit_out_json: str = "audit.json",
                         audit_out_txt: str = "audit.txt") -> None:
    print(f"--- DOCX to MkDocs Converter {VERSION} ---")
    if not os.path.exists(docx_file):
        raise SystemExit(f"Erreur: fichier introuvable: {docx_file}")

    config = load_configuration(config_file)
    code_cfg = (config.get("code", {}) or {}) if isinstance(config, dict) else {}
    copy_button_enabled = bool(code_cfg.get("copy_button", False))
    copy_label = str(code_cfg.get("copy_label", "Copier"))
    debug = bool(config.get("debug", False))
    logger = ReportLogger(report_level=report_level, verbose=verbose)

    with zipfile.ZipFile(docx_file, "r") as z:
        numbering = parse_numbering(z)
        styles = parse_styles(z)
        rels = parse_relationships(z)
        footnotes_db = parse_footnotes_db(z)
        doc_xml = _zip_read_text(z, "word/document.xml")
        if not doc_xml:
            raise SystemExit("Erreur: word/document.xml introuvable dans le DOCX.")
        root = etree.fromstring(doc_xml)
        body = root.find(".//w:body", namespaces=NS)
        if body is None:
            raise SystemExit("Erreur: w:body introuvable dans le DOCX.")

        # ---------- PASS A: audit / detect heading rebasing ----------
        style_counts: Dict[str, int] = {}
        counts = {"paragraphs": 0, "tables": 0, "image_blips": 0, "headings_raw": 0}
        list_counts = {"with_numpr": 0, "by_style": 0, "unrecognized": 0}
        heading_levels_raw: List[int] = []

        for child in body:
            tag = etree.QName(child).localname
            if tag == "p":
                counts["paragraphs"] += 1
                style_id = _p_style(child) or "(no-style)"
                style_counts[style_id] = style_counts.get(style_id, 0) + 1
                # headings
                h1 = _heading_from_style(style_id if style_id != "(no-style)" else None)
                ol = _outline_level(child)
                lvl = None
                if h1 and ol:
                    lvl = min(h1, ol)
                else:
                    lvl = h1 or ol
                if lvl and lvl > 0:
                    counts["headings_raw"] += 1
                    heading_levels_raw.append(int(lvl))
                # lists
                numId, ilvl, start_ov = _p_num_info(child, styles)
                if numId is not None and ilvl is not None:
                    list_counts["with_numpr"] += 1
                else:
                    lvl2, t2 = infer_list_from_style(style_id if style_id != "(no-style)" else None)
                    if lvl2 > 0 and t2:
                        list_counts["by_style"] += 1
                    else:
                        # If it looks like a list paragraph (style contains list) but not recognized, count it.
                        s_clean = unidecode(style_id).lower()
                        if "liste" in s_clean or "list" in s_clean:
                            list_counts["unrecognized"] += 1

                # images
                counts["image_blips"] += len(_extract_image_rIds_from_p(child))
            elif tag == "tbl":
                # Audit pass counts tables unconditionally (no start_h1 gating here).
                counts["tables"] += 1

        min_level_raw = min(heading_levels_raw) if heading_levels_raw else None
        rebase_offset = 0
        if min_level_raw and min_level_raw > 1:
            rebase_offset = min_level_raw - 1

        audit = {
            "version": VERSION,
            "file": os.path.basename(docx_file),
            "counts": counts,
            "lists": list_counts,
            "heading": {
                "min_level_raw": min_level_raw,
                "rebase_offset": rebase_offset,
            },
            "top_styles": sorted(style_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:50],
        }

        if audit_only:
            write_audit(audit, out_json=audit_out_json, out_txt=audit_out_txt)
            print(f"Audit terminé: {audit_out_json} / {audit_out_txt}")
            return

        # ---------- PASS 1: scan bookmarks -> target files ----------
        # We map each bookmark name to the markdown file that will contain it, so we can
        # generate correct cross-page links (same approach as the ODT converter V366).
        targets_map: Dict[str, str] = {}
        current_scan_file = "index.md"

        def _compute_heading_level(p_el) -> Optional[int]:
            sid = _p_style(p_el)
            h_by_style = _heading_from_style(sid) if sid else None
            h_by_outline = _outline_level(p_el)
            lvl0 = None
            if h_by_style and h_by_outline:
                lvl0 = min(h_by_style, h_by_outline)
            else:
                lvl0 = h_by_style or h_by_outline
            if not lvl0 or lvl0 <= 0:
                return None
            lvl = int(lvl0) - int(rebase_offset or 0)
            return lvl if lvl > 0 else None

        for blk in body:
            t = etree.QName(blk).localname
            if t == "p":
                lvl = _compute_heading_level(blk)
                if lvl == 1:
                    ttl = _p_plain_text(blk)
                    if ttl:
                        current_scan_file = generate_safe_filename(ttl)
                for bm in blk.xpath(".//w:bookmarkStart", namespaces=NS):
                    name = bm.get(etree.QName(NS["w"], "name"))
                    # Word uses special internal bookmarks like "_Toc..." or "_GoBack"
                    if name and not str(name).startswith("_"):
                        targets_map[str(name)] = current_scan_file
            elif t == "tbl":
                # bookmarks inside tables belong to the current_scan_file
                for bm in blk.xpath(".//w:bookmarkStart", namespaces=NS):
                    name = bm.get(etree.QName(NS["w"], "name"))
                    if name and not str(name).startswith("_"):
                        targets_map[str(name)] = current_scan_file

        # ---------- Prepare output folders ----------
        if os.path.exists("docs"):
            shutil.rmtree("docs", ignore_errors=True)
        create_directory("docs")
        create_directory("docs/images")
        create_directory("overrides/partials/integrations")

        # Copy static files
        for f in config.get("files_to_copy", []) or []:
            if os.path.exists(f):
                try:
                    shutil.copy(f, os.path.join("docs", os.path.basename(f)))
                    print(f"Copié : {f}")
                except Exception as e:
                    logger.warn(f"Erreur copie {f}: {e}")

        # Assets + overrides
        generate_focus_assets("docs", config)
        with open("overrides/partials/footer.html", "w", encoding="utf-8") as f:
            f.write(config.get("footer", "<footer></footer>"))

        analytics_content = """{% if config.extra.analytics %}
  <script async src=\"https://www.googletagmanager.com/gtag/js?id={{ config.extra.analytics.property }}\"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    var siteName = \"{{ config.site_name | replace('\\"', '\\\\\"') }}\";
    gtag('config', '{{ config.extra.analytics.property }}', {
        'page_title': siteName
    });
  </script>
{% endif %}"""
        with open("overrides/partials/integrations/analytics.html", "w", encoding="utf-8") as f:
            f.write(analytics_content)
        with open("overrides/partials/analytics.html", "w", encoding="utf-8") as f:
            f.write(analytics_content)

        media_map = extract_media(z, "docs/images")

        # ---------- Table rendering (supports nested tables) ----------
        def render_table_html(tbl_el, *, nested: bool = False) -> str:
            """Render a <w:tbl> element to HTML, preserving nested tables.

            IMPORTANT: never use descendant selectors like `.//w:tr` here.
            That would pull rows from nested tables and "merge" them into the
            outer table. We only consider direct children (`./w:tr`, `./w:tc`).
            """

            html_lines: List[str] = [
                '<table class="odt-table" style="border-collapse: collapse; width: 100%; margin-bottom: 1em;">'
            ]

            for tr in tbl_el.xpath("./w:tr", namespaces=NS):
                html_lines.append("<tr>")
                for tc in tr.xpath("./w:tc", namespaces=NS):
                    html_lines.append('<td style="border: 1px solid #ddd; padding: 8px; vertical-align: top;">')

                    cell_parts: List[str] = []

                    # Iterate cell blocks in document order (paragraphs + nested tables)
                    code_cfg = config.get("code", {}) or {}
                    cell_code_buf: List[str] = []
                    cell_code_buf_html: List[Optional[str]] = []
                    cell_code_start = 1
                    cell_code_linenums = False
                    cell_code_rich = False

                    def _flush_cell_code():
                        nonlocal cell_code_buf, cell_code_buf_html, cell_code_start, cell_code_linenums, cell_code_rich
                        if not cell_code_buf:
                            return
                        full = "\n".join(cell_code_buf)
                        lang = detect_language(full, code_cfg)
                        if cell_code_rich and any(x is not None for x in cell_code_buf_html):
                            lines_html: List[str] = []
                            for i, plain in enumerate(cell_code_buf):
                                hl = cell_code_buf_html[i] if i < len(cell_code_buf_html) else None
                                lines_html.append(html.escape(plain) if hl is None else hl)
                        else:
                            lines_html = [html.escape(x) for x in cell_code_buf]
                        cell_parts.append(
                            render_rich_code_block(
                                lines_html,
                                lang,
                                start_line=cell_code_start,
                                show_linenums=cell_code_linenums,
                                copy_button=copy_button_enabled,
                                copy_label=copy_label,
                            )
                        )
                        cell_code_buf = []
                        cell_code_buf_html = []
                        cell_code_start = 1
                        cell_code_linenums = False
                        cell_code_rich = False

                    # List handling inside table cells
                    cell_list_stack: List[Dict[str, object]] = []

                    def _close_cell_lists(target_level: int = 0) -> None:
                        nonlocal cell_list_stack, cell_parts
                        while len(cell_list_stack) > target_level:
                            top = cell_list_stack.pop()
                            if top.get("li_open"):
                                cell_parts.append("</li>")
                            cell_parts.append(f"</{top.get('tag')}>")

                    def _start_cell_list_item(level: int, list_type: Optional[str]) -> None:
                        nonlocal cell_list_stack, cell_parts
                        tag = "ol" if (list_type == "ordered") else "ul"

                        if len(cell_list_stack) > level:
                            _close_cell_lists(level)

                        # If the list type changes at the current level (e.g. bullet -> numbered),
                        # close the current list at that level and reopen with the correct tag.
                        if len(cell_list_stack) == level and level > 0:
                            current_tag = str(cell_list_stack[-1].get("tag", "")) if cell_list_stack else ""
                            if current_tag and current_tag != tag:
                                _close_cell_lists(level - 1)

                        while len(cell_list_stack) < level:
                            # Nested lists must live inside a parent <li>
                            if cell_list_stack and not cell_list_stack[-1].get("li_open"):
                                cell_parts.append("<li>")
                                cell_list_stack[-1]["li_open"] = True
                            cell_parts.append(f"<{tag}>")
                            cell_list_stack.append({"tag": tag, "li_open": False})

                        if not cell_list_stack:
                            return
                        if cell_list_stack[-1].get("li_open"):
                            cell_parts.append("</li><li>")
                        else:
                            cell_parts.append("<li>")
                            cell_list_stack[-1]["li_open"] = True

                    for blk in list(tc):
                        btag = etree.QName(blk).localname

                        if btag == "p":
                            style_p = _p_style(blk)
                            style_p_l = unidecode(style_p).lower() if style_p else ""
                            is_code_p = is_code_paragraph_by_style(style_p, code_cfg)

                            if is_code_p:
                                # Determine if numbered code block
                                numId_c, ilvl_c, _start_ov_c = _p_num_info(blk, styles)
                                list_type_c = None
                                if numId_c is not None and ilvl_c is not None:
                                    list_type_c = resolve_list_type_from_numbering(numbering, int(numId_c), int(ilvl_c))
                                numbered = bool(list_type_c == "ordered") or (
                                        "num" in style_p_l or "numero" in style_p_l)

                                if not cell_code_buf:
                                    cell_code_start = 1
                                    cell_code_linenums = bool(numbered)
                                    cell_code_rich = False
                                    cell_code_buf_html = []

                                cell_code_buf.append(_p_text_preserve(blk))
                                rich = paragraph_has_rich_code_formatting(blk)
                                if rich:
                                    cell_code_rich = True
                                    cell_code_buf_html.append(runs_to_rich_code_html_line(blk))
                                else:
                                    cell_code_buf_html.append(None)
                                continue

                            # not code
                            _flush_cell_code()

                            # Determine list level/type
                            numId_l, ilvl_l, _start_ov_l = _p_num_info(blk, styles)
                            list_level_l = 0
                            list_type_l: Optional[str] = None
                            if numId_l is not None and ilvl_l is not None:
                                list_level_l = int(ilvl_l) + 1
                                list_type_l = resolve_list_type_from_numbering(numbering, int(numId_l), int(ilvl_l))
                                # In table cells, some DOCX list styles expose numbering through the style
                                # while numbering.xml metadata may be incomplete or ambiguous. Keep the same
                                # practical behavior as outside tables: a clearly numbered style name wins.
                                if (list_type_l != "ordered") and ("num" in style_p_l or "numero" in style_p_l):
                                    list_type_l = "ordered"
                            else:
                                lvl2, t2 = infer_list_from_style(style_p)
                                if lvl2 > 0:
                                    list_level_l = lvl2
                                    list_type_l = t2 or "unordered"
                                    if (list_type_l != "ordered") and ("num" in style_p_l or "numero" in style_p_l):
                                        list_type_l = "ordered"

                            t = paragraph_to_inline_html(blk, rels, targets_map, current_filename, footnotes_ctx,
                                                         footnotes_db).strip()
                            bg_l = _p_background_css(blk, styles)
                            if bg_l and t:
                                t = f'<div style="background-color:{bg_l}; padding:0.35em 0.5em;">{t}</div>'
                            if list_level_l > 0:
                                _start_cell_list_item(list_level_l, list_type_l)
                                if t:
                                    cell_parts.append(t)
                            else:
                                _close_cell_lists(0)
                                if t:
                                    cell_parts.append(f"<p style='margin:0;'>{t}</p>")

                            # Images inside paragraph
                            for rid, wpx, hpx in _extract_images_from_p_with_dims(blk):
                                target = rels.get(rid)
                                if not target:
                                    logger.warn(f"Image rId introuvable dans rels (table): {rid}")
                                    continue
                                target_full = ("word/" + target.lstrip("./")) if not target.startswith(
                                    "word/") else target
                                target_full = target_full.replace("\\", "/")
                                src_img = media_map.get(target_full)
                                if not src_img:
                                    logger.warn(f"Cible image non extraite (table): {target_full} (rId={rid})")
                                    continue
                                style = "max-width:100%;height:auto;"
                                if wpx and hpx:
                                    style = f"width:{wpx}px;height:{hpx}px;"
                                elif wpx and not hpx:
                                    style = f"width:{wpx}px;height:auto;"
                                elif hpx and not wpx:
                                    style = f"height:{hpx}px;width:auto;"
                                cell_parts.append(
                                    f'<img src="{src_img}" class="docx-zoomable" style="{style}" alt="Image" />')

                        elif btag == "tbl":
                            # Nested table
                            _flush_cell_code()
                            _close_cell_lists(0)
                            cell_parts.append(render_table_html(blk, nested=True))

                    _flush_cell_code()
                    _close_cell_lists(0)
                    if not cell_parts:
                        cell_parts.append("&nbsp;")
                    html_lines.append("".join(cell_parts))
                    html_lines.append("</td>")

                html_lines.append("</tr>")

            html_lines.append("</table>")
            return "\n".join(html_lines)

        # ---------- Output state ----------
        toc_cfg = config.get("toc", {}) or {}
        home_label = toc_cfg.get("home_label", "Accueil")

        nav = [{home_label: "index.md"}]
        current_filename = "index.md"
        current_file = open(os.path.join("docs", current_filename), "w", encoding="utf-8")
        footnotes_ctx = init_footnotes_ctx()

        counters = [0] * 10
        found_any_h1 = False
        h1_pages_emitted = 0
        h1_seen = 0
        emitting = (start_h1 <= 1)

        code_buf: List[str] = []
        code_buf_html: List[Optional[str]] = []
        code_has_rich = False
        code_start_line = 1
        code_has_linenums = False

        list_seq: Dict[Tuple[str, int], int] = {}  # (numId, ilvl) -> current number for ordered lists

        # HTML list emission (robust for DOCX multi-level numbering and restarts).
        # We emit raw HTML lists (<ol>/<ul>) so that:
        #   - ordered lists can preserve their real start value via <ol start="N">
        #   - mixing ordered/unordered and deep nesting remains stable
        #   - we avoid Markdown list edge cases when inline HTML (<span>...) is present
        #
        # Stack items: {"tag": "ol"/"ul", "li_open": bool, "start": int|None, "last_num": int|None}
        list_stack: List[Dict[str, object]] = []

        def _close_open_lists(target_level: int = 0) -> None:
            """Close HTML lists down to target_level (0 closes all)."""
            nonlocal list_stack
            while len(list_stack) > target_level:
                top = list_stack.pop()
                if top.get("li_open"):
                    current_file.write("</li>\n")
                current_file.write(f"</{top.get('tag')}>\n")

        def _ensure_list_container(level: int, tag: str, start: Optional[int] = None) -> None:
            """Ensure list containers exist up to `level` (1..n)."""
            nonlocal list_stack
            # Close deeper levels (if any)
            while len(list_stack) > level:
                top = list_stack.pop()
                if top.get("li_open"):
                    current_file.write("</li>\n")
                current_file.write(f"</{top.get('tag')}>\n")

            # If the current level exists, ensure the tag (ol/ul) matches.
            # Also handle ordered-list restarts: if the list is already open at this level but the
            # current item number changed (start != existing), we close/reopen the <ol> with a new start.
            if len(list_stack) == level and list_stack:
                cur_tag = str(list_stack[-1].get("tag") or "")
                cur_start = list_stack[-1].get("start") if cur_tag == "ol" else None

                need_restart = (tag == "ol" and start is not None and int(start) != int(cur_start or 1))

                if (cur_tag and cur_tag != tag) or need_restart:
                    top = list_stack.pop()
                    if top.get("li_open"):
                        current_file.write("</li>\n")
                    current_file.write(f"</{cur_tag}>\n")
            # Open missing levels
            while len(list_stack) < level:
                # Nested lists must live inside an open <li> of the parent list.
                if list_stack and not list_stack[-1].get("li_open"):
                    current_file.write("<li>\n")
                    list_stack[-1]["li_open"] = True

                attrs = ""
                if tag == "ol" and start is not None and len(list_stack) == (level - 1):
                    attrs = f' start="{int(start)}"'
                current_file.write(f"<{tag}{attrs}>\n")
                list_stack.append({"tag": tag, "li_open": False,
                                   "start": (int(start) if (tag == "ol" and start is not None) else None)})

        def _start_list_item(level: int, list_type: str, start: Optional[int] = None) -> None:
            tag = "ol" if list_type == "ordered" else "ul"
            _ensure_list_container(level, tag, start=start)

            # Start (or continue) the <li> at this level
            top = list_stack[-1]
            if top.get("li_open"):
                current_file.write("</li>\n<li>")
            else:
                current_file.write("<li>")
                top["li_open"] = True

        def _list_start_from_defs(numId: int, ilvl: int, start_ov: Optional[int]) -> int:
            # Determine start value for a (numId, ilvl) taking overrides into account.
            if start_ov is not None:
                return int(start_ov)
            ov = numbering.num_overrides.get((str(numId), int(ilvl)))
            if ov is not None:
                return int(ov.start)
            abs_id = numbering.numId_to_abs.get(str(numId), "")
            ld = numbering.abs_levels.get(abs_id, {}).get(int(ilvl))
            return int(ld.start) if ld else 1

        def _get_ordered_list_num(numId: int, ilvl: int, start_ov: Optional[int]) -> int:
            # Return current number for this list level, initializing if needed (and honoring restarts).
            key = (str(numId), int(ilvl))
            if key not in list_seq:
                list_seq[key] = _list_start_from_defs(numId, ilvl, start_ov)
            else:
                if start_ov is not None:
                    list_seq[key] = int(start_ov)
            return int(list_seq.get(key, 1))

        def _reset_deeper_ordered_levels(numId: int, ilvl: int) -> None:
            # When a parent list item advances, all deeper levels of the same numbering stream
            # must restart from their definition the next time they appear. Without this reset,
            # nested mixed lists can incorrectly continue from values used under a previous parent.
            base_num = str(numId)
            cur_lvl = int(ilvl)
            for key in list(list_seq.keys()):
                k_num, k_lvl = key
                if k_num == base_num and int(k_lvl) > cur_lvl:
                    del list_seq[key]

        target_styles = [
            unidecode(s).lower().strip()
            for s in (config.get("document_title", {}).get("style_names", []) or [])
        ]

        def flush_code(indent_level: int = 0) -> None:
            nonlocal code_buf, code_buf_html, code_has_rich, code_start_line, code_has_linenums, emitting
            if not emitting:
                return
            if not code_buf:
                return
            full = "\n".join(code_buf)
            lang = detect_language(full, config.get("code", {}) or {})

            indent = "    " * max(0, indent_level)

            if code_has_rich and any(x is not None for x in code_buf_html):
                lines_html: List[str] = []
                for i, plain in enumerate(code_buf):
                    hl = code_buf_html[i] if i < len(code_buf_html) else None
                    lines_html.append(html.escape(plain) if hl is None else hl)
                block_html = render_rich_code_block(
                    lines_html, lang, start_line=code_start_line, show_linenums=code_has_linenums,
                    copy_button=copy_button_enabled, copy_label=copy_label
                )
                block_html = "\n".join((indent + ln) if ln.strip() else ln for ln in block_html.splitlines())
                current_file.write("\n" + block_html + "\n\n")
            else:
                fence = get_fence(full)
                header = f"{indent}{fence}{lang}" + (f' linenums="{code_start_line}"' if code_has_linenums else "")
                current_file.write("\n" + header + "\n")
                for line in code_buf:
                    current_file.write(f"{indent}{line}\n")
                current_file.write(f"{indent}{fence}\n\n")

            code_buf = []
            code_buf_html = []
            code_has_rich = False
            code_start_line = 1
            code_has_linenums = False

        def write_paragraph(md: str, list_level: int, list_type: Optional[str],
                            border_top: Optional[str] = None, border_bottom: Optional[str] = None,
                            paragraph_bg: Optional[str] = None,
                            ordered_num: Optional[int] = None, force_html_ol: bool = False) -> None:
            nonlocal emitting
            if not emitting:
                return

            if not (md or "").strip() and not (border_top or border_bottom):
                return

            # Leave list context
            if list_level == 0:
                if list_stack:
                    _close_open_lists(0)
                    current_file.write("\n")

                # Paragraph-level borders/background
                if border_top or border_bottom or paragraph_bg:
                    styles = []
                    if border_top:
                        styles.append(f"border-top:{border_top}")
                    if border_bottom:
                        styles.append(f"border-bottom:{border_bottom}")
                    if paragraph_bg:
                        styles.append(f"background-color:{paragraph_bg}")
                        styles.append("padding:0.35em 0.5em")
                    else:
                        styles.append("padding:0.35em 0")
                    styles.append("margin:0.75em 0")
                    current_file.write(f"<div style=\"{' ; '.join(styles)}\">{md}</div>\n\n")
                else:
                    current_file.write(md + "\n\n")
                return

            # List item (emit as raw HTML for robustness)
            if list_type:
                start_val = int(ordered_num) if (list_type == "ordered" and ordered_num is not None) else None
                _start_list_item(int(list_level), str(list_type), start=start_val)

                if border_top or border_bottom or paragraph_bg:
                    styles = []
                    if border_top:
                        styles.append(f"border-top:{border_top}")
                    if border_bottom:
                        styles.append(f"border-bottom:{border_bottom}")
                    if paragraph_bg:
                        styles.append(f"background-color:{paragraph_bg}")
                        styles.append("padding:0.35em 0.5em")
                    else:
                        styles.append("padding:0.35em 0")
                    styles.append("margin:0.35em 0")
                    current_file.write(f'<div style="{' ; '.join(styles)}">{md}</div>')
                else:
                    current_file.write(f"{md}")
                return

            # If list_level>0 but list_type missing: fallback to normal paragraph
            if list_stack:
                _close_open_lists(0)
                current_file.write("\n")
            current_file.write(md + "\n\n")

        def new_page_for_h1(title_plain: str, title_md: str, num_str: str) -> None:
            nonlocal current_file, current_filename, h1_pages_emitted, footnotes_ctx
            write_footnotes(current_file, footnotes_ctx)
            current_file.close()
            footnotes_ctx = init_footnotes_ctx()
            fname = generate_safe_filename(title_plain)
            current_filename = fname
            current_file = open(os.path.join("docs", fname), "w", encoding="utf-8")
            current_file.write(f"# {num_str}. {title_md}\n\n")
            nav.append({f"{num_str}. {title_plain}": fname})
            h1_pages_emitted += 1

        # ---------- PASS B: generate ----------
        for child in body:
            tag = etree.QName(child).localname

            if tag == "p":
                style_id = _p_style(child)
                style_id_norm = unidecode(style_id).lower().strip() if style_id else ""

                # Determine heading level (raw then rebased)
                lvl_style = _heading_from_style(style_id)
                lvl_outline = _outline_level(child)
                heading_lvl = None
                if lvl_style and lvl_outline:
                    heading_lvl = min(lvl_style, lvl_outline)
                else:
                    heading_lvl = lvl_style or lvl_outline

                if heading_lvl and heading_lvl > 0 and rebase_offset:
                    heading_lvl = max(1, int(heading_lvl) - int(rebase_offset))

                # Determine list info
                numId, ilvl, start_ov = _p_num_info(child, styles)
                list_level = 0
                list_type: Optional[str] = None
                list_mode = None
                if numId is not None and ilvl is not None:
                    list_level = int(ilvl) + 1
                    list_type = resolve_list_type_from_numbering(numbering, int(numId), int(ilvl))
                    list_mode = "numPr"
                else:
                    lvl2, t2 = infer_list_from_style(style_id)
                    if lvl2 > 0 and t2:
                        list_level, list_type = lvl2, t2
                        list_mode = "style"

                # Debug (optional)
                if debug and not found_any_h1:
                    preview = (paragraph_to_inline_html(child, rels, targets_map, current_filename, footnotes_ctx,
                                                        footnotes_db) or "")[:80]
                    print(
                        f"[DEBUG PRE-H1] style={style_id} heading={heading_lvl} rebase={rebase_offset} "
                        f"numId={numId} ilvl={ilvl} list={list_mode}:{list_level}/{list_type} txt='{preview}...'"
                    )

                # Document title (styled)
                if style_id_norm and style_id_norm in target_styles:
                    txt = paragraph_to_inline_html(child, rels, targets_map, current_filename, footnotes_ctx,
                                                   footnotes_db)
                    css = (config.get("document_title", {}) or {}).get("css", "")
                    if emitting:
                        current_file.write(f'<p style="{css}">{txt}</p>\n\n')
                    continue

                # Headings
                if heading_lvl and heading_lvl > 0:
                    # V59: align with ODT V405.
                    # When a heading immediately follows a list (no blank paragraph in between),
                    # ensure all list contexts are closed and numbering state is reset before
                    # writing the heading. Otherwise the heading can be absorbed by the last list item.
                    if list_stack:
                        _close_open_lists(0)
                        current_file.write("\n")
                    flush_code(0)
                    _close_open_lists(0)
                    list_seq.clear()
                    title_plain = etree.tostring(child, method="text", encoding="unicode").strip()
                    title_plain = re.sub(r"\s+", " ", title_plain)
                    title_md = paragraph_to_inline_html(child, rels, targets_map, current_filename, footnotes_ctx,
                                                        footnotes_db) or escape_markdown_text(title_plain)

                    # counters
                    counters[int(heading_lvl) - 1] += 1
                    for i in range(int(heading_lvl), 10):
                        counters[i] = 0
                    # Build heading numbering string (e.g. 1, 1.2, 1.2.3)
                    if int(heading_lvl) > 1:
                        num_str = ".".join(str(counters[i]) for i in range(int(heading_lvl)))
                    else:
                        num_str = str(counters[0])

                    if int(heading_lvl) == 1:
                        found_any_h1 = True
                        h1_seen += 1

                        # Skip everything until we reach the requested H1 (start_h1),
                        # but keep counters so numbering remains consistent.
                        if h1_seen < start_h1:
                            code_buf = []
                            if list_stack:
                                _close_open_lists(0)
                            continue

                        if not emitting:
                            emitting = True

                        # Stop if max-h1 exceeded (before creating the page)
                        if max_h1 is not None and h1_pages_emitted >= max_h1:
                            logger.warn(
                                f"Arrêt anticipé (--start-h1={start_h1}, --max-h1={max_h1}) "
                                f"après {h1_pages_emitted} pages H1 générées."
                            )
                            break

                        new_page_for_h1(title_plain, title_md, num_str)

                    else:
                        current_file.write(f"{'#' * int(heading_lvl)} {num_str}. {title_md}\n\n")
                    continue

                # Code paragraphs
                if emitting and is_code_paragraph_by_style(style_id, config.get("code", {}) or {}):
                    this_is_rich = paragraph_has_rich_code_formatting(child)

                    numbered = False
                    current_num = None

                    if list_mode == "numPr" and list_type == "ordered" and numId is not None and ilvl is not None:
                        key = (str(numId), int(ilvl))
                        if key not in list_seq:
                            # Base start comes from numbering.xml level definition, but Word may override it:
                            # - paragraph-level <w:numPr><w:start w:val="..."/> (restart at N)
                            # - numbering.xml <w:lvlOverride><w:startOverride w:val="..."/> (Set Numbering Value)
                            start_from = None
                            if start_ov is not None:
                                start_from = int(start_ov)
                            else:
                                ov = numbering.num_overrides.get((str(numId), int(ilvl)))
                                if ov is not None:
                                    start_from = int(ov.start)
                            if start_from is None:
                                abs_id = numbering.numId_to_abs.get(str(numId), "")
                                ld = numbering.abs_levels.get(abs_id, {}).get(int(ilvl))
                                start_from = int(ld.start) if ld else 1
                            list_seq[key] = int(start_from)
                        else:
                            # Paragraph-level restart at N should win even if we already tracked the sequence.
                            if start_ov is not None:
                                list_seq[key] = int(start_ov)
                        current_num = list_seq.get(key, 1)
                        numbered = True
                    else:
                        s_l = (unidecode(style_id).lower() if style_id else "")
                        if "num" in s_l or "numero" in s_l:
                            numbered = True
                            current_num = 1

                    if not code_buf:
                        code_start_line = int(current_num or 1)
                        code_has_linenums = bool(numbered)
                        code_has_rich = False
                        code_buf_html = []

                    line_txt = _p_text_preserve(child)
                    code_buf.append(line_txt)

                    if this_is_rich:
                        code_has_rich = True
                        code_buf_html.append(runs_to_rich_code_html_line(child))
                    else:
                        code_buf_html.append(None)

                    if list_mode == "numPr" and list_type == "ordered" and numId is not None and ilvl is not None:
                        key = (str(numId), int(ilvl))
                        if key in list_seq:
                            list_seq[key] += 1
                        _reset_deeper_ordered_levels(int(numId), int(ilvl))
                    continue

                # Flush code when leaving code block

                if code_buf:
                    flush_code(0)

                # Normal paragraph / list item
                md_text = paragraph_to_inline_html(child, rels, targets_map, current_filename, footnotes_ctx,
                                                   footnotes_db)

                border_top, border_bottom = _p_borders_css(child)
                paragraph_bg = _p_background_css(child, styles)

                ordered_num = None
                force_html_ol = False
                if list_mode == "numPr" and list_type == "ordered" and numId is not None and ilvl is not None:
                    # Track the current logical number for this DOCX numbering stream.
                    # We only emit <ol start="N"> when opening/reopening an ordered list container
                    # (for example after a normal paragraph broke the list) or when Word explicitly
                    # restarts numbering at a given value. While the same <ol> stays open, the browser
                    # must continue numbering naturally so nested mixed lists remain stable.
                    key = (str(numId), int(ilvl))
                    if key not in list_seq:
                        start_from = None
                        if start_ov is not None:
                            start_from = int(start_ov)
                        else:
                            ov = numbering.num_overrides.get((str(numId), int(ilvl)))
                            if ov is not None:
                                start_from = int(ov.start)
                        if start_from is None:
                            abs_id = numbering.numId_to_abs.get(str(numId), "")
                            ld = numbering.abs_levels.get(abs_id, {}).get(int(ilvl))
                            start_from = int(ld.start) if ld else 1
                        list_seq[key] = int(start_from)
                    elif start_ov is not None:
                        # Paragraph-level restart overrides the tracked sequence.
                        list_seq[key] = int(start_ov)

                    current_num = int(list_seq.get(key, 1))
                    same_ordered_container_open = (
                            len(list_stack) >= int(list_level)
                            and str(list_stack[int(list_level) - 1].get("tag") or "") == "ol"
                    )
                    if (not same_ordered_container_open) or (start_ov is not None):
                        ordered_num = current_num
                        force_html_ol = (int(ordered_num or 1) != 1)

                # Images after paragraph
                img_refs = _extract_images_from_p_with_dims(child)
                imgs: List[str] = []
                for rid, wpx, hpx in img_refs:
                    target = rels.get(rid)
                    if not target:
                        logger.warn(f"Image rId introuvable dans rels: {rid}")
                        continue
                    if not target.startswith("word/"):
                        target_full = "word/" + target.lstrip("./")
                    else:
                        target_full = target
                    target_full = target_full.replace("\\", "/")
                    src = media_map.get(target_full)
                    if not src:
                        logger.warn(f"Cible image non extraite: {target_full} (rId={rid})")
                        continue
                    style = "max-width:100%;height:auto;"
                    if wpx and hpx:
                        style = f"width:{wpx}px;height:{hpx}px;"
                    elif wpx and not hpx:
                        style = f"width:{wpx}px;height:auto;"
                    elif hpx and not wpx:
                        style = f"height:{hpx}px;width:auto;"
                    imgs.append(f'<img src="{src}" class="docx-zoomable" style="{style}" alt="Image" />')

                # Report list fallback usage (useful on large docs)
                if list_mode == "style":
                    logger.count_list_fallback(style_id, list_level, list_type)
                    logger.info(f"Liste par style: style='{style_id}' -> level={list_level} type={list_type}")

                if imgs:
                    if list_level > 0 and list_type:
                        # Keep images inside the same list item to preserve structure.
                        combined = md_text.strip()
                        if combined:
                            combined += "<br>"
                        combined += "<br>".join(imgs)
                        write_paragraph(combined, list_level, list_type, border_top, border_bottom, paragraph_bg,
                                        ordered_num=ordered_num, force_html_ol=force_html_ol)
                    else:
                        if md_text.strip():
                            write_paragraph(md_text, list_level, list_type, border_top, border_bottom, paragraph_bg,
                                            ordered_num=ordered_num, force_html_ol=force_html_ol)
                        for im in imgs:
                            write_paragraph(im, 0, None)
                else:
                    write_paragraph(md_text, list_level, list_type, border_top, border_bottom, paragraph_bg,
                                    ordered_num=ordered_num, force_html_ol=force_html_ol)

                # Advance ordered-list sequence for numPr ordered lists (so restarts/continuations are consistent)
                if list_mode == "numPr" and list_type == "ordered" and numId is not None and ilvl is not None:
                    key = (str(numId), int(ilvl))
                    # ensure initialization + honor restart
                    cur_n = _get_ordered_list_num(int(numId), int(ilvl), start_ov)
                    list_seq[key] = int(cur_n) + 1
                    _reset_deeper_ordered_levels(int(numId), int(ilvl))

            elif tag == "tbl":
                if not emitting:
                    continue
                flush_code(0)
                _close_open_lists(0)
                current_file.write(render_table_html(child) + "\n\n")
            else:
                # unsupported block types
                logger.count_ignored_block(tag)
                logger.info(f"Bloc ignoré: <w:{tag}>")

        # Final flush
        _close_open_lists(0)
        if code_buf:
            flush_code(0)
        write_footnotes(current_file, footnotes_ctx)
        current_file.close()

    # If no H1 at all, warn (and suggest checking styles)
    if not audit.get("heading", {}).get("min_level_raw"):
        logger.warn("Aucun titre détecté (Heading/Titre/outlineLvl). Vérifier les styles Word.")

    # Persist audit + report
    write_audit(audit, out_json=audit_out_json, out_txt=audit_out_txt)
    logger.write(report_out)

    generate_mkdocs_yml(nav, config)
    print(f"Terminé. ({audit_out_json}, {audit_out_txt}, {report_out} générés)")


def main(argv: List[str]) -> int:
    print(f"--- DOCX to MkDocs Converter {VERSION} ---")
    ap = argparse.ArgumentParser(add_help=True)
    ap.add_argument("docx", help="Fichier .docx à convertir")
    ap.add_argument("config", help="config.py ou config.json")
    ap.add_argument("--audit", action="store_true", help="Analyse uniquement (ne génère pas le site)")
    ap.add_argument("--max-h1", type=int, default=None, help="Limiter la génération aux N premiers titres H1")
    ap.add_argument("--start-h1", type=int, default=1,
                    help="Commencer la génération à partir du K-ième titre H1 (1=depuis le début)")
    ap.add_argument("--report-out", default="report.txt", help="Chemin du rapport (défaut: report.txt)")
    ap.add_argument("--report-level", default="info", choices=["info", "warn", "error"],
                    help="Niveau de détail du rapport")
    ap.add_argument("--verbose", action="store_true", help="Inclure les lignes INFO détaillées dans le report")
    ap.add_argument("--audit-out-json", default="audit.json", help="Chemin de sortie audit.json")
    ap.add_argument("--audit-out-txt", default="audit.txt", help="Chemin de sortie audit.txt")
    args = ap.parse_args(argv)

    convert_docx_to_site(
        args.docx,
        args.config,
        audit_only=args.audit,
        max_h1=args.max_h1,
        start_h1=max(1, int(args.start_h1)),
        report_out=args.report_out,
        report_level=args.report_level,
        verbose=args.verbose,
        audit_out_json=args.audit_out_json,
        audit_out_txt=args.audit_out_txt,
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
