import sys
import os
import shutil
import re
import zipfile
import json
import html
import textwrap
import importlib.util
import io
from unidecode import unidecode
from odf.opendocument import load
from odf import text, table, draw, style

# --- CONFIGURATION ---
VERSION = "V404"


# --- UTILITAIRES ---

def load_configuration(config_path):
    default_config = {
        "toc": {
            "home_label": "Accueil"
        },
        "mkdocs": {
            "site_name": "Documentation",
            "site_url": "",
            "repo_url": "",
            "repo_name": "GitHub"
        },
        "footer": "<footer>Pied de page</footer>",
        "code": {
            "language": "python",
            "style_keywords": ["code", "consolas", "source"],
            "default_language": "text",
            "detection_rules": {},
            "rich_line_height": "theme",
            "rich_font_family": "theme",
            "rich_font_size": "theme",
            "copy_button": False,
            "copy_label": "Copier"
        },
        "document_title": {
            "style_names": ["Title", "Titre", "Titre principal"],
            "css": "font-size: 28px; font-weight: bold; margin-bottom: 1em;"
        },
        "debug": True,
        "files_to_copy": [],
        "extra": {},
        "frame": {
            "header_bg_light": "",
            "header_bg_dark": "",
            "header_top_border_light": "",
            "header_top_border_dark": "",
            "header_top_border_width": "0px"
        }
    }

    if not os.path.exists(config_path):
        print(f"Fichier de configuration introuvable : {config_path}")
        return default_config

    try:
        user_config = {}

        # Support config.py ou config.json
        if config_path.endswith(".py"):
            try:
                spec = importlib.util.spec_from_file_location("user_config_module", config_path)
                module = importlib.util.module_from_spec(spec)
                config_dir = os.path.dirname(os.path.abspath(config_path))
                added_to_syspath = False
                if config_dir and config_dir not in sys.path:
                    sys.path.insert(0, config_dir)
                    added_to_syspath = True
                try:
                    spec.loader.exec_module(module)
                finally:
                    if added_to_syspath and config_dir in sys.path:
                        sys.path.remove(config_dir)
                if hasattr(module, "config") and isinstance(module.config, dict):
                    user_config = module.config
                else:
                    print(f"Attention: Le fichier {config_path} doit contenir un dictionnaire nommé 'config'.")
            except Exception as e:
                print(f"Erreur lors du chargement de {config_path} : {e}")
        else:
            with open(config_path, 'r', encoding='utf-8') as f:
                user_config = json.load(f)

        if "toc" in user_config and isinstance(user_config["toc"], dict):
            default_config["toc"].update(user_config["toc"])

        if "mkdocs" in user_config:
            default_config["mkdocs"].update(user_config["mkdocs"])

            if "markdown_extensions" in user_config["mkdocs"]:
                default_config["mkdocs"]["markdown_extensions"] = user_config["mkdocs"]["markdown_extensions"]

            # Vérifications extensions requises
            exts = default_config["mkdocs"].get("markdown_extensions", [])

            # md_in_html
            has_md_in_html = False
            for e in exts:
                if isinstance(e, str) and e == "md_in_html":
                    has_md_in_html = True
                elif isinstance(e, dict) and "md_in_html" in e:
                    has_md_in_html = True
            if not has_md_in_html:
                if isinstance(exts, list):
                    exts.append("md_in_html")
                else:
                    exts = ["md_in_html"]

            # footnotes
            has_footnotes = False
            for e in exts:
                if isinstance(e, str) and e == "footnotes":
                    has_footnotes = True
                elif isinstance(e, dict) and "footnotes" in e:
                    has_footnotes = True
            if not has_footnotes:
                if isinstance(exts, list):
                    exts.append("footnotes")
                else:
                    exts.append("footnotes")

            default_config["mkdocs"]["markdown_extensions"] = exts

            if "theme" in user_config["mkdocs"]:
                default_config["mkdocs"]["theme"] = user_config["mkdocs"]["theme"]

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

        return default_config

    except Exception as e:
        print(f"ERREUR lecture config: {e}. Utilisation des défauts.")
        return default_config


def create_directory(path):
    if not os.path.exists(path):
        os.makedirs(path)


def get_attribute_safe(node, attr_name):
    if hasattr(node, 'attributes'):
        for key, value in node.attributes.items():
            if isinstance(key, tuple):
                if key[1] == attr_name: return value
            elif key == attr_name:
                return value
    return None


def safe_int(val):
    try:
        return int(val)
    except:
        return None


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


def generate_safe_filename(raw_title):
    return re.sub(r'[^a-z0-9\-]', '', unidecode(raw_title.lower().replace(" ", "-"))) + ".md"


# --- NOTES DE BAS DE PAGE ---

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


def register_footnote(footnotes_ctx, body_text, explicit_id=None):
    if footnotes_ctx is None: return None
    if explicit_id:
        fid = sanitize_anchor_id(str(explicit_id))
        fid = fid if fid else None
    else:
        fid = None
    if not fid:
        footnotes_ctx["counter"] += 1
        fid = f"fn{footnotes_ctx['counter']}"
    if fid in footnotes_ctx.get("seen", set()): 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:
        if not body: body = ""
        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")


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


def build_code_span_style(style_name, bold_styles, italic_styles, underlined_styles, highlight_colors, text_colors):
    if not style_name: return ""
    styles = []
    if style_name in bold_styles: styles.append("font-weight:bold")
    if style_name in italic_styles: styles.append("font-style:italic")
    if style_name in underlined_styles: styles.append("text-decoration:underline")
    bg = highlight_colors.get(style_name)
    if bg: styles.append(f"background-color:{bg}")
    fg = text_colors.get(style_name)
    if fg: styles.append(f"color:{fg}")
    return ";".join(styles)


def node_has_rich_code_formatting(node, bold_styles, italic_styles, underlined_styles, highlight_colors,
                                  text_colors) -> bool:
    try:
        children = getattr(node, "childNodes", []) or []
    except Exception:
        return False
    for ch in children:
        if getattr(ch, "tagName", None) == "text:span":
            ss = get_attribute_safe(ch, 'style-name')
            if (ss in bold_styles) or (ss in italic_styles) or (ss in underlined_styles) or (
                    ss in highlight_colors) or (ss in text_colors):
                return True
            if node_has_rich_code_formatting(ch, bold_styles, italic_styles, underlined_styles, highlight_colors,
                                             text_colors):
                return True
        else:
            if node_has_rich_code_formatting(ch, bold_styles, italic_styles, underlined_styles, highlight_colors,
                                             text_colors):
                return True
    return False


def render_rich_code_block(lines_html, lang, start_line=1, show_linenums=False):
    lang_class = f"language-{lang}" if lang else "language-text"
    start = start_line if start_line and start_line > 0 else 1
    counter_reset = start - 1
    linenums_attr = "true" if show_linenums else "false"
    wrapped_lines = []
    for line in lines_html:
        wrapped_lines.append(f'<span class="odt-code-line">{line}</span>')
    joined = "\n".join(wrapped_lines)
    return (
        f'<div class="odt-code-rich" data-linenums="{linenums_attr}" style="counter-reset: odtline {counter_reset};">'
        f'<pre><code class="{lang_class}">\n{joined}\n</code></pre></div>'
    )


# --- BORDURES PARAGRAPHES ---

def normalize_odf_border_to_css(border_val):
    if not border_val: return None
    v = str(border_val).strip()
    if not v or v.lower() in ("none", "0", "0pt", "0cm"): return None
    m = re.match(
        r'^\s*([0-9]*\.?[0-9]+)\s*(pt|px|cm|mm|in)?\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+([^\s]+)\s*$',
        v, flags=re.I)
    if m:
        w, unit, style_name, color = m.group(1), (m.group(2) or 'pt'), m.group(3).lower(), m.group(4)
        return f"{w}{unit} {style_name} {color}"
    return v


def build_hr_for_border(border_css, where):
    if not border_css: return ""
    return f'<hr style="border:0; border-top:{border_css}; margin:0.75em 0;" data-odt-border="{where}" />\n'




def build_paragraph_block_html(inner_html, background_color=None, margin='0 0 0.75em 0'):
    styles = [f"margin:{margin}"]
    if background_color:
        styles.append(f"background-color:{background_color}")
        styles.append("padding:0.35em 0.6em")
    return f'<div style="{"; ".join(styles)}">{inner_html}</div>'


def html_list_stack_active_upto_level(list_number_state, level: int) -> bool:
    stack = list_number_state.get('html_list_stack') or {}
    for lvl in range(1, level + 1):
        st = stack.get(lvl)
        if st and st.get('open'):
            return True
    return False
# --- ANALYSE DES STYLES ---

def build_readable_style_map(doc):
    readable_map = {}
    sources = []
    if doc.automaticstyles: sources.extend(doc.automaticstyles.childNodes)
    if doc.styles: sources.extend(doc.styles.childNodes)

    for s in sources:
        if s.tagName == 'style:style':
            internal = get_attribute_safe(s, 'name')
            display = get_attribute_safe(s, 'display-name')
            if internal:
                final = display if display else internal
                readable_map[internal] = final
    return readable_map


def build_style_inheritance_map(doc):
    inheritance = {}
    sources = []
    if doc.automaticstyles: sources.extend(doc.automaticstyles.childNodes)
    if doc.styles: sources.extend(doc.styles.childNodes)

    for s in sources:
        if s.tagName == 'style:style':
            name = get_attribute_safe(s, 'name')
            parent = get_attribute_safe(s, 'parent-style-name')
            if name and parent:
                inheritance[name] = parent
    return inheritance


def get_formatting_styles_with_inheritance(doc, inheritance_map):
    bold_styles = set()
    italic_styles = set()
    underlined_styles = set()
    highlight_colors = {}
    text_colors = {}
    paragraph_borders = {}
    paragraph_backgrounds = {}

    sources = []
    if doc.automaticstyles: sources.extend(doc.automaticstyles.childNodes)
    if doc.styles: sources.extend(doc.styles.childNodes)

    for s in sources:
        if s.tagName == 'style:style':
            name = get_attribute_safe(s, 'name')
            if not name: continue

            name_lower = name.lower()
            if "bold" in name_lower or "gras" in name_lower:
                bold_styles.add(name)

            for child in s.childNodes:
                # Texte
                if child.tagName == 'style:text-properties':
                    fw = get_attribute_safe(child, 'font-weight')
                    fw_asian = get_attribute_safe(child, 'font-weight-asian')
                    fw_complex = get_attribute_safe(child, 'font-weight-complex')
                    is_bold = False
                    for w in [fw, fw_asian, fw_complex]:
                        if w and (w == 'bold' or w == '700' or w == '800' or w == '900'):
                            is_bold = True
                    if is_bold: bold_styles.add(name)

                    us = get_attribute_safe(child, 'text-underline-style')
                    ut = get_attribute_safe(child, 'text-underline-type')
                    uw = get_attribute_safe(child, 'text-underline-width')
                    is_ul = (us and us != 'none') or (ut and ut != 'none') or (uw and uw != '0pt' and uw != '0cm')
                    if is_ul: underlined_styles.add(name)

                    fs = get_attribute_safe(child, 'font-style')
                    fs_asian = get_attribute_safe(child, 'font-style-asian')
                    fs_complex = get_attribute_safe(child, 'font-style-complex')
                    is_italic = False
                    for f in [fs, fs_asian, fs_complex]:
                        if f and (f == 'italic' or f == 'oblique'):
                            is_italic = True
                    if is_italic: italic_styles.add(name)

                    bg = get_attribute_safe(child, 'background-color')
                    if bg and bg != 'transparent':
                        bg_lower = bg.lower()
                        if bg_lower not in ['#ffffff', '#fff', 'white', 'none']:
                            highlight_colors[name] = bg

                    fg = get_attribute_safe(child, 'color')
                    if fg and fg != 'auto' and fg != 'transparent':
                        text_colors[name] = fg

                # Paragraphe (Bordures)
                if child.tagName == 'style:paragraph-properties':
                    pbg = get_attribute_safe(child, 'background-color')
                    if pbg and pbg != 'transparent':
                        pbg_lower = str(pbg).lower()
                        if pbg_lower not in ['#ffffff', '#fff', 'white', 'none']:
                            paragraph_backgrounds[name] = pbg

                    border = get_attribute_safe(child, 'border')
                    border_top = get_attribute_safe(child, 'border-top')
                    border_bottom = get_attribute_safe(child, 'border-bottom')

                    b_data = {}
                    if border and border != 'none':
                        css = normalize_odf_border_to_css(border)
                        if css:
                            b_data['top'] = css
                            b_data['bottom'] = css
                    else:
                        if border_top and border_top != 'none':
                            css = normalize_odf_border_to_css(border_top)
                            if css: b_data['top'] = css
                        if border_bottom and border_bottom != 'none':
                            css = normalize_odf_border_to_css(border_bottom)
                            if css: b_data['bottom'] = css

                    if b_data:
                        paragraph_borders[name] = b_data

    # Propagation
    all_styles = set(inheritance_map.keys()).union(bold_styles).union(italic_styles).union(paragraph_borders.keys())
    changed = True
    while changed:
        changed = False
        for s in all_styles:
            p = inheritance_map.get(s)
            if not p: continue

            if p in bold_styles and s not in bold_styles:
                bold_styles.add(s);
                changed = True
            if p in italic_styles and s not in italic_styles:
                italic_styles.add(s);
                changed = True
            if p in underlined_styles and s not in underlined_styles:
                underlined_styles.add(s);
                changed = True
            if p in highlight_colors and s not in highlight_colors:
                highlight_colors[s] = highlight_colors[p];
                changed = True
            if p in text_colors and s not in text_colors:
                text_colors[s] = text_colors[p];
                changed = True
            if p in paragraph_backgrounds and s not in paragraph_backgrounds:
                paragraph_backgrounds[s] = paragraph_backgrounds[p];
                changed = True
            if p in paragraph_borders and s not in paragraph_borders:
                paragraph_borders[s] = paragraph_borders[p];
                changed = True
            elif p in paragraph_borders and s in paragraph_borders:
                if 'top' in paragraph_borders[p] and 'top' not in paragraph_borders[s]:
                    paragraph_borders[s]['top'] = paragraph_borders[p]['top'];
                    changed = True
                if 'bottom' in paragraph_borders[p] and 'bottom' not in paragraph_borders[s]:
                    paragraph_borders[s]['bottom'] = paragraph_borders[p]['bottom'];
                    changed = True

    return bold_styles, underlined_styles, highlight_colors, italic_styles, text_colors, paragraph_borders, paragraph_backgrounds


# --- CODE & LISTES ---

def detect_language(code_content, code_config):
    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', {})
    case_insensitive_languages = {
        str(lang).lower() for lang in code_config.get('case_insensitive_languages', [])
    }
    code_content_lower = code_content.lower()

    scores = {}
    for lang, keywords in rules.items():
        lang_key = str(lang).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 > 0:
            scores[lang] = count

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


def get_fence(content):
    matches = re.findall(r'`+', content)
    longest = max(len(m) for m in matches) if matches else 0
    return "`" * max(3, longest + 1)


def build_list_style_map(doc):
    """Extract ODT list styles.

    We keep backward compatibility with previous versions by still exposing:
      list_map[style_name] = {'levels': {level: 'ordered'|'unordered', ...}, 'default': ...}

    V356 adds per-level marker metadata:
      list_map[style_name]['level_info'][level] = {
          'type': 'ordered'|'unordered',
          'num_format': raw format (e.g. '1','a','A','i','I') or None,
          'bullet_char': raw bullet char (e.g. '•','◦','▪') or None
      }
    """
    list_map = {}
    sources = []
    if doc.automaticstyles:
        sources.extend(doc.automaticstyles.childNodes)
    if doc.styles:
        sources.extend(doc.styles.childNodes)

    for s in sources:
        if s.tagName != 'text:list-style':
            continue
        name = get_attribute_safe(s, 'name')
        if not name:
            continue

        level_map = {}
        level_info = {}

        for child in s.childNodes:
            if child.tagName not in ('text:list-level-style-number', 'text:list-level-style-bullet'):
                continue

            lvl = safe_int(get_attribute_safe(child, 'level')) or safe_int(get_attribute_safe(child, 'list-level'))
            if not lvl:
                continue

            if child.tagName == 'text:list-level-style-number':
                level_map[lvl] = 'ordered'
                nf = get_attribute_safe(child, 'num-format') or get_attribute_safe(child, 'format')
                sv = safe_int(get_attribute_safe(child, 'start-value') or get_attribute_safe(child, 'startvalue') or get_attribute_safe(child, 'start_value'))
                level_info[lvl] = {
                    'type': 'ordered',
                    'num_format': nf,
                    'bullet_char': None,
                    'start_value': sv,
                }
            else:
                level_map[lvl] = 'unordered'
                bc = get_attribute_safe(child, 'bullet-char')
                level_info[lvl] = {
                    'type': 'unordered',
                    'num_format': None,
                    'bullet_char': bc,
                    'start_value': None,
                }

        default_type = 'unordered'
        if level_map:
            default_type = level_map.get(1, next(iter(level_map.values())))

        list_map[name] = {
            'levels': level_map,
            'default': default_type,
            'level_info': level_info,
        }

    return list_map




def infer_list_type_from_ancestors(node, list_style_map):
    """Infer ordered/unordered for the current list depth by walking up ODT ancestors.

    LibreOffice sometimes omits 'text:style-name' on nested <text:list> nodes.
    In that case, the effective list style is inherited from an ancestor list that *does*
    carry a style-name, and the level-specific type is defined in that list-style.

    We compute the *local* level within that style by counting how many <text:list>
    ancestors we traversed until the styled list is found.
    """
    depth = 0
    curr = node
    for _ in range(60):  # hard stop to avoid infinite loops on malformed DOM
        if not hasattr(curr, 'parentNode') or not curr.parentNode:
            break
        curr = curr.parentNode
        if getattr(curr, 'tagName', None) == 'text:list':
            depth += 1
            style_name = get_attribute_safe(curr, 'style-name')
            if style_name:
                style_info = list_style_map.get(style_name)
                if isinstance(style_info, dict):
                    levels = style_info.get('levels', {}) or {}
                    t = levels.get(depth) or style_info.get('default')
                    return t if t in ('ordered', 'unordered') else None
                if style_info in ('ordered', 'unordered'):
                    return style_info
                return None
    return None



# --- V356: list marker helpers (ODT -> CSS list-style-type) ---

def _odt_num_format_to_css(nf):
    """Map ODT num-format to CSS list-style-type."""
    if not nf:
        return None
    nf = str(nf).strip()
    if nf == '1':
        return 'decimal'
    if nf in ('a',):
        return 'lower-alpha'
    if nf in ('A',):
        return 'upper-alpha'
    if nf in ('i',):
        return 'lower-roman'
    if nf in ('I',):
        return 'upper-roman'
    # Some documents may use e.g. '01'
    if nf in ('01', '001'):
        return 'decimal-leading-zero'
    return None

def _odt_bullet_char_to_css(bc):
    """Best-effort mapping for common LibreOffice bullets."""
    if not bc:
        return None
    bc = str(bc)
    # Common bullets
    if bc in ('•', '●', '\u2022', '\u25cf'):
        return 'disc'
    if bc in ('◦', '○', '\u25e6', '\u25cb'):
        return 'circle'
    if bc in ('▪', '■', '▣', '\u25aa', '\u25a0'):
        return 'square'
    # Fallback: many custom bullets still look acceptable as disc
    return 'disc'

def _build_depth_selector(tag, depth):
    """Selector that matches a list container at an absolute nesting depth, regardless of UL/OL mix.

    depth=1 -> .md-content {tag}
    depth=2 -> .md-content :is(ul,ol) > li > {tag}
    depth=3 -> .md-content :is(ul,ol) > li > :is(ul,ol) > li > {tag}
    ...
    """
    if depth <= 1:
        return f".md-content {tag}"
    chain = ".md-content " + " > li > :is(ul,ol)".join([":is(ul,ol)"] * (depth - 1))
    # chain currently like ".md-content :is(ul,ol) > li > :is(ul,ol) > li > :is(ul,ol)" for depth=3
    return chain + f" > li > {tag}"

def _summarize_list_styles_by_depth(list_style_map, max_depth=10):
    """Aggregate preferred marker types by absolute depth from ODT list styles.

    Returns:
      ul_pref[depth] = css list-style-type (disc/circle/square)
      ol_pref[depth] = css list-style-type (decimal/lower-alpha/...)
    Strategy:
      - For each list-style, for each level, collect the CSS marker derived from ODT.
      - Choose the most frequent marker per depth and tag.
    """
    ul_counts = {d: {} for d in range(1, max_depth + 1)}
    ol_counts = {d: {} for d in range(1, max_depth + 1)}

    for style_name, info in (list_style_map or {}).items():
        if not isinstance(info, dict):
            continue
        lvl_info = info.get('level_info') or {}
        for lvl, li in lvl_info.items():
            if not isinstance(lvl, int) or lvl < 1 or lvl > max_depth:
                continue
            t = (li or {}).get('type')
            if t == 'unordered':
                css = _odt_bullet_char_to_css((li or {}).get('bullet_char'))
                if css:
                    ul_counts[lvl][css] = ul_counts[lvl].get(css, 0) + 1
            elif t == 'ordered':
                css = _odt_num_format_to_css((li or {}).get('num_format'))
                if css:
                    ol_counts[lvl][css] = ol_counts[lvl].get(css, 0) + 1

    def pick(counts):
        if not counts:
            return None
        # deterministic: highest count then lexicographic
        return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]

    ul_pref = {d: pick(ul_counts[d]) for d in ul_counts}
    ol_pref = {d: pick(ol_counts[d]) for d in ol_counts}
    return ul_pref, ol_pref
def build_list_chain_map_odfpy(doc):
    lists_db = {}
    all_lists = doc.text.getElementsByType(text.List)
    for lst in all_lists:
        lid = get_attribute_safe(lst, 'id')
        if not lid: continue
        prev = get_attribute_safe(lst, 'continue-list')
        explicit = safe_int(get_attribute_safe(lst, 'start-value'))
        count = sum(1 for child in lst.childNodes if child.tagName == 'text:list-item')
        lists_db[lid] = {'prev': prev, 'explicit': explicit, 'count': count, 'computed': None}

    def resolve(cid, visited):
        if cid in visited or cid not in lists_db: return 1
        d = lists_db[cid]
        if d['computed'] is not None: return d['computed']
        visited.add(cid)
        if d['explicit'] is not None:
            val = d['explicit']
        elif d['prev']:
            pd = lists_db.get(d['prev'])
            val = resolve(d['prev'], visited) + (pd['count'] if pd else 0)
        else:
            val = 1
        d['computed'] = val
        return val

    for lid in lists_db: resolve(lid, set())
    return lists_db


def get_block_start_odfpy(node, list_chain_map):
    curr = node
    for _ in range(6):
        if not hasattr(curr, 'parentNode') or not curr.parentNode: break
        curr = curr.parentNode
        if curr.tagName == 'text:list-item':
            sv = safe_int(get_attribute_safe(curr, 'start-value'))
            if sv: return sv
            parent_list = curr.parentNode
            if parent_list and parent_list.tagName == 'text:list':
                lid = get_attribute_safe(parent_list, 'id')
                if lid and lid in list_chain_map:
                    val = list_chain_map[lid]['computed']
                    if val and val > 1: return val
    return 1



# --- V377: ordered list numbering outside code blocks ---
def _find_ancestor(node, tag_name, max_hops=60):
    curr = node
    for _ in range(max_hops):
        if not hasattr(curr, 'parentNode') or not curr.parentNode:
            return None
        curr = curr.parentNode
        if getattr(curr, 'tagName', None) == tag_name:
            return curr
    return None


def _find_nearest_list_node(node):
    if getattr(node, 'tagName', None) == 'text:list':
        return node
    return _find_ancestor(node, 'text:list')


def _find_nearest_list_item(node):
    if getattr(node, 'tagName', None) == 'text:list-item':
        return node
    return _find_ancestor(node, 'text:list-item')


def _get_list_style_start_value(list_node, level, list_style_map):
    if not list_node or level <= 0:
        return None
    style_name = get_attribute_safe(list_node, 'style-name')
    if not style_name:
        # try infer from ancestors (LibreOffice may omit on nested lists)
        anc = list_node
        for _ in range(20):
            anc = _find_ancestor(anc, 'text:list')
            if not anc:
                break
            style_name = get_attribute_safe(anc, 'style-name')
            if style_name:
                break
    info = list_style_map.get(style_name) if style_name else None
    if isinstance(info, dict):
        li = (info.get('level_info') or {}).get(level) or {}
        sv = li.get('start_value')
        return sv if isinstance(sv, int) and sv > 0 else None
    return None


def compute_ordered_list_marker(node, list_level, list_type, list_style_map, list_chain_map,
                                last_effective_list_type_by_level, state):
    """Return the Markdown marker for the current node when it is in a list context.

    For ordered lists, preserve explicit ODT starts (e.g. 'Commencer la numérotation à 6')
    by computing the effective item number.
    """
    effective_list_type = last_effective_list_type_by_level.get(list_level, list_type)
    if effective_list_type != 'ordered':
        return "- "

    list_item = _find_nearest_list_item(node)
    list_node = _find_nearest_list_node(node)

    lid = get_attribute_safe(list_node, 'id') if list_node else None
    key = (lid or "__noid__", list_level)

    # Increment only when we reach a new list-item (a list-item can contain multiple paragraphs)
    last_item = (state.get('last_item') or {}).get(key)
    if last_item is not list_item:
        # init counter if first time for this list id + level
        counters = state.setdefault('counters', {})
        if key not in counters:
            # 1) explicit start-value on list-item
            sv = safe_int(get_attribute_safe(list_item, 'start-value') if list_item else None)
            # 2) computed chain start (continue-list / start-value on list)
            if (sv is None or sv <= 0) and lid and lid in (list_chain_map or {}):
                cv = (list_chain_map[lid] or {}).get('computed')
                if isinstance(cv, int) and cv > 0:
                    sv = cv
            # 3) list-style level start-value (LibreOffice restart-numbering often materializes as a new list-style)
            if (sv is None or sv <= 0):
                sv = _get_list_style_start_value(list_node, list_level, list_style_map)
            # fallback
            if sv is None or sv <= 0:
                sv = 1
            counters[key] = sv
        else:
            counters[key] += 1

        state.setdefault('last_item', {})[key] = list_item

    n = state.get('counters', {}).get(key, 1)
    return f"{n}. "





def _html_list_indent(level: int) -> str:
    return "    " * max(0, level - 1)


def html_list_close_to_level(current_file, list_number_state, target_level: int):
    """Close HTML lists down to target_level (0 => close all). Ensures valid nesting with </li>."""
    stack = list_number_state.get('html_list_stack') or {}
    if not stack:
        return

    max_level = max(stack.keys()) if stack else 0
    for lvl in range(max_level, target_level, -1):
        st = stack.get(lvl)
        if not st:
            continue
        indent = _html_list_indent(lvl)
        # close any open <li> at this level
        if st.get('li_open'):
            current_file.write(f"{indent}  </li>\n")
        st['li_open'] = False
        # close container
        tag = st.get('tag') or 'ol'
        if st.get('open'):
            current_file.write(f"{indent}</{tag}>\n")
            st['open'] = False
        stack.pop(lvl, None)

    list_number_state['html_list_stack'] = stack


def html_list_ensure_container(current_file, list_number_state, level: int, tag: str, start_value: int | None = None):
    """Ensure the list container (<ol>/<ul>) is open at given level.

    If tag changes at the same level, we close and reopen to keep HTML valid.
    """
    stack = list_number_state.setdefault('html_list_stack', {})
    tag = 'ol' if tag == 'ol' else 'ul'

    st = stack.get(level)
    if st and st.get('open') and st.get('tag') != tag:
        # close and reopen with new tag
        html_list_close_to_level(current_file, list_number_state, level - 1)
        st = None

    if not st:
        st = {'tag': tag, 'open': False, 'li_open': False, 'current_item': None, 'opened_with_start': None}
        stack[level] = st

    if not st.get('open'):
        indent = _html_list_indent(level)
        if tag == 'ol' and isinstance(start_value, int) and start_value > 1:
            current_file.write(f'{indent}<ol start="{start_value}">\n')
            st['opened_with_start'] = start_value
        else:
            current_file.write(f"{indent}<{tag}>\n")
            st['opened_with_start'] = None
        st['open'] = True
        st['tag'] = tag
        st['li_open'] = False
        st['current_item'] = None

    stack[level] = st
    list_number_state['html_list_stack'] = stack


def html_list_write_item(current_file, list_number_state, level: int, tag: str, html_content: str,
                         list_item_node=None, start_value: int | None = None):
    """Write HTML list item content at given level, keeping nested lists inside the parent <li>.

    - If multiple paragraphs belong to the same ODT list-item, append with <br/>.
    - Otherwise, close previous <li> at this level and open a new one.
    """
    # Ensure containers for all levels up to 'level' exist.
    for l in range(1, level + 1):
        # For intermediate levels, open container based on what we already have, or default to 'ol' if unknown.
        if l == level:
            html_list_ensure_container(current_file, list_number_state, l, tag, start_value=start_value)
        else:
            # Respect existing tag if already opened, else default to 'ol'
            stack = list_number_state.get('html_list_stack') or {}
            existing = stack.get(l, {})
            mid_tag = existing.get('tag') or 'ol'
            html_list_ensure_container(current_file, list_number_state, l, mid_tag, start_value=None)

    stack = list_number_state.get('html_list_stack') or {}
    st = stack.get(level)
    if not st:
        return

    indent = _html_list_indent(level)

    # Same list-item => append
    if st.get('li_open') and (list_item_node is not None) and (st.get('current_item') is list_item_node):
        # Separate paragraphs but stay inside same <li>
        if html_content:
            current_file.write(f"{indent}    <br/>{html_content}")
        return

    # New list-item
    if st.get('li_open'):
        current_file.write(f"{indent}  </li>\n")
        st['li_open'] = False
        st['current_item'] = None

    current_file.write(f"{indent}  <li>")
    if html_content:
        current_file.write(f"{html_content}")
    current_file.write("\n")
    st['li_open'] = True
    st['current_item'] = list_item_node

    stack[level] = st
    list_number_state['html_list_stack'] = stack


def html_lists_active_upto_level(list_number_state, level: int) -> bool:
    """True if an ancestor ordered-list level is forced to raw HTML."""
    html_ol = list_number_state.get('html_ol', {}) or {}
    for lvl in range(1, level + 1):
        st = html_ol.get(lvl)
        if st and st.get('forced'):
            return True
    return False



# --- NAVIGATION ---

def get_readable_style_name(internal, readable_map, inheritance_map):
    curr = internal
    for _ in range(10):
        if not curr: break
        if curr in readable_map: return readable_map[curr]
        curr = inheritance_map.get(curr)
    return internal


def get_heading_level(node, readable_map, inheritance_map):
    if node.tagName == 'text:h':
        lvl = get_attribute_safe(node, 'outline-level')
        return int(lvl) if lvl else 1
    if node.tagName == 'text:p':
        style_name = get_attribute_safe(node, 'style-name')
        if not style_name: return 0
        readable = get_readable_style_name(style_name, readable_map, inheritance_map)
        s_clean = unidecode(readable).lower() if readable else ""
        if any(x in s_clean for x in ['normal', 'standard', 'text body', 'default']): return 0
        match = re.search(r"^(titre|heading|h|chapitre)\s*(\d+)", s_clean)
        if match: return int(match.group(2))
        outline = get_attribute_safe(node, 'outline-level')
        if outline: return int(outline)
    return 0


def is_code_paragraph(node, readable_map, inheritance_map, style_keywords):
    if node.tagName != 'text:p': return False
    curr = get_attribute_safe(node, 'style-name')
    targets = set(style_keywords)
    for _ in range(10):
        if not curr: break
        readable = get_readable_style_name(curr, readable_map, inheritance_map)
        s_clean = unidecode(readable).lower()
        if any(t in s_clean for t in targets): return True
        curr = inheritance_map.get(curr)
    return False


# --- SCANNER V326 ---

def recursive_scan_for_bookmarks(node, current_filename, targets_map):
    if hasattr(node, 'tagName'):
        if node.tagName in ('text:bookmark', 'text:bookmark-start', 'text:reference-mark', 'text:reference-mark-start'):
            name = get_attribute_safe(node, 'name')
            if name:
                targets_map[name] = current_filename

    for child in node.childNodes:
        recursive_scan_for_bookmarks(child, current_filename, targets_map)


# --- TEXTE ET FORMATAGE ---

def apply_formatting(txt, style_name, bold_styles, underlined_styles, highlight_colors, italic_styles, text_colors,
                     output_format, is_block_context=False):
    if not txt or output_format == "code_html": return txt

    bg_color = highlight_colors.get(style_name)
    if bg_color: txt = f'<mark style="background-color: {bg_color}">{txt}</mark>'

    fg_color = text_colors.get(style_name)
    if fg_color: txt = f'<span style="color: {fg_color}">{txt}</span>'

    if is_block_context: return txt

    is_bold = style_name and style_name in bold_styles
    is_italic = style_name and style_name in italic_styles
    is_underlined = style_name and style_name in underlined_styles

    def _wrap_md_delims_keep_outer_spaces(s, delim):
        # CommonMark requires the delimiter to touch a non-space character.
        # LibreOffice styles sometimes include surrounding spaces in the same span.
        m = re.match(r'^(\s*)(.*?)(\s*)$', s, flags=re.S)
        if not m: 
            return f"{delim}{s}{delim}"
        lead, core, trail = m.group(1), m.group(2), m.group(3)
        if core == "":  # nothing but spaces
            return s
        return f"{lead}{delim}{core}{delim}{trail}"

    if is_bold:
        txt = _wrap_md_delims_keep_outer_spaces(txt, "**") if output_format == "md" else f"<b>{txt}</b>"
    if is_italic:
        txt = _wrap_md_delims_keep_outer_spaces(txt, "*") if output_format == "md" else f"<i>{txt}</i>"
    if is_underlined:
        txt = f"<u>{txt}</u>"
    return txt


def has_image_child(node):
    for child in node.childNodes:
        if child.tagName == 'draw:image': return True
        if has_image_child(child): return True
    return False


def clean_text(node, escape=False, bold_styles=None, underlined_styles=None, highlight_colors=None, italic_styles=None,
               text_colors=None, output_format="md", targets_map=None, current_filename=None, footnotes_ctx=None):
    if bold_styles is None: bold_styles = set()
    if italic_styles is None: italic_styles = set()
    if underlined_styles is None: underlined_styles = set()
    if highlight_colors is None: highlight_colors = {}
    if text_colors is None: text_colors = {}
    if targets_map is None: targets_map = {}
    if current_filename is None: current_filename = "index.md"

    txt = ""
    for child in node.childNodes:
        if child.tagName == 'draw:frame' and has_image_child(child): continue

        # V326: Si on veut du texte brut (ex: pour un titre de TOC), on IGNORE les ancres.
        if output_format == "txt":
            if child.tagName in ('text:bookmark', 'text:bookmark-start', 'text:reference-mark',
                                 'text:reference-mark-start'):
                continue

        # V331: Notes de bas de page
        if child.tagName == 'text:note':
            if output_format == "txt":
                continue
            explicit = None
            body_node = None
            for nchild in child.childNodes:
                if nchild.tagName == 'text:note-citation':
                    explicit = clean_text(nchild, escape=False, output_format="txt", targets_map=targets_map,
                                          current_filename=current_filename, footnotes_ctx=footnotes_ctx)
                elif nchild.tagName == 'text:note-body':
                    body_node = nchild
            body_txt = ""
            if body_node is not None:
                body_txt = clean_text(body_node, escape=True, bold_styles=bold_styles,
                                      underlined_styles=underlined_styles, highlight_colors=highlight_colors,
                                      italic_styles=italic_styles, text_colors=text_colors, output_format="md",
                                      targets_map=targets_map, current_filename=current_filename,
                                      footnotes_ctx=footnotes_ctx)
                body_txt = re.sub(r'<a id="[^"]+"></a>', '', body_txt).strip()
            fid = register_footnote(footnotes_ctx, body_txt, explicit_id=explicit)
            if fid:
                txt += f"[^{fid}]"
            continue

        # V326: Gestion des Repères et Renvois (Bookmarks & References)
        if child.tagName == 'text:bookmark':
            name = get_attribute_safe(child, 'name')
            if name and output_format != "txt": txt += f'<a id="{sanitize_anchor_id(name)}"></a>'

        elif child.tagName == 'text:bookmark-start':
            name = get_attribute_safe(child, 'name')
            if name and output_format != "txt": txt += f'<a id="{sanitize_anchor_id(name)}"></a>'

        elif child.tagName in ('text:reference-mark', 'text:reference-mark-start'):
            name = get_attribute_safe(child, 'name')
            if name and output_format != "txt": txt += f'<a id="{sanitize_anchor_id(name)}"></a>'

        elif child.tagName in ('text:reference-ref', 'text:bookmark-ref'):
            ref_name = get_attribute_safe(child, 'ref-name')
            inner = clean_text(child, escape, bold_styles, underlined_styles, highlight_colors, italic_styles,
                               text_colors, output_format, targets_map, current_filename, footnotes_ctx=footnotes_ctx)

            if ref_name and output_format in ("md", "html"):
                target_file = targets_map.get(ref_name)
                anchor = sanitize_anchor_id(ref_name)

                # Same-file target
                if (not target_file) or (target_file == current_filename):
                    href = f"#{anchor}"
                else:
                    if output_format == "md":
                        href = f"{target_file}#{anchor}"
                    else:
                        # In HTML context (e.g., inside a table), MkDocs won't rewrite .md links inside raw HTML.
                        # Convert to the final rendered page name (use_directory_urls is False in our config).
                        if isinstance(target_file, str) and target_file.lower().endswith(".md"):
                            href = f"{target_file[:-3]}.html#{anchor}"
                        else:
                            href = f"{target_file}#{anchor}"

                if output_format == "md":
                    txt += f'[{inner}]({href})'
                else:
                    txt += f'<a href="{href}">{inner}</a>'
            else:
                txt += inner

        elif child.nodeType == 3:
            data = str(child.data)
            if escape:
                data = html.escape(data)
                if output_format == "md":
                    data = data.replace("<", "&lt;").replace(">", "&gt;")
                    data = escape_markdown_text(data)
            txt += data
        elif child.tagName == 'text:s':
            c = get_attribute_safe(child, 'c')
            txt += " " * (int(c) if c else 1)
        elif child.tagName == 'text:tab':
            txt += "    "
        elif child.tagName == 'text:line-break':
            txt += "<br>" if output_format == "html" else "\n"
        elif child.tagName == 'text:a':
            ls = get_attribute_safe(child, 'style-name')
            inner = clean_text(child, escape, bold_styles, underlined_styles, highlight_colors, italic_styles,
                               text_colors, output_format, targets_map, current_filename, footnotes_ctx=footnotes_ctx)
            href = get_attribute_safe(child, 'href')
            if output_format == "md":
                txt += f"[{inner}]({href})"
            else:
                txt += f'<a href="{href}">{inner}</a>'
        elif child.tagName == 'text:span':
            ss = get_attribute_safe(child, 'style-name')
            inner = clean_text(child, escape, bold_styles, underlined_styles, highlight_colors, italic_styles,
                               text_colors, output_format, targets_map, current_filename, footnotes_ctx=footnotes_ctx)
            if output_format == "code_html":
                css = build_code_span_style(ss, bold_styles, italic_styles, underlined_styles, highlight_colors,
                                            text_colors)
                if css:
                    txt += f'<span style="{css}">{inner}</span>'
                else:
                    txt += inner
            else:
                inner = apply_formatting(inner, ss, bold_styles, underlined_styles, highlight_colors, italic_styles,
                                         text_colors, output_format)
                txt += inner
        else:
            txt += clean_text(child, escape, bold_styles, underlined_styles, highlight_colors, italic_styles,
                              text_colors, output_format, targets_map, current_filename, footnotes_ctx=footnotes_ctx)
    return txt


# --- IMAGES (V340: REINTEGRATION) ---

def extract_images_from_zip(odt_path, output_dir):
    images_map = {}
    if not zipfile.is_zipfile(odt_path): return {}
    with zipfile.ZipFile(odt_path, 'r') as z:
        for file_info in z.infolist():
            if file_info.filename.startswith('Pictures/') and not file_info.filename.endswith('/'):
                original_name = os.path.basename(file_info.filename)
                safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', original_name)
                target_path = os.path.join(output_dir, safe_name)
                with open(target_path, 'wb') as f: f.write(z.read(file_info))
                images_map[file_info.filename] = f"images/{safe_name}"
    return images_map


def get_image_dimensions(image_node):
    parent = image_node.parentNode
    width = get_attribute_safe(parent, 'width') if parent and parent.tagName == 'draw:frame' else None
    height = get_attribute_safe(parent, 'height') if parent and parent.tagName == 'draw:frame' else None
    return width, height


def extract_image_tag_from_node(node, images_map):
    md = ""
    for img in node.getElementsByType(draw.Image):
        href = get_attribute_safe(img, 'href')
        if href:
            target = href if href in images_map else href.replace("./", "")
            if target in images_map:
                src = images_map[target]
                width, height = get_image_dimensions(img)
                style = ""
                if width: style += f"width:{width};"
                if height: style += f"height:{height};"
                if not style: style = "max-width:100%;height:auto;"
                md += f'\n<img src="{src}" style="{style}" alt="Image" class="odt-zoomable" data-zoom-src="{src}" />\n'
    return md


def get_images_in_node_as_html(node, images_map):
    html_out = ""
    for img in node.getElementsByType(draw.Image):
        href = get_attribute_safe(img, 'href')
        if href:
            target = href if href in images_map else href.replace("./", "")
            if target in images_map:
                src = images_map[target]
                width, height = get_image_dimensions(img)
                style = "display:inline-block; margin:4px;"
                if width: style += f"width:{width};"
                if height:
                    style += f"height:{height};"
                else:
                    style += "max-width:100%;height:auto;"
                html_out += f'<img src="{src}" style="{style}" class="odt-zoomable" data-zoom-src="{src}" />'
    return html_out


# --- FLUX NODE STREAM ---

def stream_nodes_context(node, readable_map, inheritance_map, list_style_map, list_level=0, list_type='unordered'):
    if node.tagName in ('office:text', 'text:section', 'draw:frame', 'draw:text-box'):
        for child in node.childNodes:
            yield from stream_nodes_context(child, readable_map, inheritance_map, list_style_map, list_level, list_type)
        return
    if node.tagName == 'table:table':
        yield (node, list_level, list_type)
        return
    if node.tagName == 'text:p' and has_nested_table(node):
        buffer = []
        for child in node.childNodes:
            if child.tagName in ('draw:frame', 'draw:text-box'):
                if buffer:
                    p = text.P();
                    p.childNodes = buffer
                    yield (p, list_level, list_type)
                    buffer = []
                yield from stream_nodes_context(child, readable_map, inheritance_map, list_style_map, list_level,
                                                list_type)
            else:
                buffer.append(child)
        if buffer: p = text.P(); p.childNodes = buffer; yield (p, list_level, list_type)
        return
    if node.tagName == 'text:list':
        style_name = get_attribute_safe(node, 'style-name')
        style_info = list_style_map.get(style_name)
        new_level = list_level + 1
        if isinstance(style_info, dict):
            new_type = style_info.get('levels', {}).get(new_level, style_info.get('default', 'unordered'))
        else:
            new_type = style_info if style_info else 'unordered'
        for child in node.childNodes:
            if child.tagName == 'text:list-item':
                for item_child in child.childNodes:
                    if item_child.tagName == 'text:list':
                        yield from stream_nodes_context(item_child, readable_map, inheritance_map, list_style_map,
                                                        new_level, new_type)
                    else:
                        yield from stream_nodes_context(item_child, readable_map, inheritance_map, list_style_map,
                                                        new_level, new_type)
        return
    yield (node, list_level, list_type)


def has_nested_table(node):
    if node.tagName == 'table:table': return True
    for child in node.childNodes:
        if has_nested_table(child): return True
    return False


def get_all_rows(node):
    rows = []
    if node.tagName == 'table:table-row':
        rows.append(node)
    elif node.tagName in ('table:table', 'table:table-header-rows', 'table:table-footer-rows', 'table:table-row-group'):
        for child in node.childNodes: rows.extend(get_all_rows(child))
    return rows


def process_table_to_html(table_node, images_map, bold_styles, underlined_styles, highlight_colors, italic_styles,
                          text_colors, paragraph_borders, paragraph_backgrounds, readable_map, inheritance_map, list_style_map, list_chain_map, config,
                          targets_map, current_filename, footnotes_ctx=None):
    html_out = ['<table class="odt-table" style="border-collapse: collapse; width: 100%; margin-bottom: 1em;">']
    all_rows = get_all_rows(table_node)
    if not all_rows: return ""
    style_keywords = config['code']['style_keywords']

    for row in all_rows:
        html_out.append('<tr>')
        for cell in row.childNodes:
            if cell.tagName == 'table:covered-table-cell':
                continue
            if cell.tagName != 'table:table-cell':
                continue

            colspan = get_attribute_safe(cell, 'number-columns-spanned')
            rowspan = get_attribute_safe(cell, 'number-rows-spanned')
            attrs = ' style="border: 1px solid #ddd; padding: 8px; vertical-align: top;"'
            if colspan: attrs += f' colspan="{colspan}"'
            if rowspan: attrs += f' rowspan="{rowspan}"'
            html_out.append(f'<td{attrs}>')

            buf = io.StringIO()
            list_state = {'counters': {}, 'last_item': {}, 'html_list_stack': {}}
            last_effective_list_type_by_level = {}
            code_buffer = []
            code_buffer_html = []
            code_has_rich = False
            code_start_line = 1
            code_has_linenums = False

            def flush_code():
                nonlocal code_buffer, code_buffer_html, code_has_rich, code_start_line, code_has_linenums
                if not code_buffer:
                    return
                full_code = "\n".join(code_buffer)
                lang = detect_language(full_code, config['code'])
                if code_has_rich and any(x is not None for x in code_buffer_html):
                    lines_html = []
                    for i, pl in enumerate(code_buffer):
                        hl = code_buffer_html[i] if i < len(code_buffer_html) else None
                        lines_html.append(html.escape(pl) if hl is None else hl)
                    buf.write(render_rich_code_block(lines_html, lang, start_line=code_start_line, show_linenums=code_has_linenums))
                else:
                    fence = get_fence(full_code)
                    header = f'{fence}{lang}' + (f' linenums="{code_start_line}"' if code_has_linenums else '')
                    buf.write(f'<div markdown="block">\n{header}\n{full_code}\n{fence}\n</div>')
                buf.write("\n")
                code_buffer = []
                code_buffer_html = []
                code_has_rich = False
                code_start_line = 1
                code_has_linenums = False

            def render_paragraph_html(n, list_level=0, list_type='unordered', list_item_node=None):
                nonlocal code_has_rich, code_start_line, code_has_linenums
                p_style = get_attribute_safe(n, 'style-name')
                p_bg = paragraph_backgrounds.get(p_style) if p_style else None
                b = paragraph_borders.get(p_style) if p_style else None

                if is_code_paragraph(n, readable_map, inheritance_map, style_keywords):
                    if not code_buffer:
                        ln = get_attribute_safe(n, 'line-number')
                        sv = None
                        if hasattr(n, 'parentNode') and n.parentNode and n.parentNode.tagName == 'text:list-item':
                            sv = get_attribute_safe(n.parentNode, 'start-value')
                        v_list = get_block_start_odfpy(n, list_chain_map)
                        code_start_line = v_list if (v_list and v_list > 1) else (int(ln) if ln else 1)
                        is_numbered_list_context = (list_level > 0 and list_type == 'ordered')
                        code_has_linenums = (ln is not None) or (sv is not None) or (v_list and v_list > 1) or is_numbered_list_context
                    plain_line = clean_text(n, escape=False, output_format="txt")
                    code_buffer.append(plain_line)
                    if node_has_rich_code_formatting(n, bold_styles, italic_styles, underlined_styles, highlight_colors, text_colors):
                        code_has_rich = True
                    if code_has_rich:
                        html_line = clean_text(n, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                                               highlight_colors=highlight_colors, italic_styles=italic_styles, text_colors=text_colors,
                                               output_format="code_html", targets_map=targets_map, current_filename=current_filename,
                                               footnotes_ctx=footnotes_ctx)
                        code_buffer_html.append(html_line)
                    else:
                        code_buffer_html.append(None)
                    return

                flush_code()
                inner = clean_text(n, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                                   highlight_colors=highlight_colors, italic_styles=italic_styles, text_colors=text_colors,
                                   output_format="html", targets_map=targets_map, current_filename=current_filename,
                                   footnotes_ctx=footnotes_ctx)
                inner = apply_formatting(inner, p_style, bold_styles, underlined_styles, highlight_colors,
                                         italic_styles, text_colors, output_format="html")
                img = get_images_in_node_as_html(n, images_map)

                if list_level > 0 and list_item_node is not None:
                    tag = 'ol' if list_type == 'ordered' else 'ul'
                    start_attr = None
                    if list_type == 'ordered':
                        marker = compute_ordered_list_marker(n, list_level, list_type, list_style_map, list_chain_map,
                                                             last_effective_list_type_by_level, list_state)
                        mnum = re.match(r'^(\d+)\.', marker.strip())
                        stack = list_state.get('html_list_stack') or {}
                        if mnum and not stack.get(list_level, {}).get('open'):
                            nstart = int(mnum.group(1))
                            if nstart > 1:
                                start_attr = nstart
                    html_content = inner
                    if p_bg:
                        html_content = build_paragraph_block_html(inner, p_bg, margin='0')
                    if b and b.get('top'):
                        html_content = build_hr_for_border(b.get('top'), 'top') + html_content
                    if b and b.get('bottom'):
                        html_content = html_content + build_hr_for_border(b.get('bottom'), 'bottom')
                    html_list_write_item(buf, list_state, list_level, tag, html_content, list_item_node=list_item_node, start_value=start_attr)
                    if img:
                        buf.write(img)
                    return

                if b and b.get('top'):
                    buf.write(build_hr_for_border(b.get('top'), 'top'))
                if inner.strip() or p_bg:
                    buf.write(build_paragraph_block_html(inner, p_bg, margin='0 0 0.5em 0'))
                if b and b.get('bottom'):
                    buf.write(build_hr_for_border(b.get('bottom'), 'bottom'))
                if img:
                    buf.write(img)

            def walk(n, current_level=0, current_type='unordered', current_list_item=None):
                if n.tagName == 'text:p':
                    render_paragraph_html(n, current_level, current_type, current_list_item)
                    return
                if n.tagName == 'text:list':
                    flush_code()
                    style_name = get_attribute_safe(n, 'style-name')
                    style_info = list_style_map.get(style_name)
                    new_level = current_level + 1
                    if isinstance(style_info, dict):
                        new_type = style_info.get('levels', {}).get(new_level, style_info.get('default', 'unordered'))
                    else:
                        new_type = style_info if style_info else (infer_list_type_from_ancestors(n, list_style_map) or 'unordered')
                    last_effective_list_type_by_level[new_level] = new_type
                    for item in n.childNodes:
                        if item.tagName != 'text:list-item':
                            continue
                        for c in item.childNodes:
                            walk(c, new_level, new_type, item)
                    html_list_close_to_level(buf, list_state, current_level)
                    return
                if n.tagName == 'table:table':
                    flush_code()
                    buf.write(process_table_to_html(n, images_map, bold_styles, underlined_styles, highlight_colors, italic_styles,
                                                    text_colors, paragraph_borders, paragraph_backgrounds, readable_map, inheritance_map,
                                                    list_style_map, list_chain_map, config, targets_map, current_filename, footnotes_ctx=footnotes_ctx))
                    return
                for c in getattr(n, 'childNodes', []) or []:
                    walk(c, current_level, current_type, current_list_item)

            for child in cell.childNodes:
                walk(child, 0, 'unordered', None)
            flush_code()
            html_list_close_to_level(buf, list_state, 0)

            content = buf.getvalue().strip()
            html_out.append(content if content else '&nbsp;')
            html_out.append('</td>')

        html_out.append('</tr>')
    html_out.append('</table>')
    return "".join(html_out)


# --- MAIN & BUILDERS ---

def generate_focus_assets(docs_dir, config):
    js_dir = os.path.join(docs_dir, "javascripts")
    css_dir = os.path.join(docs_dir, "stylesheets")
    create_directory(js_dir)
    create_directory(css_dir)

    # Align "rich code" blocks (HTML-rendered) with Material's standard code blocks.
    code_cfg = (config.get("code", {}) or {})
    # --- Configurable image shadow & copy button style (V364) ---
    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", "4px"))
    img_shadow_zoomable = str(img_shadow_cfg.get("zoomable", "0 2px 10px rgba(0,0,0,.25)"))
    img_shadow_zoomable_hover = str(img_shadow_cfg.get("zoomable_hover", "0 6px 18px rgba(0,0,0,.35)"))
    img_shadow_lightbox = str(img_shadow_cfg.get("lightbox", "0 10px 30px rgba(0,0,0,.6)"))

    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;"))

    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", frame_bg_light) 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", frame_border_light) or "").strip()
    frame_border_width = str(frame_cfg.get("header_top_border_width", "0px") or "0px").strip()

    # Line-height
    rich_lh = code_cfg.get("rich_line_height", "theme")
    if rich_lh is None:
        rich_lh_css = "var(--md-code-line-height, 1.4)"
    else:
        rich_lh_s = str(rich_lh).strip().lower()
        if rich_lh_s in ("theme", "default", ""):
            rich_lh_css = "var(--md-code-line-height, 1.4)"
        else:
            rich_lh_css = str(rich_lh).strip()

    # Font family
    rich_ff = code_cfg.get("rich_font_family", "theme")
    rich_ff_s = str(rich_ff).strip() if rich_ff is not None else "theme"
    rich_ff_css = "var(--md-code-font-family)" if rich_ff_s.lower() in ("theme", "default", "") else rich_ff_s

    # Font size
    rich_fs = code_cfg.get("rich_font_size", "theme")
    rich_fs_s = str(rich_fs).strip() if rich_fs is not None else "theme"
    rich_fs_css = "var(--md-code-font-size)" if rich_fs_s.lower() in ("theme", "default", "") else rich_fs_s

    with open(os.path.join(css_dir, "focus.css"), "w", encoding="utf-8") as f:
        f.write(
            "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; }} img.odt-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; }} img.odt-zoomable:hover {{ transform: scale(1.02); box-shadow: {img_shadow_zoomable_hover if img_shadow_enabled else 'none'}; }} .odt-lightbox-overlay {{ position: fixed; inset: 0; background: rgba(0,0,0,.75); display: none; align-items: center; justify-content: center; z-index: 2000; padding: 24px; }} .odt-lightbox-overlay.is-open {{ display: flex; }} .odt-lightbox-content {{ position: relative; max-width: 95vw; max-height: 95vh; }} .odt-lightbox-content img {{ max-width: 95vw; max-height: 95vh; width: auto; height: auto; display: block; box-shadow: {img_shadow_lightbox if img_shadow_enabled else 'none'}; border-radius: {img_shadow_radius}; }} .odt-lightbox-close {{ position: absolute; top: -14px; right: -14px; width: 34px; height: 34px; border-radius: 999px; border: none; background: rgba(255,255,255,.92); color: #000; font-size: 22px; line-height: 34px; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.35); }} .odt-lightbox-close:hover {{ filter: brightness(0.95); }} .odt-lightbox-close:focus {{ outline: 2px solid rgba(255,255,255,.8); outline-offset: 2px; }} "
            f".odt-code-rich pre, .odt-code-rich code, .odt-code-rich .odt-code-line {{ "
            f"font-family: {rich_ff_css}; "
            f"font-size: {rich_fs_css}; "
            f"line-height: {rich_lh_css}; "
            "}} "
            ".odt-code-rich pre { margin: 0.75em 0; } "
            ".odt-code-rich code { display: block; } "
            ".odt-code-rich .odt-code-line { display: block; } "
            ".odt-code-rich[data-linenums='true'] .odt-code-line::before { "
            "counter-increment: odtline; content: counter(odtline); display: inline-block; "
            "width: 3em; margin-right: 1em; opacity: 0.6; text-align: right; } "
            ".odt-code-rich[data-linenums='false'] .odt-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; } "
            " .md-content ol ol ol ol { list-style-type: decimal; } "
            " .md-content ol ol ol ol ol { list-style-type: lower-alpha; } "
            " .md-content ol ol ol ol ol ol { list-style-type: lower-roman; }  .md-content ul ol { list-style-type: decimal; }  .md-content ul ol ol { list-style-type: lower-alpha; }  .md-content ul ol ol ol { list-style-type: lower-roman; }  .md-content ul ol ol ol ol { list-style-type: decimal; }  .md-content ul ol ol ol ol ol { list-style-type: lower-alpha; }  .md-content ul ol ol ol ol ol ol { list-style-type: lower-roman; } "
            f".odt-code-copy-btn {{ {copy_btn_css} }} "
            f".odt-code-copy-btn:hover {{ {copy_btn_hover_css} }} "
            f".odt-code-copy-btn.is-copied {{ {copy_btn_copied_css} }} "
            f".odt-code-copy-container {{ {copy_container_css} }} "
        )

    # Header background / top border overrides from config["frame"]
    if frame_bg_light or frame_bg_dark or frame_border_light or frame_border_dark:
        with open(os.path.join(css_dir, "focus.css"), "a", encoding="utf-8") as f:
            default_rules = []
            slate_rules = []
            if frame_bg_light:
                default_rules.append(f"background-color: {frame_bg_light};")
            if frame_bg_dark:
                slate_rules.append(f"background-color: {frame_bg_dark};")
            elif frame_bg_light:
                slate_rules.append(f"background-color: {frame_bg_light};")

            if frame_border_light:
                default_rules.append(f"border-top: {frame_border_width} solid {frame_border_light};")
            if frame_border_dark:
                slate_rules.append(f"border-top: {frame_border_width} solid {frame_border_dark};")
            elif frame_border_light:
                slate_rules.append(f"border-top: {frame_border_width} solid {frame_border_light};")

            f.write("\n")
            if default_rules:
                f.write('[data-md-color-scheme="default"] .md-header { ' + " ".join(default_rules) + ' }\n')
            if slate_rules:
                f.write('[data-md-color-scheme="slate"] .md-header { ' + " ".join(slate_rules) + ' }\n')

    # V356: Generate list marker CSS from ODT list styles (mixed UL/OL nesting aware)
    try:
        _lsm = (config.get('_odt_list_style_map') or {}) if isinstance(config, dict) else {}
        # Determine maximum depth observed
        _max_depth = 1
        for _si in _lsm.values():
            if isinstance(_si, dict):
                _li = _si.get('level_info') or {}
                if _li:
                    _max_depth = max(_max_depth, max([k for k in _li.keys() if isinstance(k, int)] + [1]))
        _max_depth = max(1, min(int(_max_depth), 12))
        _ul_pref, _ol_pref = _summarize_list_styles_by_depth(_lsm, max_depth=_max_depth)
        _css_lines = []
        for d in range(1, _max_depth + 1):
            ul_t = _ul_pref.get(d)
            if ul_t:
                sel = _build_depth_selector('ul', d)
                _css_lines.append(f"{sel} {{ list-style-type: {ul_t} !important; }}")
            ol_t = _ol_pref.get(d)
            if ol_t:
                sel = _build_depth_selector('ol', d)
                _css_lines.append(f"{sel} {{ list-style-type: {ol_t} !important; }}")
        if _css_lines:
            with open(os.path.join(css_dir, 'focus.css'), 'a', encoding='utf-8') as _f2:
                _f2.write('\n' + '\n'.join(_css_lines) + '\n')
    except Exception as _e:
        # Never break the build for CSS generation issues
        pass

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

    var header = document.querySelector(".md-header__inner");
    if (header) header.appendChild(btn);

    btn.addEventListener("click", function () {
      document.body.classList.toggle("md-focus");
      if (document.body.classList.contains("md-focus")) {
        btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2m3-8H5v2h5V5H8v3m6 11h2v-3h3v-2h-5v5m2-11V5h-2v5h5V8h-3Z"></path></svg>';
      } else {
        btn.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>';
      }
    });
  }

  // --- Image lightbox (V357) ---
  var overlay = document.querySelector(".odt-lightbox-overlay");
  var overlayImg = null;
  var closeBtn = null;

  function ensureLightbox() {
    if (overlay) return;

    overlay = document.createElement("div");
    overlay.className = "odt-lightbox-overlay";
    overlay.setAttribute("role", "dialog");
    overlay.setAttribute("aria-modal", "true");
    overlay.setAttribute("aria-label", "Agrandissement d'image");

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

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

    overlayImg = document.createElement("img");
    overlayImg.alt = "";

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

    // Close interactions
    closeBtn.addEventListener("click", closeLightbox);

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

    content.addEventListener("click", function (e) {
      e.stopPropagation();
    });

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

  function openLightbox(src, altText) {
    ensureLightbox();
    if (!overlayImg) return;

    overlayImg.src = src;
    overlayImg.alt = altText || "";
    overlay.classList.add("is-open");
    if (closeBtn) closeBtn.focus();
  }

  function closeLightbox() {
    if (!overlay) return;
    overlay.classList.remove("is-open");
    if (overlayImg) {
      overlayImg.src = "";
      overlayImg.alt = "";
    }
  }

  document.addEventListener("click", function (e) {
    var t = e.target;
    if (!t) return;
    if (t.tagName && t.tagName.toLowerCase() === "img" && t.classList.contains("odt-zoomable")) {
      var src = t.getAttribute("data-zoom-src") || t.getAttribute("src");
      if (!src) return;
      e.preventDefault();
      openLightbox(src, t.getAttribute("alt") || "Image");
    }
  });
});""")

        # --- Optional "Copy code" buttons for code blocks ---
        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", False))
            min_lines = int(code_cfg.get("copy_min_lines", 0) or 0)
            only_recognized_js = "true" if only_recognized else "false"
            min_lines_js = str(min_lines)
            pyg_heur = bool(code_cfg.get("copy_allow_pygments_heuristic", True))
            pyg_heur_js = "true" if pyg_heur else "false"

            f.write(f"""
// --- Copy-to-clipboard for code blocks (ODT converter) ---
(function() {{
  var COPY_LABEL = "{copy_label_js}";
  var COPIED_LABEL = "{copied_label_js}";
  var ONLY_RECOGNIZED = {only_recognized_js};
  var MIN_LINES = {min_lines_js};
  var PYGMENTS_HEURISTIC = {pyg_heur_js};

  // Languages considered "not recognized" (plain text)
  var EXCLUDED = {{ "text":1, "plaintext":1, "plain":1, "txt":1, "none":1 }};

  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 "";
    // try code, pre, container, ancestors
    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) {{
      // ancestors
      var cur = el;
      for (var k = 0; k < 4 && cur; k++) {{
        lang = getLangFromClass(cur);
        if (lang) break;
        cur = cur.parentElement;
      }}
    }}
    if (!lang) {{
      // data attributes
      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) {{
      // Heuristic: if the block contains Pygments token spans, syntax highlighting is active
      // even if no explicit language-xxx class is present.
      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;
    // normalize trailing newline
    text = text.replace(/\\s+$/,"");
    if (!text) return 0;
    return text.split("\\n").length;
  }}

  function extractCodeFromRich(container) {{
    // Prefer explicit content spans to avoid line numbers
    var parts = container.querySelectorAll(".odt-code-line-content");
    if (parts && parts.length) {{
      var lines = [];
      parts.forEach(function (sp) {{
        lines.push(sp.textContent || "");
      }});
      return lines.join("\\n");
    }}
    // Fallback: remove known line-number nodes then read text
    var code = container.querySelector("pre code") || container.querySelector("code") || container.querySelector("pre");
    if (!code) return "";
    var clone = code.cloneNode(true);
    clone.querySelectorAll(".odt-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) {{
    // Handle common "table with line numbers" layouts by preferring the code cell
    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(".odt-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 || {{}};
    var FORCE = !!opts.force;

    if (!container) return false;
    if (container.querySelector && container.querySelector(".odt-code-copy-btn")) return false;

    var lang = detectLanguage(container);
    // V369: Rich code blocks always get a [Copier] button (no language gating)
    if (!FORCE && 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;
    // Compute text first (needed for min-lines decision)
    var text = extractor(container);
    if (!shouldAddButton(container, text, opts)) return;

    container.classList.add("odt-code-copy-container");

    var btn = document.createElement("button");
    btn.type = "button";
    btn.className = "odt-code-copy-btn";
    btn.textContent = COPY_LABEL;

    btn.addEventListener("click", function (ev) {{
      ev.preventDefault();
      ev.stopPropagation();
      var t = extractor(container);
      copyText(t).then(function () {{
        setButtonState(btn);
      }});
    }});

    container.appendChild(btn);
  }}

  function initCopy(root) {{
    var scope = root || document;

    // Rich code blocks generated by the converter
    scope.querySelectorAll(".odt-code-rich").forEach(function (block) {{
      ensureButton(block, extractCodeFromRich, {{ force: true }});
    }});

    // Pygments highlight tables: attach only on td.code (prevents double buttons)
    scope.querySelectorAll("table.highlighttable").forEach(function (tbl) {{
      var cell = tbl.querySelector("td.code");
      if (cell) {{
        ensureButton(cell, function () {{ return extractCodeGeneric(cell); }});
      }}
    }});

    // Other code blocks: add a button on the nearest wrapper around <pre>
    scope.querySelectorAll("pre").forEach(function (pre) {{
      if (pre.closest && pre.closest(".odt-code-rich")) return;
      if (pre.closest && pre.closest("table.highlighttable")) return;

      var container = pre.parentElement;
      if (container && (container.classList.contains("highlight") || container.classList.contains("codehilite"))) {{
        ensureButton(container, function () {{ return extractCodeGeneric(container); }});
      }} else {{
        ensureButton(pre, function () {{ return extractCodeGeneric(pre); }});
      }}
    }});
  }}

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

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

  // MkDocs Material instant navigation support
  if (window.document$ && typeof window.document$.subscribe === "function") {{
    window.document$.subscribe(function () {{
      window.setTimeout(boot, 0);
    }});
  }} else {{
    // Fallback: observe content swaps
    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, config_data):
    mk = config_data.get('mkdocs', {})
    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', {})
    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', [])
    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 = 'null' if (k == "pymdownx.highlight" and sub_k == "linenums") else (
                                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', {})
    if extra_data:
        out += "\nextra:\n"
        if 'analytics' in extra_data:
            an = extra_data['analytics']
            out += f"  analytics:\n    provider: {an.get('provider', 'google')}\n    property: {an.get('property', '')}\n"

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


def convert_odt_to_site(odt_file, config_file):
    if not os.path.exists(odt_file): return print(f"Erreur: {odt_file} introuvable.")
    config = load_configuration(config_file)
    is_debug = config.get('debug', True)

    print(f"Traitement de {odt_file}...")
    if os.path.exists("docs"): shutil.rmtree("docs", ignore_errors=True)
    create_directory("docs");
    create_directory("docs/images");
    create_directory("overrides/partials/integrations")

    files_list = config.get('files_to_copy', [])
    for f in files_list:
        if os.path.exists(f):
            try:
                shutil.copy(f, os.path.join("docs", f));
                print(f"Copié : {f}")
            except Exception as e:
                print(f"Erreur copie {f}: {e}")

    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)

    doc = load(odt_file)
    images_map = extract_images_from_zip(odt_file, "docs/images")
    readable_map = build_readable_style_map(doc)
    inheritance_map = build_style_inheritance_map(doc)
    list_style_map = build_list_style_map(doc)
    # V356: expose list styles to asset generator
    if isinstance(config, dict):
        config['_odt_list_style_map'] = list_style_map


    # V321: Get colors and borders
    bold_styles, underlined_styles, highlight_colors, italic_styles, text_colors, paragraph_borders, paragraph_backgrounds = get_formatting_styles_with_inheritance(
        doc, inheritance_map)
    list_chain_map = build_list_chain_map_odfpy(doc)

    # --- PASSE 1 : SCAN DES CIBLES (Repères -> Fichiers) ---
    print("Scan des renvois...")
    targets_map = {}
    current_scan_file = "index.md"

    # On itère pour repérer les cibles
    for node, list_level, list_type in stream_nodes_context(doc.text, readable_map, inheritance_map, list_style_map):
        heading_level = 0
        if node.tagName in ('text:p', 'text:h'):
            heading_level = get_heading_level(node, readable_map, inheritance_map)

        if heading_level == 1:
            # On utilise txt pur pour le nom de fichier, sans ancres
            raw_txt = clean_text(node, escape=False, output_format="txt")
            current_scan_file = generate_safe_filename(raw_txt)

        recursive_scan_for_bookmarks(node, current_scan_file, targets_map)

    # --- PASSE 2 : GÉNÉRATION ---

    toc_cfg = config.get("toc", {}) if isinstance(config, dict) else {}
    home_label = toc_cfg.get("home_label", "Accueil")
    nav = [{home_label: "index.md"}]
    current_file = open("docs/index.md", "w", encoding="utf-8")
    current_filename_str = "index.md"
    footnotes_ctx = init_footnotes_ctx()

    counters = [0] * 10
    code_buffer = []
    code_buffer_html = []
    code_has_rich = False
    code_start_line = 1
    code_has_linenums = False

    found_first_h1 = False
    doc_title_config = config.get('document_title', {})
    target_styles = [unidecode(s).lower().strip() for s in doc_title_config.get('style_names', [])]
    previous_was_list = False
    prev_list_level = 0
    last_effective_list_type_by_level = {}

    # V377: state for preserving ordered list numbers outside code blocks
    list_number_state = {"counters": {}, "last_item": {}}

    print("Génération Markdown...")

    for node, list_level, list_type in stream_nodes_context(doc.text, readable_map, inheritance_map, list_style_map):

        # Track effective list types by depth (ordered/unordered).
        # We infer the effective type from ancestor list-styles to support mixed lists where
        # LibreOffice omits the style-name on nested <text:list> nodes.
        if list_level > 0:
            for k in list(last_effective_list_type_by_level.keys()):
                if k > list_level:
                    del last_effective_list_type_by_level[k]
            # V377: prune ordered-list numbering state for deeper levels
            for (lid, lvl) in list(list_number_state.get("counters", {}).keys()):
                if lvl > list_level:
                    list_number_state["counters"].pop((lid, lvl), None)
                    list_number_state.get("last_item", {}).pop((lid, lvl), None)
            eff = infer_list_type_from_ancestors(node, list_style_map) or list_type
            last_effective_list_type_by_level[list_level] = eff
        else:
            last_effective_list_type_by_level.clear()
            list_number_state["counters"].clear()
            list_number_state["last_item"].clear()
            html_list_close_to_level(current_file, list_number_state, target_level=0)
            list_number_state.pop("html_list_stack", None)

        if prev_list_level > 0 and list_level == 0: current_file.write("\n")
        if prev_list_level == 0 and list_level > 0: current_file.write("\n")

        # V382: close any raw-HTML ordered lists when leaving / decreasing list levels
        if prev_list_level > list_level:
            html_list_close_to_level(current_file, list_number_state, target_level=list_level)
        elif prev_list_level > 0 and list_level == 0:
            html_list_close_to_level(current_file, list_number_state, target_level=0)

        prev_list_level = list_level
        previous_was_list = (list_level > 0)

        # DEBUG PRE-H1
        if is_debug and not found_first_h1:
            if node.tagName in ('text:p', 'text:h'):
                dbg_style = get_attribute_safe(node, 'style-name')
                dbg_read = get_readable_style_name(dbg_style, readable_map, inheritance_map)
                dbg_txt = clean_text(node)[:50]
                print(f"[DEBUG PRE-H1] Tag={node.tagName} Style='{dbg_read}' Text='{dbg_txt}...'")

        # TABLEAU
        if node.tagName == 'table:table':
            current_file.write(
                process_table_to_html(node, images_map, bold_styles, underlined_styles, highlight_colors, italic_styles,
                                      text_colors, paragraph_borders, paragraph_backgrounds, readable_map, inheritance_map, list_style_map, list_chain_map,
                                      config, targets_map, current_filename_str, footnotes_ctx=footnotes_ctx) + "\n\n")
            continue

        # TITRE DOC
        if node.tagName in ('text:p', 'text:h'):
            p_style = get_attribute_safe(node, 'style-name')
            readable = get_readable_style_name(p_style, readable_map, inheritance_map)
            s_clean = unidecode(readable).lower().strip() if readable else ""
            if s_clean in target_styles:
                txt = clean_text(node, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                                 highlight_colors=highlight_colors, italic_styles=italic_styles,
                                 text_colors=text_colors, output_format="md", targets_map=targets_map,
                                 current_filename=current_filename_str, footnotes_ctx=footnotes_ctx)
                txt = apply_formatting(txt, p_style, set(), set(), highlight_colors, set(), text_colors,
                                       output_format="md", is_block_context=True)
                current_file.write(f'<p style="{doc_title_config.get("css", "")}">{txt}</p>\n\n')
                print(f">>> TITRE DOCUMENT TROUVÉ : {txt}")
                continue

        # CHAPITRES
        heading_level = 0
        if node.tagName in ('text:p', 'text:h'):
            heading_level = get_heading_level(node, readable_map, inheritance_map)


        if heading_level > 0:
            # --- FIX V405: ensure lists are closed before writing a heading ---
            html_list_close_to_level(current_file, list_number_state, target_level=0)
            list_number_state["counters"].clear()
            list_number_state["last_item"].clear()

            found_first_h1 = True
            if code_buffer:
                full = "\n".join(code_buffer)
                lang = detect_language(full, config['code'])
                indent = "    " * list_level
                if code_has_rich and any(x is not None for x in code_buffer_html):
                    # Rich HTML rendering (preserves bold/italic/underline/highlight/color spans from ODT)
                    lines_html = []
                    for i, pl in enumerate(code_buffer):
                        hl = code_buffer_html[i] if i < len(code_buffer_html) else None
                        if hl is None:
                            lines_html.append(html.escape(pl))
                        else:
                            lines_html.append(hl)
                    block_html = render_rich_code_block(lines_html, lang, start_line=code_start_line,
                                                        show_linenums=code_has_linenums)
                    # Indent HTML block when inside list contexts
                    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(
                        f"\n{header}\n" + "\n".join([f"{indent}{l}" for l in code_buffer]) + f"\n{indent}{fence}\n\n")
                code_buffer = []
                code_buffer_html = []
                code_has_rich = False
                code_has_linenums = False

            # TEXTE BRUT (pour log et nom de fichier)
            # V326: Important - output_format="txt" ne génère PAS d'ancres HTML
            raw_title_text = clean_text(node, escape=False, output_format="txt")

            # TEXTE FORMATÉ (pour affichage dans le fichier)
            # V326: Ici on génère le MD. Les ancres seront générées si présentes
            formatted_title_text = clean_text(node, escape=True, bold_styles=bold_styles,
                                              underlined_styles=underlined_styles, highlight_colors=highlight_colors,
                                              italic_styles=italic_styles, text_colors=text_colors, output_format="md",
                                              targets_map=targets_map, current_filename=current_filename_str,
                                              footnotes_ctx=footnotes_ctx).strip()

            # V326: Extraction propre des ancres pour mise en forme
            anchors = re.findall(r'<a id="[^"]+"></a>', formatted_title_text)
            clean_title = re.sub(r'<a id="[^"]+"></a>', '', formatted_title_text).strip()

            p_style = get_attribute_safe(node, 'style-name')
            final_display_title = apply_formatting(clean_title, p_style, set(), set(), highlight_colors, set(),
                                                   text_colors, output_format="md", is_block_context=True)

            counters[heading_level - 1] += 1
            for i in range(heading_level, 10): counters[i] = 0
            num_str = ".".join(str(counters[i]) for i in range(heading_level)) if heading_level > 1 else str(
                counters[0])

            print(f">>> CHAPITRE {heading_level}: {num_str} {raw_title_text}")

            if heading_level == 1:
                # V378: close any remaining forced HTML ordered lists before closing the current chapter
                html_ol = list_number_state.get('html_ol', {})
                if html_ol:
                    for lvl in sorted([l for l, st in html_ol.items() if st.get('open')], reverse=True):
                        indent_close = "    " * (lvl - 1)
                        current_file.write(f"{indent_close}</ol>\n")
                        st = html_ol.get(lvl, {})
                        st['open'] = False
                        st['forced'] = False
                        html_ol[lvl] = st

                write_footnotes(current_file, footnotes_ctx)
                current_file.close()
                fname = generate_safe_filename(raw_title_text)
                current_filename_str = fname  # Mise à jour pour les futurs liens
            
                current_file = open(f"docs/{fname}", "w", encoding="utf-8")
                footnotes_ctx = init_footnotes_ctx()
                # Write anchors first
                if anchors: current_file.write("\n".join(anchors) + "\n\n")
                current_file.write(f"# {num_str}. {final_display_title}\n\n")
                nav.append({f"{num_str}. {raw_title_text}": fname})
            else:
                # Write anchors first
                if anchors: current_file.write("\n".join(anchors) + "\n\n")
                current_file.write(f"{'#' * heading_level} {num_str}. {final_display_title}\n\n")
            continue

        # LISTE
        if node.tagName == 'text:list-item':
            # For ordered lists that require explicit start value (<ol start="n">),
            # we must output the list as raw HTML. In that case, inline formatting
            # (bold/italic/links/...) MUST also be emitted as HTML, because Markdown
            # is not parsed inside HTML blocks.
            indent = "    " * (list_level - 1)
            effective_list_type = last_effective_list_type_by_level.get(list_level, list_type)

            forced_html_ol = False
            marker = None
            n = 1
            st = None

            if effective_list_type == 'ordered':
                marker = compute_ordered_list_marker(
                    node, list_level, list_type, list_style_map, list_chain_map,
                    last_effective_list_type_by_level, list_number_state
                )
                mnum = re.match(r'^(\d+)\.', marker.strip())
                n = int(mnum.group(1)) if mnum else 1

                html_ol = list_number_state.setdefault('html_ol', {})
                st = html_ol.get(list_level, {'forced': False, 'open': False})

                # If the first visible number at this level is not 1, force raw HTML for this level
                if (not st['forced']) and n != 1:
                    st['forced'] = True
                forced_html_ol = st['forced']
                html_ol[list_level] = st

            output_fmt = "html" if forced_html_ol else "md"

            parts = []
            for child in node.childNodes:
                if child.tagName == 'text:p':
                    p_style = get_attribute_safe(child, 'style-name')
                    txt = clean_text(child, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                                     highlight_colors=highlight_colors, italic_styles=italic_styles,
                                     text_colors=text_colors, output_format=output_fmt, targets_map=targets_map,
                                     current_filename=current_filename_str, footnotes_ctx=footnotes_ctx)
                    txt = apply_formatting(txt, p_style, bold_styles, underlined_styles, highlight_colors,
                                           italic_styles, text_colors, output_format=output_fmt, is_block_context=True)
                    parts.append(txt)
            full = " ".join(parts).strip()
            # V378: Ordered lists with explicit numbering (LibreOffice "Commencer la numérotation ...")
            # Some Markdown->HTML pipelines ignore the numeric marker and always render 1,2,3...
            # When we detect a non-1 start at a given list level, we switch that level to raw HTML.
            if effective_list_type == 'ordered':
                if forced_html_ol:
                    if not st['open']:
                        current_file.write(f'{indent}<ol start="{n}">\n')
                        st['open'] = True
                    current_file.write(f'{indent}  <li>{full}</li>\n')
                    list_number_state['html_ol'][list_level] = st
                    continue

                current_file.write(f"{indent}{marker}{full}\n")
                continue

            # unordered list (Markdown)
            current_file.write(f"{indent}- {full}\n")
            continue

        # CODE
        if node.tagName == 'text:p' and is_code_paragraph(node, readable_map, inheritance_map,
                                                          config['code']['style_keywords']):
            if not code_buffer:
                ln = get_attribute_safe(node, 'line-number')
                sv = None
                if hasattr(node, 'parentNode') and node.parentNode.tagName == 'text:list-item':
                    sv = get_attribute_safe(node.parentNode, 'start-value')
                # Numérotation des blocs de code :
                # - On n'active linenums QUE si le document ODT numérote réellement le bloc (attribut line-number
                #   ou bloc inséré dans une liste numérotée avec un start-value / continuation détectée).
                v_list = get_block_start_odfpy(node, list_chain_map)
                code_start_line = v_list if v_list > 1 else (int(ln) if ln else 1)

                is_numbered_list_context = (list_level > 0 and list_type == "ordered")

                code_has_linenums = (ln is not None) or (sv is not None) or (
                            v_list and v_list > 1) or is_numbered_list_context

            # Collect plain line and, if needed, rich HTML line
            plain_line = clean_text(node, escape=False, output_format="txt", targets_map=targets_map,
                                    current_filename=current_filename_str, footnotes_ctx=footnotes_ctx)
            code_buffer.append(plain_line)

            if node_has_rich_code_formatting(node, bold_styles, italic_styles, underlined_styles, highlight_colors,
                                             text_colors):
                code_has_rich = True
            if code_has_rich:
                html_line = clean_text(node, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                                       highlight_colors=highlight_colors, italic_styles=italic_styles,
                                       text_colors=text_colors,
                                       output_format="code_html", targets_map=targets_map,
                                       current_filename=current_filename_str,
                                       footnotes_ctx=footnotes_ctx)
                code_buffer_html.append(html_line)
            else:
                code_buffer_html.append(None)
            continue

        if code_buffer:
            full = "\n".join(code_buffer)
            fence = get_fence(full)
            lang = detect_language(full, config['code'])
            indent = "    " * list_level
            if code_has_rich and any(x is not None for x in code_buffer_html):
                # Rich HTML rendering (preserves bold/italic/underline/highlight/color spans from ODT)
                lines_html = []
                for i, pl in enumerate(code_buffer):
                    hl = code_buffer_html[i] if i < len(code_buffer_html) else None
                    lines_html.append(html.escape(pl) 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)
                # Indent HTML block when inside list contexts
                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:
                header = f"{indent}{fence}{lang}" + (f' linenums="{code_start_line}"' if code_has_linenums else '')
                current_file.write(
                    f"\n{header}\n" + "\n".join([f"{indent}{l}" for l in code_buffer]) + f"\n{indent}{fence}\n\n")
            code_buffer = []
            code_buffer_html = []
            code_has_rich = False
            code_has_linenums = False

        # TEXTE STANDARD
        if node.tagName in ('text:p', 'text:h'):
            p_style = get_attribute_safe(node, 'style-name')
            # V325: Update clean_text
            txt = clean_text(node, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                             highlight_colors=highlight_colors, italic_styles=italic_styles, text_colors=text_colors,
                             output_format="md", targets_map=targets_map, current_filename=current_filename_str,
                             footnotes_ctx=footnotes_ctx)
            txt = apply_formatting(txt, p_style, bold_styles, underlined_styles, highlight_colors, italic_styles,
                                   text_colors, output_format="md")
            img = extract_image_tag_from_node(node, images_map)

            b = paragraph_borders.get(p_style) if p_style else None

            if txt.strip():
                if list_level > 0:
                    indent = "    " * (list_level - 1)
                    effective_list_type = last_effective_list_type_by_level.get(list_level, list_type)
                    # If an ancestor list is already rendered as raw HTML, or this paragraph has a background
                    # or paragraph borders, keep the subtree in raw HTML to preserve valid nesting and
                    # paragraph decorations inside list items.
                    p_bg = paragraph_backgrounds.get(p_style) if p_style else None
                    has_para_borders = bool(b and (b.get('top') or b.get('bottom')))
                    html_active = html_lists_active_upto_level(list_number_state, list_level) or html_list_stack_active_upto_level(list_number_state, list_level) or bool(p_bg) or has_para_borders

                    if effective_list_type == 'ordered':
                        marker = compute_ordered_list_marker(
                            node, list_level, list_type, list_style_map, list_chain_map,
                            last_effective_list_type_by_level, list_number_state
                        )
                        mnum = re.match(r'^(\d+)\.', marker.strip())
                        n = int(mnum.group(1)) if mnum else 1

                        # V382: Some Markdown->HTML pipelines ignore the numeric markers for ordered lists.
                        # If we detect a non-1 start at this level, we switch that level to raw HTML:
                        #   <ol start="n"><li>...</li>...</ol>
                        html_ol = list_number_state.setdefault('html_ol', {})
                        st = html_ol.get(list_level, {'forced': False, 'open': False})

                        if (not st['forced']) and n != 1:
                            st['forced'] = True
                        if html_active or st['forced']:
                            # Render this level (and its subtree) as raw HTML lists to keep valid nesting.
                            st['forced'] = True
                            html_ol[list_level] = st

                            li_node = _find_nearest_list_item(node)
                            stack = list_number_state.get('html_list_stack') or {}
                            opened = bool(stack.get(list_level, {}).get('open'))
                            start_attr = n if (st.get('forced') and (not opened) and isinstance(n, int) and n > 1) else None

                            li_content = clean_text(
                                node, escape=True, bold_styles=bold_styles,
                                underlined_styles=underlined_styles, highlight_colors=highlight_colors,
                                italic_styles=italic_styles, text_colors=text_colors, output_format="html",
                                targets_map=targets_map, current_filename=current_filename_str,
                                footnotes_ctx=footnotes_ctx
                            )
                            li_content = apply_formatting(li_content, p_style, bold_styles, underlined_styles,
                                                          highlight_colors, italic_styles, text_colors,
                                                          output_format="html", is_block_context=True)
                            if p_bg:
                                li_content = build_paragraph_block_html(li_content, p_bg, margin='0')
                            if b and b.get('top'):
                                li_content = build_hr_for_border(b.get('top'), 'top') + li_content
                            if b and b.get('bottom'):
                                li_content = li_content + build_hr_for_border(b.get('bottom'), 'bottom')

                            html_list_write_item(current_file, list_number_state, list_level, 'ol',
                                                 li_content, list_item_node=li_node, start_value=start_attr)
                        else:
                            current_file.write(f"{indent}{marker}{txt}\n")
                    else:
                        if html_active:
                            li_node = _find_nearest_list_item(node)
                            li_content = clean_text(
                                node, escape=True, bold_styles=bold_styles,
                                underlined_styles=underlined_styles, highlight_colors=highlight_colors,
                                italic_styles=italic_styles, text_colors=text_colors, output_format="html",
                                targets_map=targets_map, current_filename=current_filename_str,
                                footnotes_ctx=footnotes_ctx
                            )
                            li_content = apply_formatting(li_content, p_style, bold_styles, underlined_styles,
                                                          highlight_colors, italic_styles, text_colors,
                                                          output_format="html", is_block_context=True)
                            if p_bg:
                                li_content = build_paragraph_block_html(li_content, p_bg, margin='0')
                            if b and b.get('top'):
                                li_content = build_hr_for_border(b.get('top'), 'top') + li_content
                            if b and b.get('bottom'):
                                li_content = li_content + build_hr_for_border(b.get('bottom'), 'bottom')
                            html_list_write_item(current_file, list_number_state, list_level, 'ul',
                                                 li_content, list_item_node=li_node)
                        else:
                            current_file.write(f"{indent}- {txt}\n")
                else:
                    p_bg = paragraph_backgrounds.get(p_style) if p_style else None
                    if b and b.get('top'): current_file.write(build_hr_for_border(b.get('top'), 'top'))
                    if p_bg:
                        html_txt = clean_text(node, escape=True, bold_styles=bold_styles, underlined_styles=underlined_styles,
                                              highlight_colors=highlight_colors, italic_styles=italic_styles, text_colors=text_colors,
                                              output_format="html", targets_map=targets_map, current_filename=current_filename_str,
                                              footnotes_ctx=footnotes_ctx)
                        html_txt = apply_formatting(html_txt, p_style, bold_styles, underlined_styles, highlight_colors, italic_styles,
                                                    text_colors, output_format="html")
                        current_file.write(build_paragraph_block_html(html_txt, p_bg) + "\n\n")
                    else:
                        current_file.write(f"{txt}\n\n")
                    if b and b.get('bottom'): current_file.write(build_hr_for_border(b.get('bottom'), 'bottom'))
            if img: current_file.write(f"{img}\n\n")

    # Flush final : évite de perdre ou de dupliquer le dernier bloc de code si le document se termine par du code
    if code_buffer:
        full = "\n".join(code_buffer)
        fence = get_fence(full)
        lang = detect_language(full, config['code'])
        indent = "    " * 0
        header = f"{indent}{fence}{lang}" + (f' linenums="{code_start_line}"' if code_has_linenums else '')
        current_file.write(
            f"\n{header}\n" + "\n".join([f"{indent}{l}" for l in code_buffer]) + f"\n{indent}{fence}\n\n")
        code_buffer = []
        code_start_line = 1
        code_has_linenums = False

    # V382: close any remaining raw-HTML ordered lists
    html_list_close_to_level(current_file, list_number_state, target_level=0)

    # V382: close any remaining raw-HTML ordered lists
    html_list_close_to_level(current_file, list_number_state, target_level=0)

    write_footnotes(current_file, footnotes_ctx)
    current_file.close()
    generate_mkdocs_yml(nav, config)
    print("Terminé.")


if __name__ == "__main__":
    print(f"--- ODT to MkDocs Converter {VERSION} ---")
    if len(sys.argv) < 3:
        print("Usage: python convert.py <fichier.odt> <config.json>")
    else:
        convert_odt_to_site(sys.argv[1], sys.argv[2])