#47: Volles PDF mit Highlight statt 1-Seiten-Extract

User-Feedback: "Kontext geht verloren wenn nur 1 Seite kommt".

Änderung: render_highlighted_page liefert jetzt das GESAMTE Wahlprogramm-
PDF mit gelber Highlight-Annotation auf der Fundstelle, statt eines
1-Seiten-Auszugs. Der Browser öffnet das vollständige Programm.

Frontend hängt #page=N an die URL → Browser scrollt direkt zur
Fundstelle. found_page wird als X-Found-Page Header mitgeliefert,
falls der Text auf einer anderen Seite als angefordert gefunden wurde
(Pre-#60 halluzinierte Seitennummern).

Return-Typ geändert: (bytes, int) statt bytes — zweiter Wert ist die
1-indexed Seitennummer wo der Treffer tatsächlich liegt.

Tests angepasst: Tuple-Unpacking, Size-Check entfernt (volles PDF ist
größer als 1-Seiten-Extract, der alte Vergleich war obsolet).

Refs: #47
This commit is contained in:
Dotty Dotter 2026-04-10 10:16:00 +02:00
parent 5a035be20b
commit 6f35efe4d7
4 changed files with 61 additions and 59 deletions

View File

@ -592,6 +592,11 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
``add_highlight_annot``. Returns the serialized PDF bytes, or None ``add_highlight_annot``. Returns the serialized PDF bytes, or None
if the programme/page can't be resolved. if the programme/page can't be resolved.
Returns a tuple ``(pdf_bytes, found_page)`` where ``found_page`` is the
1-indexed page where the highlight was placed (may differ from ``seite``
if the text was found on a different page). Returns ``(None, 0)`` if the
programme/page can't be resolved.
Args: Args:
programm_id: Key into PROGRAMME registry validated by caller. programm_id: Key into PROGRAMME registry validated by caller.
seite: 1-indexed page number within the programme PDF. seite: 1-indexed page number within the programme PDF.
@ -602,22 +607,22 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
""" """
info = PROGRAMME.get(programm_id) info = PROGRAMME.get(programm_id)
if not info: if not info:
return None return None, 0
pdf_filename = info.get("pdf") pdf_filename = info.get("pdf")
if not pdf_filename: if not pdf_filename:
return None return None, 0
referenzen = Path(__file__).parent / "static" / "referenzen" referenzen = Path(__file__).parent / "static" / "referenzen"
pdf_path = referenzen / pdf_filename pdf_path = referenzen / pdf_filename
if not pdf_path.exists(): if not pdf_path.exists():
return None return None, 0
needle = (query or "").strip()[:200] needle = (query or "").strip()[:200]
src = fitz.open(str(pdf_path)) src = fitz.open(str(pdf_path))
try: try:
if seite < 1 or seite > len(src): if seite < 1 or seite > len(src):
return None return None, 0
# Suche den Needle auf der angegebenen Seite. Falls dort nichts # Suche den Needle auf der angegebenen Seite. Falls dort nichts
# gefunden wird (Pre-#60-Assessments haben oft falsche Seiten- # gefunden wird (Pre-#60-Assessments haben oft falsche Seiten-
@ -625,40 +630,37 @@ def render_highlighted_page(programm_id: str, seite: int, query: str) -> Optiona
# mit einem Treffer — so funktioniert Highlighting auch bei # mit einem Treffer — so funktioniert Highlighting auch bei
# halluzinierten Seitenzahlen retroaktiv. # halluzinierten Seitenzahlen retroaktiv.
target_page_idx = seite - 1 target_page_idx = seite - 1
rects = []
if needle: if needle:
clean = needle.replace("\u00ad", "") clean = needle.replace("\u00ad", "")
# Versuch 1: angegebene Seite
rects = src[target_page_idx].search_for(clean)
if not rects:
words = clean.split() words = clean.split()
anchor = " ".join(words[:5]) if len(words) >= 5 else clean anchor = " ".join(words[:5]) if len(words) >= 5 else clean
# Versuch 1: angegebene Seite, Volltext
rects = src[target_page_idx].search_for(clean)
# Versuch 2: angegebene Seite, 5-Wort-Anker
if not rects:
rects = src[target_page_idx].search_for(anchor) rects = src[target_page_idx].search_for(anchor)
# Versuch 2: wenn Seite leer, alle Seiten durchsuchen # Versuch 3: alle Seiten durchsuchen
if not rects: if not rects:
for i in range(len(src)): for i in range(len(src)):
rects = src[i].search_for(anchor if len(words) >= 5 else clean) rects = src[i].search_for(anchor)
if rects: if rects:
target_page_idx = i target_page_idx = i
break break
# Single-page Sub-PDF erzeugen # Volles PDF mit Highlight-Annotation statt Single-Page-Extract.
new = fitz.open() # Der Browser öffnet das vollständige Wahlprogramm; das Frontend
try: # hängt #page=N an die URL, sodass direkt zur Fundstelle gescrollt
new.insert_pdf(src, from_page=target_page_idx, to_page=target_page_idx) # wird. Kontext des Programms bleibt erhalten.
page = new[0]
if needle and rects: if needle and rects:
# Rects beziehen sich auf src-Page-Koordinaten — nach insert_pdf page = src[target_page_idx]
# sind die Koordinaten identisch (gleiche MediaBox).
for rect in rects: for rect in rects:
annot = page.add_highlight_annot(rect) annot = page.add_highlight_annot(rect)
if annot is not None: if annot is not None:
annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb
annot.update() annot.update()
return new.tobytes() return src.tobytes(), target_page_idx + 1
finally:
new.close()
finally: finally:
src.close() src.close()

View File

@ -640,7 +640,7 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str
if seite < 1 or seite > 2000: if seite < 1 or seite > 2000:
raise HTTPException(status_code=400, detail="Ungültige Seitennummer") raise HTTPException(status_code=400, detail="Ungültige Seitennummer")
pdf_bytes = render_highlighted_page(pid, seite, q) pdf_bytes, found_page = render_highlighted_page(pid, seite, q)
if pdf_bytes is None: if pdf_bytes is None:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
@ -655,6 +655,10 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str
headers={ headers={
"Content-Disposition": f'inline; filename="{safe_name}"', "Content-Disposition": f'inline; filename="{safe_name}"',
"Cache-Control": "public, max-age=86400", "Cache-Control": "public, max-age=86400",
# found_page als Header mitgeben, damit das Frontend den
# #page=N Fragment korrekt setzen kann (bei page-redirect
# nach Fallback-Suche auf anderer Seite als angefordert).
"X-Found-Page": str(found_page),
}, },
) )

View File

@ -1504,15 +1504,22 @@
// Post-#47 (die schon /api/wahlprogramm-cite enthalten). // Post-#47 (die schon /api/wahlprogramm-cite enthalten).
function makeCiteUrl(z) { function makeCiteUrl(z) {
if (!z || !z.url) return '#'; if (!z || !z.url) return '#';
// Schon eine Cite-URL? Durchreichen. // Schon eine Cite-URL? #page=N anhängen falls nicht vorhanden.
if (z.url.includes('/api/wahlprogramm-cite')) return z.url; if (z.url.includes('/api/wahlprogramm-cite')) {
const m = z.url.match(/seite=(\d+)/);
const page = m ? m[1] : '';
return page ? z.url + '#page=' + page : z.url;
}
// Statische URL umschreiben: /static/referenzen/X.pdf#page=N // Statische URL umschreiben: /static/referenzen/X.pdf#page=N
// → /api/wahlprogramm-cite?pdf=X.pdf&seite=N&q=<text>#page=N
// Das volle PDF mit Highlight-Annotation wird ausgeliefert,
// #page=N lässt den Browser direkt zur Fundstelle scrollen.
const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/); const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/);
if (m && z.text) { if (m && z.text) {
const pdf = m[1]; const pdf = m[1];
const page = m[2]; const page = m[2];
const q = encodeURIComponent((z.text || '').substring(0, 200)); const q = encodeURIComponent((z.text || '').substring(0, 200));
return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}`; return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}#page=${page}`;
} }
return z.url; return z.url;
} }

View File

@ -441,44 +441,33 @@ class TestRenderHighlightedPage:
return pid return pid
def test_unknown_pid_returns_none(self): def test_unknown_pid_returns_none(self):
assert render_highlighted_page("fake-xx-9999", 1, "x") is None pdf_bytes, page = render_highlighted_page("fake-xx-9999", 1, "x")
assert pdf_bytes is None
def test_invalid_seite_returns_none(self, sample_pid): def test_invalid_seite_returns_none(self, sample_pid):
assert render_highlighted_page(sample_pid, 99999, "x") is None pdf_bytes, _ = render_highlighted_page(sample_pid, 99999, "x")
assert render_highlighted_page(sample_pid, 0, "x") is None assert pdf_bytes is None
pdf_bytes2, _ = render_highlighted_page(sample_pid, 0, "x")
assert pdf_bytes2 is None
def test_renders_single_page_pdf(self, sample_pid): def test_renders_full_pdf_with_highlight(self, sample_pid):
out = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit") pdf_bytes, found_page = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit")
assert out is not None assert pdf_bytes is not None
assert isinstance(out, bytes) assert isinstance(pdf_bytes, bytes)
# PDF magic header assert pdf_bytes[:5] == b"%PDF-"
assert out[:5] == b"%PDF-" assert found_page >= 1
# PyMuPDF behält bei insert_pdf gemeinsame Resources (Fonts, Images)
# mit, deshalb ist ein 1-Seiten-Sub-PDF nicht zwangsläufig winzig.
# Wir prüfen nur dass es überhaupt deutlich kleiner als das Original
# ist (< 50% der Programm-Größe).
from pathlib import Path
info = PROGRAMME[sample_pid]
original_size = (
Path(__file__).parent.parent / "app" / "static" / "referenzen" / info["pdf"]
).stat().st_size
assert len(out) < original_size, (
f"sub-PDF {len(out)} not smaller than original {original_size}"
)
def test_returns_pdf_even_when_query_empty(self, sample_pid): def test_returns_pdf_even_when_query_empty(self, sample_pid):
# Empty query → render the page without any annotations pdf_bytes, found_page = render_highlighted_page(sample_pid, 1, "")
out = render_highlighted_page(sample_pid, 1, "") assert pdf_bytes is not None
assert out is not None assert pdf_bytes[:5] == b"%PDF-"
assert out[:5] == b"%PDF-"
def test_returns_pdf_even_when_query_not_found(self, sample_pid): def test_returns_pdf_even_when_query_not_found(self, sample_pid):
# No match → still render the page (no highlights) pdf_bytes, found_page = render_highlighted_page(
out = render_highlighted_page(
sample_pid, 1, "this exact phrase definitely does not exist anywhere", sample_pid, 1, "this exact phrase definitely does not exist anywhere",
) )
assert out is not None assert pdf_bytes is not None
assert out[:5] == b"%PDF-" assert pdf_bytes[:5] == b"%PDF-"
def test_format_quotes_truncates_long_chunks_at_500_chars(): def test_format_quotes_truncates_long_chunks_at_500_chars():