fix(pdf): replace fpdf2 with weasyprint for proper md→PDF rendering

fpdf2's built-in Helvetica was Latin-1 only and required manual line-by-line
markdown parsing, causing indentation artifacts at speaker changes. weasyprint
renders via HTML+CSS so layout, wrapping, and Unicode all work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 09:30:34 +02:00
parent 4a81ef3673
commit 2389d377b8
3 changed files with 36 additions and 63 deletions

View File

@@ -3,6 +3,13 @@ FROM docker.io/library/python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt /app/requirements.txt COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt

90
app.py
View File

@@ -14,7 +14,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import fpdf as fpdflib import weasyprint
import markdown as md import markdown as md
import requests import requests
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
@@ -261,67 +261,33 @@ def _split_thinking(text: str) -> tuple[str, str]:
def _md_to_pdf(title: str, content_md: str) -> bytes: def _md_to_pdf(title: str, content_md: str) -> bytes:
"""Render Markdown content to a PDF byte string.""" """Render Markdown to PDF via weasyprint (HTML+CSS → PDF, full Unicode support)."""
body_html = md.markdown(content_md, extensions=["fenced_code", "tables", "nl2br"])
def _l1(text: str) -> str: html = f"""<!DOCTYPE html>
"""Sanitize text to Latin-1; fpdf2 built-in fonts require it.""" <html lang="de"><head>
return text.encode("latin-1", errors="replace").decode("latin-1") <meta charset="utf-8">
<title>{title}</title>
_title = _l1(title[:100]) <style>
@page {{
class PDF(fpdflib.FPDF): margin: 2cm;
def header(self): @bottom-center {{ content: "Seite " counter(page); font-size: 8pt; color: #999; }}
self.set_font("Helvetica", "B", 9) @top-left {{ content: "{title[:80]}"; font-size: 8pt; color: #999; }}
self.set_text_color(100, 100, 100) }}
self.cell(0, 6, _title, align="L") body {{ font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #1a1a1a; line-height: 1.6; }}
self.ln(2) h1 {{ font-size: 15pt; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-top: 0; }}
self.set_draw_color(200, 200, 200) h2 {{ font-size: 13pt; color: #333; margin-top: 14pt; }}
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y()) h3 {{ font-size: 11pt; color: #444; margin-top: 10pt; }}
self.ln(4) pre, code {{ font-family: DejaVu Sans Mono, monospace; font-size: 9pt;
background: #f5f5f5; border-radius: 3px; padding: 1px 4px; }}
def footer(self): pre {{ padding: 8px; white-space: pre-wrap; word-break: break-all; }}
self.set_y(-12) hr {{ border: none; border-top: 1px solid #ddd; margin: 12px 0; }}
self.set_font("Helvetica", "I", 8) p {{ margin: 4px 0 8px; }}
self.set_text_color(160, 160, 160) </style>
self.cell(0, 6, f"Seite {self.page_no()}", align="C") </head><body>
<h1>{title}</h1>
pdf = PDF(orientation="P", unit="mm", format="A4") {body_html}
pdf.set_auto_page_break(auto=True, margin=15) </body></html>"""
pdf.add_page() return weasyprint.HTML(string=html).write_pdf()
def _cell(text: str, size: int = 10, bold: bool = False, indent: float = 0, h: float = 5):
try:
pdf.set_font("Helvetica", "B" if bold else "", size)
pdf.set_text_color(20 if bold else 30, 20 if bold else 30, 20 if bold else 30)
if indent:
pdf.set_x(pdf.l_margin + indent)
pdf.multi_cell(pdf.w - pdf.l_margin - pdf.r_margin - indent, h, _l1(text))
except Exception:
pass
for raw_line in content_md.split("\n"):
line = raw_line.rstrip()
if line.startswith("# "):
_cell(line[2:], size=14, bold=True, h=7)
pdf.ln(1)
elif line.startswith("## "):
_cell(line[3:], size=12, bold=True, h=6)
pdf.ln(1)
elif line.startswith("### "):
_cell(line[4:], size=11, bold=True, h=6)
elif line.startswith(("- ", "* ", "+ ")):
_cell("" + line[2:], indent=5)
elif re.match(r"^\d+\. ", line):
_cell(line, indent=5)
elif line in ("", "---"):
pdf.ln(3)
else:
clean = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
clean = re.sub(r"\*(.+?)\*", r"\1", clean)
clean = re.sub(r"`(.+?)`", r"\1", clean)
_cell(clean)
return bytes(pdf.output())
def _collect_doc_chain(input_doc) -> list[dict]: def _collect_doc_chain(input_doc) -> list[dict]:

View File

@@ -3,4 +3,4 @@ uvicorn[standard]==0.32.1
requests==2.32.3 requests==2.32.3
python-multipart==0.0.12 python-multipart==0.0.12
markdown==3.7 markdown==3.7
fpdf2==2.8.3 weasyprint>=62.3