Add WeasyPrint PDF export for markdown files

This commit is contained in:
Bruno Charest 2026-05-26 21:22:02 -04:00
parent 9776311c20
commit c79202716c
5 changed files with 172 additions and 6 deletions

View File

@ -3,7 +3,7 @@
FROM python:3.11-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libffi-dev libc6-dev \
&& apt-get install -y --no-install-recommends gcc libffi-dev libc6-dev libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
@ -22,6 +22,11 @@ WORKDIR /app
# Copy installed packages from builder stage
COPY --from=builder /install /usr/local
# WeasyPrint runtime dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# Copy application code
COPY backend/ ./backend/
COPY frontend/ ./frontend/

View File

@ -18,7 +18,7 @@ from typing import Optional, List, Dict, Any
import frontmatter
import mistune
from fastapi import FastAPI, HTTPException, Query, Body, Depends
from fastapi import FastAPI, HTTPException, Query, Body, Depends, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
@ -558,7 +558,7 @@ app.add_middleware(SecurityHeadersMiddleware)
from backend.auth.router import router as auth_router
from backend.auth.middleware import require_auth, require_admin, check_vault_access
from backend.secret_redactor import redact_file_content
from backend.audit import log_file_save, log_file_delete
from backend.pdf_export import generate_pdf, build_pdf_html
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares
from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks
@ -1178,6 +1178,33 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
)
@app.get("/api/file/{vault_name}/pdf")
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a markdown file as PDF."""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(404, f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists():
raise HTTPException(404, f"File not found: {path}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_open(current_user.get("username"), vault_name, path)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
html = _render_markdown(post.content, vault_name, file_path)
title = post.metadata.get("title", file_path.stem)
pdf_html = build_pdf_html(html, str(title))
pdf_bytes = generate_pdf(pdf_html, str(title))
safe_name = "".join(c for c in str(title) if c.isalnum() or c in " _-.").strip() or "document"
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
async def api_file_save(
vault_name: str,
@ -2834,6 +2861,34 @@ table{{border-collapse:collapse;width:100%}}th,td{{border:1px solid #ddd;padding
)
@app.get("/s/{token}/pdf")
async def public_share_pdf_download(token: str):
"""Download shared document as real PDF via WeasyPrint."""
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
html = _render_markdown(post.content, share["vault"], file_path)
title = post.metadata.get("title", file_path.stem)
pdf_html = build_pdf_html(html, str(title))
pdf_bytes = generate_pdf(pdf_html, str(title))
safe_name = "".join(c for c in str(title) if c.isalnum() or c in " _-.").strip() or "document"
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
@app.get("/s/{token}")
async def public_share_view(token: str):
"""Public share view — no authentication required."""
@ -2902,7 +2957,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
.md
</button>
<button class="toolbar-btn" onclick="exportPDF()" title="Télécharger en PDF">
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
PDF
</button>
@ -2912,7 +2967,6 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
function exportMD(){{var t=document.getElementById("content").innerText;var b=new Blob([t],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download="{title}.md";a.click()}}
function exportPDF(){{var w=window.open("","_blank","width=800,height=600");w.document.write("<!DOCTYPE html><html><head><meta charset=utf-8><title>{title}</title><style>body{{font-family:Georgia,serif;max-width:700px;margin:40px auto;padding:0 20px;line-height:1.7;color:#1a1a2e;font-size:13px}}h1{{font-size:22px;border-bottom:2px solid #333;padding-bottom:6px}}h2{{font-size:18px;margin-top:20px}}h3{{font-size:15px}}pre{{background:#f5f5f5;padding:10px;border-radius:4px;font-size:11px}}code{{font-size:12px;background:#f5f5f5;padding:1px 3px}}img{{max-width:100%}}blockquote{{border-left:3px solid #ccc;padding-left:12px;color:#555}}table{{border-collapse:collapse;width:100%}}th,td{{border:1px solid #ddd;padding:6px 10px}}th{{background:#f0f0f0}}@media print{{body{{margin:20px}}}}</style></head><body><h1>{title}</h1>"+document.getElementById("content").innerHTML+"</body></html>");w.document.close();w.focus();setTimeout(function(){{w.print();w.close()}},500)}}
</script></body></html>""")

92
backend/pdf_export.py Normal file
View File

@ -0,0 +1,92 @@
"""
PDF export utility using WeasyPrint.
Generates PDF documents from rendered markdown HTML.
Used by both the authenticated API and public share views.
"""
import logging
from pathlib import Path
from typing import Optional
from weasyprint import HTML
logger = logging.getLogger("obsigate.pdf")
def generate_pdf(html_content: str, title: str = "document", base_url: Optional[str] = None) -> bytes:
"""Generate a PDF from HTML content.
Args:
html_content: Full HTML document string.
title: Document title (used for metadata).
base_url: Base URL for resolving relative URLs in the HTML.
Returns:
PDF file as bytes.
"""
html = HTML(string=html_content, base_url=base_url or "")
return html.write_pdf()
def build_pdf_html(body_html: str, title: str, theme: str = "light") -> str:
"""Wrap rendered markdown HTML in a complete print-friendly HTML document.
Args:
body_html: Rendered markdown HTML (without <html>/<body> wrappers).
title: Document title.
theme: 'light' or 'dark' light is recommended for PDF.
Returns:
Complete HTML document string.
"""
return f"""<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>{title}</title>
<style>
body {{
font-family: Georgia, "Times New Roman", serif;
max-width: 720px;
margin: 40px auto;
padding: 0 20px;
line-height: 1.7;
color: #1a1a2e;
font-size: 13px;
}}
h1 {{ font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; margin-top: 0; }}
h2 {{ font-size: 18px; margin-top: 24px; }}
h3 {{ font-size: 15px; margin-top: 20px; }}
h4, h5, h6 {{ font-size: 14px; margin-top: 16px; }}
p {{ margin: 8px 0; }}
pre {{
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 10px 14px;
font-size: 11px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}}
code {{ font-size: 12px; background: #f5f5f5; padding: 1px 4px; border-radius: 2px; }}
pre code {{ background: none; padding: 0; }}
a {{ color: #4f46e5; text-decoration: none; }}
img {{ max-width: 100%; border-radius: 4px; }}
blockquote {{
border-left: 3px solid #ccc;
padding-left: 14px;
color: #555;
margin: 12px 0;
}}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; page-break-inside: avoid; }}
th, td {{ border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 12px; }}
th {{ background: #f0f0f0; font-weight: 600; }}
ul, ol {{ margin: 8px 0; padding-left: 24px; }}
li {{ margin: 2px 0; }}
hr {{ border: none; border-top: 1px solid #ddd; margin: 20px 0; }}
@page {{ margin: 2cm; size: A4; }}
@media print {{ body {{ margin: 0; }} }}
</style></head>
<body><h1>{title}</h1>
{body_html}
</body></html>"""

View File

@ -8,3 +8,4 @@ aiohttp>=3.9.0
watchdog>=4.0.0
argon2-cffi>=23.1.0
python-jose>=3.3.0
weasyprint>=60.0

View File

@ -3244,6 +3244,13 @@
const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [icon("download", 14), document.createTextNode("Télécharger")]);
downloadBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
window.open(dlUrl, "_blank");
});
// MD download button
const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]);
mdBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
const a = document.createElement("a");
a.href = dlUrl;
@ -3253,6 +3260,13 @@
document.body.removeChild(a);
});
// PDF download button
const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]);
pdfBtn.addEventListener("click", () => {
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
window.open(pdfUrl, "_blank");
});
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
editBtn.addEventListener("click", () => {
openEditor(data.vault, data.path);
@ -3345,7 +3359,7 @@
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);