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
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
@ -22,6 +22,11 @@ WORKDIR /app
|
|||||||
# Copy installed packages from builder stage
|
# Copy installed packages from builder stage
|
||||||
COPY --from=builder /install /usr/local
|
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 application code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
COPY frontend/ ./frontend/
|
COPY frontend/ ./frontend/
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from typing import Optional, List, Dict, Any
|
|||||||
|
|
||||||
import frontmatter
|
import frontmatter
|
||||||
import mistune
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse
|
from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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.router import router as auth_router
|
||||||
from backend.auth.middleware import require_auth, require_admin, check_vault_access
|
from backend.auth.middleware import require_auth, require_admin, check_vault_access
|
||||||
from backend.secret_redactor import redact_file_content
|
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.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
|
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)
|
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||||
async def api_file_save(
|
async def api_file_save(
|
||||||
vault_name: str,
|
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}")
|
@app.get("/s/{token}")
|
||||||
async def public_share_view(token: str):
|
async def public_share_view(token: str):
|
||||||
"""Public share view — no authentication required."""
|
"""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>
|
<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
|
.md
|
||||||
</button>
|
</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>
|
<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
|
PDF
|
||||||
</button>
|
</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 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(){{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 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>""")
|
</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
|
watchdog>=4.0.0
|
||||||
argon2-cffi>=23.1.0
|
argon2-cffi>=23.1.0
|
||||||
python-jose>=3.3.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")]);
|
const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [icon("download", 14), document.createTextNode("Télécharger")]);
|
||||||
downloadBtn.addEventListener("click", () => {
|
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 dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = dlUrl;
|
a.href = dlUrl;
|
||||||
@ -3253,6 +3260,13 @@
|
|||||||
document.body.removeChild(a);
|
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")]);
|
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
|
||||||
editBtn.addEventListener("click", () => {
|
editBtn.addEventListener("click", () => {
|
||||||
openEditor(data.vault, data.path);
|
openEditor(data.vault, data.path);
|
||||||
@ -3345,7 +3359,7 @@
|
|||||||
// Assemble
|
// Assemble
|
||||||
area.innerHTML = "";
|
area.innerHTML = "";
|
||||||
area.appendChild(breadcrumb);
|
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);
|
if (fmSection) area.appendChild(fmSection);
|
||||||
area.appendChild(mdDiv);
|
area.appendChild(mdDiv);
|
||||||
area.appendChild(rawDiv);
|
area.appendChild(rawDiv);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user