diff --git a/app/embeddings.py b/app/embeddings.py index b67b52d..efbdb4a 100644 --- a/app/embeddings.py +++ b/app/embeddings.py @@ -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 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: programm_id: Key into PROGRAMME registry — validated by caller. 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) if not info: - return None + return None, 0 pdf_filename = info.get("pdf") if not pdf_filename: - return None + return None, 0 referenzen = Path(__file__).parent / "static" / "referenzen" pdf_path = referenzen / pdf_filename if not pdf_path.exists(): - return None + return None, 0 needle = (query or "").strip()[:200] src = fitz.open(str(pdf_path)) try: if seite < 1 or seite > len(src): - return None + return None, 0 # Suche den Needle auf der angegebenen Seite. Falls dort nichts # 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 # halluzinierten Seitenzahlen retroaktiv. target_page_idx = seite - 1 + rects = [] if needle: clean = needle.replace("\u00ad", "") - # Versuch 1: angegebene Seite + words = clean.split() + 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: - words = clean.split() - anchor = " ".join(words[:5]) if len(words) >= 5 else clean rects = src[target_page_idx].search_for(anchor) - # Versuch 2: wenn Seite leer, alle Seiten durchsuchen + # Versuch 3: alle Seiten durchsuchen if not rects: 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: target_page_idx = i break - # Single-page Sub-PDF erzeugen - new = fitz.open() - try: - new.insert_pdf(src, from_page=target_page_idx, to_page=target_page_idx) - page = new[0] + # Volles PDF mit Highlight-Annotation statt Single-Page-Extract. + # Der Browser öffnet das vollständige Wahlprogramm; das Frontend + # hängt #page=N an die URL, sodass direkt zur Fundstelle gescrollt + # wird. Kontext des Programms bleibt erhalten. + if needle and rects: + page = src[target_page_idx] + for rect in rects: + annot = page.add_highlight_annot(rect) + if annot is not None: + annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb + annot.update() - if needle and rects: - # Rects beziehen sich auf src-Page-Koordinaten — nach insert_pdf - # sind die Koordinaten identisch (gleiche MediaBox). - for rect in rects: - annot = page.add_highlight_annot(rect) - if annot is not None: - annot.set_colors(stroke=(1.0, 0.93, 0.0)) # gelb - annot.update() - - return new.tobytes() - finally: - new.close() + return src.tobytes(), target_page_idx + 1 finally: src.close() diff --git a/app/main.py b/app/main.py index 0d34b68..3852ced 100644 --- a/app/main.py +++ b/app/main.py @@ -640,7 +640,7 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str if seite < 1 or seite > 2000: 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: raise HTTPException( status_code=404, @@ -655,6 +655,10 @@ async def wahlprogramm_cite(pid: str = "", pdf: str = "", seite: int = 1, q: str headers={ "Content-Disposition": f'inline; filename="{safe_name}"', "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), }, ) diff --git a/app/templates/index.html b/app/templates/index.html index 9606b7b..7ddbea9 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1504,15 +1504,22 @@ // Post-#47 (die schon /api/wahlprogramm-cite enthalten). function makeCiteUrl(z) { if (!z || !z.url) return '#'; - // Schon eine Cite-URL? Durchreichen. - if (z.url.includes('/api/wahlprogramm-cite')) return z.url; + // Schon eine Cite-URL? #page=N anhängen falls nicht vorhanden. + 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 + // → /api/wahlprogramm-cite?pdf=X.pdf&seite=N&q=#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+)/); if (m && z.text) { const pdf = m[1]; const page = m[2]; 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; } diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index c1b3b9c..bb133ad 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -441,44 +441,33 @@ class TestRenderHighlightedPage: return pid 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): - assert render_highlighted_page(sample_pid, 99999, "x") is None - assert render_highlighted_page(sample_pid, 0, "x") is None + pdf_bytes, _ = render_highlighted_page(sample_pid, 99999, "x") + 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): - out = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit") - assert out is not None - assert isinstance(out, bytes) - # PDF magic header - assert out[:5] == b"%PDF-" - # 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_renders_full_pdf_with_highlight(self, sample_pid): + pdf_bytes, found_page = render_highlighted_page(sample_pid, 1, "Soziale Gerechtigkeit") + assert pdf_bytes is not None + assert isinstance(pdf_bytes, bytes) + assert pdf_bytes[:5] == b"%PDF-" + assert found_page >= 1 def test_returns_pdf_even_when_query_empty(self, sample_pid): - # Empty query → render the page without any annotations - out = render_highlighted_page(sample_pid, 1, "") - assert out is not None - assert out[:5] == b"%PDF-" + pdf_bytes, found_page = render_highlighted_page(sample_pid, 1, "") + assert pdf_bytes is not None + assert pdf_bytes[:5] == b"%PDF-" def test_returns_pdf_even_when_query_not_found(self, sample_pid): - # No match → still render the page (no highlights) - out = render_highlighted_page( + pdf_bytes, found_page = render_highlighted_page( sample_pid, 1, "this exact phrase definitely does not exist anywhere", ) - assert out is not None - assert out[:5] == b"%PDF-" + assert pdf_bytes is not None + assert pdf_bytes[:5] == b"%PDF-" def test_format_quotes_truncates_long_chunks_at_500_chars():