Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ documentation, and CI work are left out.

## [Unreleased]

### Added

- The text panels of a result can be searched — `Ctrl/Cmd+F` opens the search in
the panel you last worked in, and a selected find offers **Im Ergebnis
suchen** to check whether its text still occurs in the output. See
[Reviewing a result](docs/user-guide/review.md).
- **Schwarze Balken statt Platzhaltern** in the export menu draws black bars
over the placeholders of a scanned document's rebuilt PDF, so it looks like a
native redacted PDF — see [Exporting](docs/user-guide/export.md#redacted-pdf).

## [0.3.0] — 2026-08-20

### Added
Expand Down
11 changes: 10 additions & 1 deletion backend/src/routers/v1/endpoints/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ async def export_pdf(
raw_language = form.get("output_language")
output_language = raw_language.strip() if isinstance(raw_language, str) else None
force_ocr = _parse_bool(form.get("force_ocr"))
# Scanned documents only: black bars over the placeholders of the rebuilt
# page, so a reconstruction looks like the native export instead of reading
# its replacements out in words.
redaction_bars = _parse_bool(form.get("redaction_bars"))
raw_profile = form.get("ocr_profile")
ocr_profile = (
raw_profile.strip() if isinstance(raw_profile, str) and raw_profile.strip() else None
Expand Down Expand Up @@ -160,7 +164,12 @@ async def export_pdf(
pdf_bytes = redact_native_pdf(data, result.entities, settings, areas=redact_areas)
elif source_type == "pdf-ocr":
pdf_bytes = rebuild_scanned_pdf(
result.source_text, layout, result.entities, page_count, areas=redact_areas
result.source_text,
layout,
result.entities,
page_count,
areas=redact_areas,
bars=redaction_bars,
)
else:
raise HTTPException(
Expand Down
58 changes: 58 additions & 0 deletions backend/src/utils/pdf_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ def rebuild_scanned_pdf(
entities: list[AppliedEntity],
page_count: int,
areas: list[RedactArea] | None = None,
bars: bool = False,
) -> bytes:
from reportlab.lib.colors import grey
from reportlab.pdfgen import canvas
Expand All @@ -632,6 +633,7 @@ def rebuild_scanned_pdf(
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer, pagesize=_PAGE_A4)
pages = max(page_count, max(line.page_number for line in layout))
bar_tokens = _bar_tokens(entities) if bars else []

for page_number in range(1, pages + 1):
pdf.setFillColor(grey)
Expand All @@ -653,6 +655,8 @@ def rebuild_scanned_pdf(
for index, wrapped_line in enumerate(wrapped):
baseline = height - top - (index + 1) * font_size * _LINE_SPACING + font_size * 0.2
pdf.drawString(x, baseline, wrapped_line)
if bar_tokens:
_draw_bars(pdf, wrapped_line, bar_tokens, x, baseline, font_size)
pdf.showPage()
pdf.save()
output = buffer.getvalue()
Expand All @@ -669,6 +673,60 @@ def rebuild_scanned_pdf(
_LINE_SPACING = 1.25
_FONT_CANDIDATES = (11.0, 10.0, 9.0, 8.0, 7.0, 6.0)

# How far a bar reaches below and above the baseline, as a share of the font
# size, plus the horizontal padding that makes it read as a bar rather than a
# tight box around the token. Sized to the text line rather than to the glyphs,
# which is what the native export's boxes cover — the two should look alike.
_BAR_DESCENT = 0.25
_BAR_ASCENT = 0.95
_BAR_PADDING = 0.12


def _bar_tokens(entities: list[AppliedEntity]) -> list[str]:
"""The replacement strings a black bar is drawn over, longest first.

Mirrors the native export (`_redact_native_true`): everything it blacks out
gets a bar here too, while a GENERALIZED replacement — a date reduced to its
year — stays readable there and so stays readable here. PRESERVED entities
keep their original text and are not replacements at all.
"""
tokens = {
_latin1_safe(entity.replacement)
for entity in entities
if entity.replacement
and entity.status not in (SpanStatus.PRESERVED, SpanStatus.GENERALIZED)
}
return sorted(tokens, key=len, reverse=True)


def _draw_bars(pdf, text: str, tokens: list[str], x: float, baseline: float, size: float) -> None:
"""Black out every placeholder occurrence in one drawn line.

The bar is painted OVER the placeholder, which stays in the text layer: the
token is never sensitive (that is the point of it), and keeping it means a
reader extracting the text still gets `[PERSON_1]` — including which person
it was. Locating the tokens by searching the drawn string is exact, because
this is the same string, font and size `drawString` just rendered, and
placeholders contain no spaces, so wrapping can never split one.
"""
from reportlab.pdfbase.pdfmetrics import stringWidth

for token in tokens:
position = text.find(token)
while position != -1:
padding = size * _BAR_PADDING
start = x + stringWidth(text[:position], "Helvetica", size) - padding
token_width = stringWidth(token, "Helvetica", size) + 2 * padding
pdf.rect(
start,
baseline - size * _BAR_DESCENT,
token_width,
size * (_BAR_DESCENT + _BAR_ASCENT),
fill=1,
stroke=0,
)
position = text.find(token, position + len(token))


def _fit_text(text: str, box_width: float, box_height: float) -> tuple[float, list[str]]:
"""Choose a font size and wrap the text so it fits the OCR box.
Expand Down
85 changes: 85 additions & 0 deletions backend/tests/integration/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,88 @@ def test_health_endpoints(client):
assert client.get("/health/live").json() == {"status": "ok"}
ready = client.get("/health/ready").json()
assert ready["status"] == "ready"


def _fake_scan_extraction(text: str, lines: list[str]):
"""A pdf-ocr extraction with one layout box per line, stacked down page 1.

Lets the export route exercise the scanned-document reconstruction without
an OCR service — the same substitution the reconstruction itself sees."""
from backend.src.utils.extraction import ExtractedDocument, LayoutLine, PageRange

layout = []
position = 0
for index, line in enumerate(lines):
start = text.index(line, position)
layout.append(
LayoutLine(
page_number=1,
x1=100,
y1=100 + index * 40,
x2=900,
y2=130 + index * 40,
start=start,
end=start + len(line),
)
)
position = start + len(line)
return ExtractedDocument(
text=text,
source_type="pdf-ocr",
pages=[PageRange(page_number=1, start=0, end=len(text))],
layout=layout,
)


def test_export_scanned_pdf_draws_bars_only_when_asked(client, monkeypatch):
"""The `redaction_bars` flag reaches the reconstruction."""
import io

import pymupdf

from backend.src.routers.v1.endpoints import export as export_endpoint
from backend.tests.pdf_builder import make_pdf

lines = [
"Patient: Max Mustermann, geb. 01.02.1980",
"Der Patient wurde stationaer aufgenommen und komplikationslos behandelt.",
]
text = "\n".join(lines)
pdf = make_pdf(lines)

async def fake_extract(*args, **kwargs):
return _fake_scan_extraction(text, lines)

monkeypatch.setattr(export_endpoint, "extract_document", fake_extract)

def black_rects(content: bytes) -> int:
document = pymupdf.open(stream=content, filetype="pdf")
try:
return sum(
1
for drawing in document[0].get_drawings()
if drawing.get("fill") == (0.0, 0.0, 0.0)
)
finally:
document.close()

def export(**data):
response = client.post(
"/api/v1/export/pdf",
files={"file": ("scan.pdf", pdf, "application/pdf")},
data={"overrides": "[]", **data},
)
assert response.status_code == 200
return response.content

plain = export()
barred = export(redaction_bars="true")
assert black_rects(plain) == 0
assert black_rects(barred) > 0

# The bar is drawn over the placeholder, which stays in the text layer.
from pypdf import PdfReader

extracted = "".join(p.extract_text() or "" for p in PdfReader(io.BytesIO(barred)).pages)
assert "Max Mustermann" not in extracted
assert "[PERSON_1]" in extracted
80 changes: 80 additions & 0 deletions backend/tests/unit/test_pdf_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,86 @@ def test_rebuild_places_anonymized_text_and_verifies():
assert "rekonstruiertes" in extracted # the reconstruction notice


def _black_bar_count(output: bytes) -> int:
"""Filled black rectangles on page 1 of a rebuilt PDF."""
import pymupdf

document = pymupdf.open(stream=output, filetype="pdf")
try:
return sum(
1
for drawing in document[0].get_drawings()
if drawing.get("fill") == (0.0, 0.0, 0.0)
and any(item[0] == "re" for item in drawing.get("items", []))
)
finally:
document.close()


def _dark_fraction(output: bytes, needle: str) -> float:
"""Share of dark pixels where `needle` sits on page 1 of a rebuilt PDF."""
import pymupdf

document = pymupdf.open(stream=output, filetype="pdf")
try:
page = document[0]
image = page.get_pixmap(clip=page.search_for(needle)[0], colorspace=pymupdf.csGRAY, dpi=150)
pixels = list(image.samples)
finally:
document.close()
return sum(1 for value in pixels if value < 60) / len(pixels)


def test_rebuild_draws_bars_over_placeholders_but_keeps_them_readable():
from pypdf import PdfReader

lines = ["Patientin: Erika Musterfrau, geb. 03.11.1957", "Aufnahme durch Erika Musterfrau."]
source = "\n".join(lines)
layout = make_layout(source, lines)
entities = entities_for(source, [("Erika Musterfrau", "[PERSON_1]")])
# Both occurrences are replaced (offsets differ, the tag does not).
entities.append(applied("Erika Musterfrau", source.rindex("Erika Musterfrau")))

plain = rebuild_scanned_pdf(source, layout, entities, page_count=1)
barred = rebuild_scanned_pdf(source, layout, entities, page_count=1, bars=True)

# One bar per placeholder occurrence, and none without the option.
assert _black_bar_count(plain) == 0
assert _black_bar_count(barred) == 2

# The bar is painted OVER the token: the text layer is unchanged, so the
# consistent tag still says WHICH person was redacted — and the export
# verification still sees an output free of the original name.
extracted = "\n".join(p.extract_text() or "" for p in PdfReader(io.BytesIO(barred)).pages)
assert "Erika Musterfrau" not in extracted
assert extracted.count("[PERSON_1]") == 2

# …and the bar really covers it: where the token renders as thin glyph
# strokes on white without the option, it renders as a solid block with it.
# The remainder is the line box's leading above the glyphs, not exposed text.
assert _dark_fraction(plain, "[PERSON_1]") < 0.35
assert _dark_fraction(barred, "[PERSON_1]") > 0.8


def test_rebuild_bars_leave_generalized_replacements_readable():
lines = ["Aufnahme am 03.11.1957 in der Klinik."]
source = "\n".join(lines)
layout = make_layout(source, lines)
generalized = applied(
"03.11.1957",
source.index("03.11.1957"),
etype=EntityType.OTHER_DATE,
replacement="1957",
)
generalized.status = SpanStatus.GENERALIZED
generalized.transformation = TransformationType.GENERALIZE

output = rebuild_scanned_pdf(source, layout, [generalized], page_count=1, bars=True)

# The native export keeps a generalized date visible; so does this one.
assert _black_bar_count(output) == 0


def test_rebuild_wraps_paragraph_boxes_and_maps_bullets():
from pypdf import PdfReader

Expand Down
Binary file modified docs/assets/screenshots/batch-documents.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/input-file-selected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-entities-selected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-entity-selected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-export-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-pdf-area-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-pdf.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-review-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/result-review-required.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/screenshots/settings-expert-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions docs/user-guide/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ Both paths **fail closed**. If the redaction cannot be verified afterwards, the
export is refused with an error rather than handing you a file that looks
redacted but is not.

### Black bars in a rebuilt document

The two paths look different by nature: a native PDF blacks its redactions out,
while a rebuilt one prints the replacements as words — `[PERSON_1]`,
`[ADRESSE]`. **Schwarze Balken statt Platzhaltern** in the export menu (shown
for scanned sources only) draws a bar over each placeholder instead, so the two
kinds of document look alike.

The bar is painted over the placeholder, which stays in the text layer: copying
the text out still yields `[PERSON_1]`, including which person the tag refers
to. Nothing sensitive sits under a bar — the original text was never written
into the rebuilt page in the first place.

Dates reduced to their year stay readable either way, exactly as in a native
export. The setting is remembered, applies to the preview and to every document
of the batch, and changes nothing about what was redacted — only how it is
drawn.

Any [areas you blacked out](review.md#blacking-out-areas-of-a-pdf) are applied
on top, in both paths.

Expand Down
33 changes: 33 additions & 0 deletions docs/user-guide/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,38 @@ Chips above the panels toggle up to three views side by side:
In [expert mode](advanced-settings.md#expert-mode) the redacted PDF and the
anonymized text become separate chips, so you can show both at once.

## Searching inside a panel

Each text panel has a magnifier in its header, and `Ctrl+F` (`Cmd+F` on a Mac)
opens the search **in the panel you last worked in** — so the final check is one
shortcut away from wherever you are reading. `Enter` and `Shift+Enter` step
through the hits, `Esc` closes the bar. Every hit is marked in the text and the
counter shows which one you are on.

The search ignores case and diacritics, so `muller` finds *Müller*. Each panel
searches on its own; opening the search in one leaves the others untouched.

The point of it is the last check before you export: search a name in the
**Ergebnis** panel and read the counter. **Kein Treffer** means that string does
not occur in the anonymized text. A selected find offers this in one click —
**Im Ergebnis suchen** puts its text straight into the anonymized text's search,
switching the result panel to that view if the redacted PDF was showing.

!!! warning "What a hit count is, and is not"

It is a statement about **one string in one panel's text**, nothing more. A
name spelled differently, split across a line break, or mangled by OCR does
not turn up — "Kein Treffer" is not a proof of anonymization
([Warnings & validation](validation.md)).

!!! note "PDF views have no search of their own"

A PDF is rendered by the browser's own viewer, which this app can neither
search nor highlight in — so the **Original** panel of a PDF source and the
**Geschwärztes PDF** panel carry no magnifier, and the shortcut skips them
for the nearest text panel. To search the pages themselves, click into the
PDF and use the viewer's own find function.

## Reading the highlights

<figure markdown>
Expand Down Expand Up @@ -93,6 +125,7 @@ have to look away from the passage you are judging:
|---|---|
| **Beibehalten** | Keep this passage visible. |
| **Schwärzen** | Redact a passage that was preserved. |
| **Im Ergebnis suchen** | Look this text up in the anonymized text — see [Searching inside a panel](#searching-inside-a-panel). |
| Type dropdown | Correct a wrong type — the new type's transformation applies immediately. |
| **Zurücksetzen** | Undo your change for this entity. |

Expand Down
Loading