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

90
app.py
View File

@@ -14,7 +14,7 @@ from datetime import datetime
from pathlib import Path
from typing import List, Optional
import fpdf as fpdflib
import weasyprint
import markdown as md
import requests
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:
"""Render Markdown content to a PDF byte string."""
def _l1(text: str) -> str:
"""Sanitize text to Latin-1; fpdf2 built-in fonts require it."""
return text.encode("latin-1", errors="replace").decode("latin-1")
_title = _l1(title[:100])
class PDF(fpdflib.FPDF):
def header(self):
self.set_font("Helvetica", "B", 9)
self.set_text_color(100, 100, 100)
self.cell(0, 6, _title, align="L")
self.ln(2)
self.set_draw_color(200, 200, 200)
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
self.ln(4)
def footer(self):
self.set_y(-12)
self.set_font("Helvetica", "I", 8)
self.set_text_color(160, 160, 160)
self.cell(0, 6, f"Seite {self.page_no()}", align="C")
pdf = PDF(orientation="P", unit="mm", format="A4")
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
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())
"""Render Markdown to PDF via weasyprint (HTML+CSS → PDF, full Unicode support)."""
body_html = md.markdown(content_md, extensions=["fenced_code", "tables", "nl2br"])
html = f"""<!DOCTYPE html>
<html lang="de"><head>
<meta charset="utf-8">
<title>{title}</title>
<style>
@page {{
margin: 2cm;
@bottom-center {{ content: "Seite " counter(page); font-size: 8pt; color: #999; }}
@top-left {{ content: "{title[:80]}"; font-size: 8pt; color: #999; }}
}}
body {{ font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #1a1a1a; line-height: 1.6; }}
h1 {{ font-size: 15pt; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-top: 0; }}
h2 {{ font-size: 13pt; color: #333; margin-top: 14pt; }}
h3 {{ font-size: 11pt; color: #444; margin-top: 10pt; }}
pre, code {{ font-family: DejaVu Sans Mono, monospace; font-size: 9pt;
background: #f5f5f5; border-radius: 3px; padding: 1px 4px; }}
pre {{ padding: 8px; white-space: pre-wrap; word-break: break-all; }}
hr {{ border: none; border-top: 1px solid #ddd; margin: 12px 0; }}
p {{ margin: 4px 0 8px; }}
</style>
</head><body>
<h1>{title}</h1>
{body_html}
</body></html>"""
return weasyprint.HTML(string=html).write_pdf()
def _collect_doc_chain(input_doc) -> list[dict]: