diff --git a/Dockerfile b/Dockerfile
index 9795090..8e690f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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/
diff --git a/backend/main.py b/backend/main.py
index ae94726..09cb54c 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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:
.md
-