From 2ba71388816fbe9bb88b5c4194fe45e083c2b9ba Mon Sep 17 00:00:00 2001 From: bruno Date: Mon, 27 Jul 2026 12:32:11 -0400 Subject: [PATCH] feat: affichage PDF avec TOC + frame-src CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSP: ajout frame-src 'self' http://127.0.0.1:* (permet iframe PDF) - pdf_reader.py: extract_pdf_toc() — extrait les bookmarks/outline (pypdf + pymupdf) - main.py: inclut pdf_toc dans la réponse /api/file pour les PDF - viewer.js: sidebar TOC avec liens vers les pages (si bookmarks présents) - style.css: layout .pdf-body flex + .pdf-toc sidebar 240px --- backend/main.py | 4 +++- backend/pdf_reader.py | 38 ++++++++++++++++++++++++++++++++++++++ desktop/tauri.conf.json | 2 +- frontend/js/viewer.js | 19 +++++++++++++++++-- frontend/style.css | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index d810b38..fffb41d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2339,9 +2339,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative # === PDF: special handling before read_text (binary file) === if ext == ".pdf": try: - from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text + from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc pdf_text = extract_pdf_text(file_path, max_chars=100000) pdf_meta = extract_pdf_metadata(file_path) + pdf_toc = extract_pdf_toc(file_path) size = file_path.stat().st_size return { "vault": vault_name, @@ -2356,6 +2357,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative "is_pdf": True, "unsupported": False, "pdf_metadata": pdf_meta, + "pdf_toc": pdf_toc, "size_bytes": size, } except Exception as e: diff --git a/backend/pdf_reader.py b/backend/pdf_reader.py index bd431ee..c018ec2 100644 --- a/backend/pdf_reader.py +++ b/backend/pdf_reader.py @@ -57,6 +57,44 @@ def extract_pdf_metadata(file_path: Path) -> dict: return info +def extract_pdf_toc(file_path: Path) -> list[dict]: + """Extract table of contents (bookmarks/outline) from a PDF file. + + Returns a list of {title, page, level} dicts, or empty list on failure. + """ + toc: list[dict] = [] + try: + if PDF_READER == "pymupdf": + doc = fitz.open(str(file_path)) + raw = doc.get_toc(simple=False) + for item in raw: + toc.append({ + "title": str(item[1]), + "page": item[2], + "level": item[0], + }) + doc.close() + elif PdfReader is not None: + reader = PdfReader(str(file_path)) + outline = reader.outline + if outline: + def _flatten(items, level=1): + for item in items: + if isinstance(item, list): + _flatten(item, level + 1) + elif hasattr(item, 'title') and hasattr(item, 'page'): + page_num = reader.get_page_number(item.page) + 1 if hasattr(item, 'page') and item.page else 1 + toc.append({ + "title": str(item.title), + "page": page_num, + "level": level, + }) + _flatten(outline) + except Exception as e: + logger.warning("Failed to extract PDF TOC from %s: %s", file_path, e) + return toc + + def _extract_pymupdf(file_path: Path, max_chars: int) -> str: doc = fitz.open(str(file_path)) parts = [] diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index d80a6ef..4f58b3a 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -27,7 +27,7 @@ } ], "security": { - "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:* https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh; style-src 'self' 'unsafe-inline' http://127.0.0.1:* https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' http://127.0.0.1:* https://fonts.gstatic.com; img-src 'self' data: blob: http://127.0.0.1:* https:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*;" + "csp": "default-src 'self' http://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:* https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh; style-src 'self' 'unsafe-inline' http://127.0.0.1:* https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' http://127.0.0.1:* https://fonts.gstatic.com; img-src 'self' data: blob: http://127.0.0.1:* https:; frame-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*;" } }, "bundle": { diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 735f04f..cf8eaf8 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -402,10 +402,22 @@ async function renderBacklinksPanel(vault, path, container) { export function renderFile(data) { const area = document.getElementById("content-area"); - // Handle PDF files — render in iframe + // Handle PDF files — render in iframe with TOC sidebar if (data.is_pdf) { const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`; const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?'; + const toc = data.pdf_toc || []; + + let tocHtml = ''; + if (toc.length > 0) { + tocHtml = '

Table des matières

'; + } + area.innerHTML = `
@@ -417,7 +429,10 @@ export function renderFile(data) { Télécharger
- +
+ ${tocHtml} + +
`; lucide.createIcons(); return; diff --git a/frontend/style.css b/frontend/style.css index 9b915c9..ae2d9f5 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -8498,6 +8498,46 @@ body.popup-mode .content-area { border: none; min-height: 70vh; } +.pdf-body { + display: flex; + flex: 1; + min-height: 0; +} +.pdf-toc { + width: 240px; + flex-shrink: 0; + overflow-y: auto; + padding: 12px; + border-right: 1px solid var(--border); + background: var(--surface2); + font-size: 0.85rem; +} +.pdf-toc h3 { + margin: 0 0 8px 0; + font-size: 0.9rem; + color: var(--text-dim); +} +.pdf-toc ul { + list-style: none; + padding: 0; + margin: 0; +} +.pdf-toc li { + padding: 2px 0; +} +.pdf-toc a { + color: var(--text); + text-decoration: none; +} +.pdf-toc a:hover { + color: var(--accent); + text-decoration: underline; +} +.toc-page { + color: var(--text-dim); + font-size: 0.75rem; + margin-left: 4px; +} /* ── PaneManager — Split View ── */ .pane-grid {