Add WeasyPrint PDF export for markdown files
This commit is contained in:
parent
9776311c20
commit
c79202716c
@ -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/
|
||||
|
||||
@ -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
92
backend/pdf_export.py
Normal 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>"""
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user