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:
@@ -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
90
app.py
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user