#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:
parent
5a035be20b
commit
6f35efe4d7
@ -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
|
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)
|
rects = src[target_page_idx].search_for(clean)
|
||||||
|
# Versuch 2: angegebene Seite, 5-Wort-Anker
|
||||||
if not rects:
|
if not rects:
|
||||||
words = clean.split()
|
|
||||||
anchor = " ".join(words[:5]) if len(words) >= 5 else clean
|
|
||||||
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:
|
||||||
|
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:
|
return src.tobytes(), target_page_idx + 1
|
||||||
# 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()
|
|
||||||
finally:
|
finally:
|
||||||
src.close()
|
src.close()
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user