547 lines
18 KiB
Python
547 lines
18 KiB
Python
"""Génération du Guide d'utilisation téléchargeable en Markdown et PDF (#105).
|
|
|
|
Source unique de vérité : la modale ``#help-modal`` de ``frontend/index.html``
|
|
(comme dans l'application) + les blocs ``data-i18n`` résolus dans les locales
|
|
``frontend/locales/{fr,en}.json`` — le téléchargement reflète donc exactement
|
|
ce que voit l'utilisateur, dans sa langue.
|
|
|
|
Le Markdown est produit par un convertisseur HTML→MD minimal (stdlib) ; le
|
|
PDF passe par le moteur d'export existant (WeasyPrint) avec repli reportlab
|
|
quand les bibliothèques natives GTK manquent (Windows).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import logging
|
|
import re
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger("obsigate.guide")
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
INDEX_HTML = ROOT / "frontend" / "index.html"
|
|
LOCALES_DIR = ROOT / "frontend" / "locales"
|
|
VERSION_FILE = ROOT / "VERSION"
|
|
DIAGRAMS_DIR = ROOT / "backend" / "assets" / "guide_diagrams"
|
|
|
|
|
|
def diagram_png_for(code: str) -> Path | None:
|
|
"""Chemin du PNG pré-rendu (scripts/build_guide_diagrams.py) pour un code
|
|
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
|
|
sha1(unescape(code).strip())[:16]."""
|
|
normalized = html.unescape(code).strip()
|
|
# Identifiant de cache déterministe (pas un usage sécurité).
|
|
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
|
|
png = DIAGRAMS_DIR / (sha + ".png")
|
|
return png if png.exists() else None
|
|
|
|
# Éléments décoratifs exclus des exports
|
|
_SKIP_CLASSES = {"help-hero-visual", "editor-modal", "help-nav"}
|
|
# En-tête HTML du guide (mode lecture)
|
|
_HEADER_BLOCK = "ObsiGate User Guide"
|
|
|
|
|
|
class Node:
|
|
"""Noeud DOM minimal (stdlib only)."""
|
|
|
|
__slots__ = ("attrs", "children", "parent", "tag")
|
|
|
|
def __init__(self, tag: str, attrs: dict[str, str | None], parent: Node | None = None):
|
|
self.tag = tag
|
|
self.attrs = attrs
|
|
self.children: list[Node | str] = []
|
|
self.parent = parent
|
|
|
|
def cls(self) -> str:
|
|
return self.attrs.get("class") or ""
|
|
|
|
def i18n(self) -> str | None:
|
|
v = self.attrs.get("data-i18n")
|
|
return v if isinstance(v, str) else None
|
|
|
|
def find_all(self, tag: str) -> list[Node]:
|
|
out: list[Node] = []
|
|
for c in self.children:
|
|
if isinstance(c, Node):
|
|
if c.tag == tag:
|
|
out.append(c)
|
|
out.extend(c.find_all(tag))
|
|
return out
|
|
|
|
|
|
_VOID_TAGS = {"br", "img", "hr", "input", "meta", "link"}
|
|
|
|
|
|
class _TreeBuilder(HTMLParser):
|
|
"""Constructeur d'arbre tolérant (ignore les balises orphelines)."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.root = Node("#root", {})
|
|
self.cur = self.root
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
a = {k: v for k, v in attrs}
|
|
node = Node(tag, a, self.cur)
|
|
self.cur.children.append(node)
|
|
if tag not in _VOID_TAGS:
|
|
self.cur = node
|
|
|
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
a = {k: v for k, v in attrs}
|
|
self.cur.children.append(Node(tag, a, self.cur))
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
n: Node | None = self.cur
|
|
while n is not None and n.tag != tag:
|
|
n = n.parent
|
|
if n is not None and n.parent is not None:
|
|
self.cur = n.parent
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
self.cur.children.append(data)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extraction / cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_cache: dict[tuple[str, str], tuple[tuple[float, int, float, int], bytes]] = {}
|
|
|
|
|
|
def _read_index_html() -> str:
|
|
return INDEX_HTML.read_text(encoding="utf-8")
|
|
|
|
|
|
def _guide_fragment(index_html: str) -> str:
|
|
"""Le HTML de #help-modal…help-content jusqu'au footer du guide."""
|
|
start = index_html.index('id="help-modal"')
|
|
cstart = index_html.index('<div class="help-content">', start)
|
|
end = index_html.index('<div class="help-footer">', cstart)
|
|
return index_html[cstart:end]
|
|
|
|
|
|
def _locale_strings(lang: str) -> dict[str, str]:
|
|
path = LOCALES_DIR / (lang if lang in ("fr", "en") else "fr")
|
|
return json.loads(Path(path).with_suffix(".json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _signature() -> tuple[float, int, float, int]:
|
|
st = INDEX_HTML.stat()
|
|
lt = (LOCALES_DIR / "fr.json").stat()
|
|
return (st.st_mtime, st.st_size, lt.st_mtime, lt.st_size)
|
|
|
|
|
|
def _app_version() -> str:
|
|
try:
|
|
return VERSION_FILE.read_text(encoding="utf-8").strip() or "dev"
|
|
except OSError:
|
|
return "dev"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Résolution i18n : un node portant data-i18n est REMPLACÉ par le contenu
|
|
# (HTML) de la locale — exactement comme _applyDOM() dans le navigateur.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _resolve_i18n(node: Node, loc: dict[str, str]) -> list[Node | str]:
|
|
"""Retourne les children effectifs d'un node (locale si data-i18n[-html])."""
|
|
key = node.i18n() or node.attrs.get("data-i18n-html")
|
|
if not isinstance(key, str):
|
|
return node.children
|
|
value = loc.get(key)
|
|
if value is None:
|
|
# clé absente de la locale : garder le texte FR inline de index.html
|
|
return node.children
|
|
tb = _TreeBuilder()
|
|
tb.feed(f"<span>{value}</span>")
|
|
span = tb.root.children[0]
|
|
assert isinstance(span, Node)
|
|
return span.children
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_WS_RE = re.compile(r"[ \t]*\n[ \t]*")
|
|
|
|
|
|
def _collapse(text: str) -> str:
|
|
return _WS_RE.sub(" ", text).strip()
|
|
|
|
|
|
def _md_inline(node: Node | str, loc: dict[str, str]) -> str:
|
|
if isinstance(node, str):
|
|
return _collapse(node)
|
|
tag = node.tag
|
|
kids = _resolve_i18n(node, loc)
|
|
inner = "".join(_md_inline(c, loc) for c in kids)
|
|
if tag == "br":
|
|
return " "
|
|
if tag in ("strong", "b"):
|
|
t = inner.strip()
|
|
return f"**{t}**" if t else ""
|
|
if tag in ("em", "i"):
|
|
if node.cls().startswith("lucide") or tag == "i" and not inner.strip():
|
|
return ""
|
|
t = inner.strip()
|
|
return f"*{t}*" if t else ""
|
|
if tag == "code":
|
|
t = inner.replace("`", "'").strip()
|
|
return f"`{t}`" if t else ""
|
|
if tag == "kbd":
|
|
t = inner.strip()
|
|
return f"`{t}`" if t else ""
|
|
if tag == "a":
|
|
href = node.attrs.get("href") or ""
|
|
t = inner.strip()
|
|
if href.startswith("http") and t:
|
|
return f"[{t}]({href})"
|
|
return t
|
|
if tag == "img":
|
|
alt = node.attrs.get("alt") or ""
|
|
return f"![{alt}]"
|
|
return inner
|
|
|
|
|
|
def _md_block(node: Node | str, out: list[str], loc: dict[str, str], depth: int = 0) -> None:
|
|
"""Remplit ``out`` (bloc courant) — ``pending`` gère listes imbriquées."""
|
|
if isinstance(node, str):
|
|
t = _collapse(node)
|
|
if t:
|
|
out.append(t)
|
|
return
|
|
if any(c in node.cls().split() for c in _SKIP_CLASSES):
|
|
return
|
|
tag = node.tag
|
|
|
|
if tag == "pre":
|
|
raw = _pre_text(node)
|
|
lang = "mermaid" if "mermaid" in raw[:40] or "language-mermaid" in _pre_classes(node) else ""
|
|
out.append(f"```{lang}\n{raw.rstrip()}\n```")
|
|
return
|
|
|
|
kids = _resolve_i18n(node, loc)
|
|
|
|
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
level = int(tag[1])
|
|
text = _collapse("".join(_md_inline(c, loc) for c in kids))
|
|
if text:
|
|
out.append("#" * level + " " + text)
|
|
return
|
|
|
|
if tag == "p":
|
|
text = _collapse("".join(_md_inline(c, loc) for c in kids))
|
|
if text:
|
|
out.append(text)
|
|
return
|
|
|
|
if tag in ("ul", "ol"):
|
|
_md_list(kids, out, loc, tag, depth)
|
|
return
|
|
|
|
if tag == "table":
|
|
_md_table(node, out, loc)
|
|
return
|
|
|
|
# conteneurs neutres (section, div, span de bloc, li imbriqué…)
|
|
for c in kids:
|
|
_md_block(c, out, loc, depth)
|
|
|
|
|
|
def _md_list(items: list[Node | str], out: list[str], loc: dict[str, str], kind: str, depth: int) -> None:
|
|
n = 0
|
|
for li in items:
|
|
if isinstance(li, str):
|
|
continue
|
|
if li.tag == "li":
|
|
n += 1
|
|
marker = "- " if kind == "ul" else f"{n}. "
|
|
text_parts: list[str] = []
|
|
nested: list[Node] = []
|
|
for c in li.children:
|
|
if isinstance(c, Node) and c.tag in ("ul", "ol"):
|
|
nested.append(c)
|
|
else:
|
|
text_parts.append(_md_inline(c, loc))
|
|
line = _collapse("".join(text_parts))
|
|
if line:
|
|
out.append(" " * depth + marker + line)
|
|
for sub in nested:
|
|
_md_list(sub.children, out, loc, sub.tag, depth + 1)
|
|
elif li.tag in ("ul", "ol"):
|
|
_md_list(li.children, out, loc, li.tag, depth)
|
|
|
|
|
|
def _md_table(node: Node, out: list[str], loc: dict[str, str]) -> None:
|
|
rows = node.find_all("tr")
|
|
if not rows:
|
|
return
|
|
grid: list[list[str]] = []
|
|
for tr in rows:
|
|
cells = []
|
|
for td in tr.children:
|
|
if isinstance(td, Node) and td.tag in ("td", "th"):
|
|
cells.append(_collapse("".join(_md_inline(c, loc) for c in td.children)).replace("|", "\\|") or " ")
|
|
if cells:
|
|
grid.append(cells)
|
|
if not grid:
|
|
return
|
|
width = max(len(r) for r in grid)
|
|
grid = [r + [" "] * (width - len(r)) for r in grid]
|
|
out.append("| " + " | ".join(grid[0]) + " |")
|
|
out.append("|" + "|".join([" --- "] * width) + "|")
|
|
for r in grid[1:]:
|
|
out.append("| " + " | ".join(r) + " |")
|
|
|
|
|
|
def _pre_text(node: Node) -> str:
|
|
"""Texte brut préservé d'un <pre> (les locales n'y touchent pas)."""
|
|
buf: list[str] = []
|
|
|
|
def walk(n: Node | str) -> None:
|
|
if isinstance(n, str):
|
|
buf.append(n)
|
|
return
|
|
for c in n.children:
|
|
walk(c)
|
|
|
|
walk(node)
|
|
return "".join(buf).strip("\n")
|
|
|
|
|
|
def _pre_classes(node: Node) -> str:
|
|
cls = node.cls()
|
|
for c in node.find_all("code"):
|
|
cls += " " + c.cls()
|
|
return cls
|
|
|
|
|
|
def build_guide_markdown(lang: str = "fr") -> bytes:
|
|
"""Guide complet en Markdown (UTF-8), dans la langue demandée."""
|
|
index_html = _read_index_html()
|
|
loc = _locale_strings(lang)
|
|
tree = _TreeBuilder()
|
|
tree.feed(_guide_fragment(index_html))
|
|
root = tree.root.children[0]
|
|
assert isinstance(root, Node)
|
|
|
|
blocks: list[str] = []
|
|
content = _guide_title_fr if lang == "fr" else _guide_title_en
|
|
blocks.append("# " + content)
|
|
for section in root.find_all("section"):
|
|
_md_block(section, blocks, loc)
|
|
blocks.append(
|
|
"---\n\n"
|
|
+ _export_footer(lang)
|
|
)
|
|
md = "\n\n".join(b for b in blocks if b.strip()) + "\n"
|
|
return md.encode("utf-8")
|
|
|
|
|
|
_guide_title_fr = "Guide d'utilisation ObsiGate"
|
|
_guide_title_en = "ObsiGate User Guide"
|
|
|
|
|
|
def _export_footer(lang: str) -> str:
|
|
loc = _locale_strings(lang)
|
|
template = loc.get("guide105.export_footer", "")
|
|
if "%s" not in template and "{" not in template:
|
|
template = "ObsiGate {version}"
|
|
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
|
|
return _collapse(template).format(version=_app_version(), date=today)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML (pour le PDF) — mêmes règles, sortie balisée propre
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _html_inline(node: Node | str, loc: dict[str, str]) -> str:
|
|
if isinstance(node, str):
|
|
return html.escape(_collapse(node), quote=False)
|
|
tag = node.tag
|
|
kids = _resolve_i18n(node, loc)
|
|
inner = "".join(_html_inline(c, loc) for c in kids)
|
|
if tag == "br":
|
|
return " "
|
|
if tag in ("strong", "b") and inner.strip():
|
|
return f"<strong>{inner}</strong>"
|
|
if tag in ("em",) and inner.strip():
|
|
return f"<em>{inner}</em>"
|
|
if tag == "code":
|
|
t = inner.strip()
|
|
return f"<code>{t}</code>" if t else ""
|
|
if tag == "kbd":
|
|
t = inner.strip()
|
|
return f"<code>{t}</code>" if t else ""
|
|
if tag == "a":
|
|
href = node.attrs.get("href") or ""
|
|
if href.startswith("http"):
|
|
return f'<a href="{html.escape(href, quote=True)}">{inner}</a>'
|
|
return inner
|
|
return inner
|
|
|
|
|
|
def _html_block(node: Node | str, out: list[str], loc: dict[str, str]) -> None:
|
|
if isinstance(node, str):
|
|
t = _collapse(node)
|
|
if t:
|
|
out.append(f"<p>{html.escape(t, quote=False)}</p>")
|
|
return
|
|
if any(c in node.cls().split() for c in _SKIP_CLASSES):
|
|
return
|
|
tag = node.tag
|
|
|
|
if tag == "pre":
|
|
raw = _pre_text(node)
|
|
classes = _pre_classes(node)
|
|
if "language-mermaid" in classes:
|
|
png = diagram_png_for(raw)
|
|
if png is not None:
|
|
url = "file:///" + str(png).replace("\\", "/").lstrip("/")
|
|
out.append(f'<img src="{url}" style="max-width: 100%" />')
|
|
return
|
|
out.append(f"<pre><code>{html.escape(raw, quote=False)}</code></pre>")
|
|
return
|
|
|
|
kids = _resolve_i18n(node, loc)
|
|
|
|
if tag in ("h2", "h3", "h4"):
|
|
text = _collapse("".join(_html_inline(c, loc) for c in kids))
|
|
if text:
|
|
out.append(f"<{tag}>{text}</{tag}>")
|
|
return
|
|
|
|
if tag == "p":
|
|
text = "".join(_html_inline(c, loc) for c in kids).strip()
|
|
if text:
|
|
out.append(f"<p>{text}</p>")
|
|
return
|
|
|
|
if tag in ("ul", "ol"):
|
|
out.append(_html_list(kids, loc, tag))
|
|
return
|
|
|
|
if tag == "table":
|
|
out.append(_html_table(node, loc))
|
|
return
|
|
|
|
for c in kids:
|
|
_html_block(c, out, loc)
|
|
|
|
|
|
def _html_list(items: list[Node | str], loc: dict[str, str], kind: str) -> str:
|
|
parts: list[str] = []
|
|
n = 0
|
|
for li in items:
|
|
if isinstance(li, str):
|
|
continue
|
|
if li.tag == "li":
|
|
n += 1
|
|
text_parts: list[str] = []
|
|
nested: list[Node] = []
|
|
for c in li.children:
|
|
if isinstance(c, Node) and c.tag in ("ul", "ol"):
|
|
nested.append(c)
|
|
else:
|
|
text_parts.append(_html_inline(c, loc))
|
|
line = "".join(text_parts).strip()
|
|
inner = line + "".join(_html_list(s.children, loc, s.tag) for s in nested)
|
|
if inner:
|
|
parts.append(f"<li>{inner}</li>")
|
|
elif li.tag in ("ul", "ol"):
|
|
parts.append(_html_list(li.children, loc, li.tag))
|
|
body = "".join(parts)
|
|
return f"<{kind}>{body}</{kind}>"
|
|
|
|
|
|
def _html_table(node: Node, loc: dict[str, str]) -> str:
|
|
rows_html: list[str] = []
|
|
for tr in node.find_all("tr"):
|
|
cells: list[str] = []
|
|
for td in tr.children:
|
|
if isinstance(td, Node) and td.tag in ("td", "th"):
|
|
tag = td.tag
|
|
inner = _collapse("".join(_html_inline(c, loc) for c in td.children))
|
|
cells.append(f"<{tag}>{inner}</{tag}>")
|
|
if cells:
|
|
rows_html.append("<tr>{}</tr>".format("".join(cells)))
|
|
return "<table>{}</table>".format("".join(rows_html))
|
|
|
|
|
|
def build_guide_html(lang: str = "fr") -> str:
|
|
"""Corps HTML autonome du guide (pour rendu PDF)."""
|
|
index_html = _read_index_html()
|
|
loc = _locale_strings(lang)
|
|
tree = _TreeBuilder()
|
|
tree.feed(_guide_fragment(index_html))
|
|
root = tree.root.children[0]
|
|
assert isinstance(root, Node)
|
|
|
|
blocks: list[str] = []
|
|
for section in root.find_all("section"):
|
|
_html_block(section, blocks, loc)
|
|
return "\n".join(blocks)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PDF (WeasyPrint, repli reportlab)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_guide_pdf(lang: str = "fr") -> bytes:
|
|
lang_norm = lang if lang in ("fr", "en") else "fr"
|
|
title = _guide_title_fr if lang_norm == "fr" else _guide_title_en
|
|
loc = _locale_strings(lang_norm)
|
|
note = loc.get("guide105.arch_diagram_note", "")
|
|
footer = _export_footer(lang_norm)
|
|
try:
|
|
from backend.pdf_export import build_pdf_html, generate_pdf
|
|
|
|
body = build_guide_html(lang_norm)
|
|
body += (
|
|
f"<hr><p style='color:#777;font-size:11px'>{html.escape(note, quote=False)} — {html.escape(footer, quote=False)}</p>"
|
|
)
|
|
return generate_pdf(build_pdf_html(body, title), title)
|
|
except Exception as e: # WeasyPrint lève à l'import OU au rendu (GTK absent)
|
|
logger.warning("WeasyPrint indisponible pour le guide PDF (%s) — repli reportlab", e)
|
|
md = build_guide_markdown(lang_norm).decode("utf-8")
|
|
from backend.tools.documents import _render_reportlab_pdf
|
|
|
|
return _render_reportlab_pdf(md, title)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Point d'entrée + cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_guide_document(fmt: str, lang: str) -> tuple[bytes, str, str]:
|
|
"""Retourne (octets, media_type, filename) pour le format demandé.
|
|
|
|
``fmt`` : ``md`` | ``pdf``. Résultat mis en cache tant que index.html et
|
|
fr.json ne changent pas (les locales en ne divergent jamais sur les
|
|
structures ; la signature couvre l'essentiel).
|
|
"""
|
|
fmt = "pdf" if fmt == "pdf" else "md"
|
|
lang = "en" if lang == "en" else "fr"
|
|
key = (fmt, lang)
|
|
sig = _signature()
|
|
hit = _cache.get(key)
|
|
if hit and hit[0] == sig:
|
|
payload = hit[1]
|
|
else:
|
|
payload = build_guide_pdf(lang) if fmt == "pdf" else build_guide_markdown(lang)
|
|
_cache[key] = (sig, payload)
|
|
fname = f"ObsiGate-Guide-{_app_version()}-{lang}.{fmt}"
|
|
media = "application/pdf" if fmt == "pdf" else "text/markdown; charset=utf-8"
|
|
return payload, media, fname
|