Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ documentation, and CI work are left out.

## [Unreleased]

### Changed

- **Bereiche schwärzen** now draws on the redacted pages, so the automatic text
redactions are visible while marking areas; scanned documents draw on the
reconstruction, which is the page their areas are applied to — see
[Reviewing a result](docs/user-guide/review.md).

### Fixed

- An area drawn on a scanned document's export removed only the pixels: the
reconstructed text under the black box stayed selectable. Areas are now true
redactions on both PDF paths, verified before the export is handed out.

## [0.2.0] — 2026-08-11

### Added
Expand Down
75 changes: 63 additions & 12 deletions backend/src/utils/pdf_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,63 @@ def preserved_texts_at_risk(entities: list[AppliedEntity]) -> list[str]:
return sorted(at_risk)


def _apply_areas(data: bytes, areas: list[RedactArea] | None) -> bytes:
"""Apply the user-drawn areas to a PDF as TRUE redactions: the text and the
image pixels under each rectangle are removed, not merely covered.

Painting a filled rectangle over the page (what a drawing API does) leaves
everything underneath selectable and extractable — the box would hide the
content from the eye only. Verified below, and never silently skipped."""
if not areas:
return data

import pymupdf

document = pymupdf.open(stream=data, filetype="pdf")
try:
for page in document:
page_width, page_height = page.rect.width, page.rect.height
rects = [
pymupdf.Rect(x0 * page_width, y0 * page_height, x1 * page_width, y1 * page_height)
for x0, y0, x1, y1 in _page_areas(areas, page.number + 1)
]
if not rects:
continue
for rect in rects:
page.add_redact_annot(rect, fill=(0, 0, 0))
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_PIXELS)
output = document.tobytes(garbage=4, deflate=True)
finally:
document.close()

_verify_areas(output, areas)
return output


def _verify_areas(data: bytes, areas: list[RedactArea] | None) -> None:
"""Mandatory: no text may survive inside a user-drawn area."""
if not areas:
return

import pymupdf

document = pymupdf.open(stream=data, filetype="pdf")
try:
for page in document:
page_width, page_height = page.rect.width, page.rect.height
for x0, y0, x1, y1 in _page_areas(areas, page.number + 1):
rect = pymupdf.Rect(
x0 * page_width, y0 * page_height, x1 * page_width, y1 * page_height
)
if any(word[4].strip() for word in page.get_text("words", clip=rect)):
raise ExportError(
"Verification failed: text is still present under a blacked-out "
"area; the redacted PDF was NOT generated."
)
finally:
document.close()


def _compact(text: str) -> str:
"""Strip ALL whitespace. The detector reads the PDF via one extractor
(docling-serve or pypdf) while the export searches it via another (pymupdf /
Expand Down Expand Up @@ -213,6 +270,7 @@ def _redact_native_true(
document.close()

_verify_native(output, needles)
_verify_areas(output, areas)
return output


Expand Down Expand Up @@ -595,22 +653,15 @@ 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)
# User-drawn areas: black box at the (approximate) original position —
# the rebuild is layout-faithful, so the normalized coordinates map
# onto the reconstructed page. reportlab's origin is bottom-left.
for x0, y0, x1, y1 in _page_areas(areas, page_number):
pdf.rect(
x0 * width,
height - y1 * height,
(x1 - x0) * width,
(y1 - y0) * height,
stroke=0,
fill=1,
)
pdf.showPage()
pdf.save()
output = buffer.getvalue()

# User-drawn areas are applied to the REBUILT page (the geometry the review
# UI draws on), as true redactions — a filled rectangle painted over the
# text would leave that text selectable underneath.
output = _apply_areas(output, areas)

_verify_rebuilt(output, entities)
return output

Expand Down
29 changes: 29 additions & 0 deletions backend/tests/unit/test_pdf_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,35 @@ def test_rebuild_scanned_applies_area():
assert _dark_fraction_in_area(output, area) > 0.8


def test_rebuild_scanned_area_removes_the_text_underneath():
"""A black box over reconstructed text must REMOVE it, not cover it — the
reviewer draws these areas over exactly the things no detector caught."""
from pypdf import PdfReader

source = "Unterschrift Dr. Beispiel\nDiagnose unauffaellig"
signature, diagnosis = source.split("\n")
layout = [
LayoutLine(page_number=1, x1=100, y1=100, x2=800, y2=140, start=0, end=len(signature)),
LayoutLine(
page_number=1,
x1=100,
y1=200,
x2=800,
y2=240,
start=len(signature) + 1,
end=len(source),
),
]
# Covers the first line only, with room for the font the rebuild picks.
area = RedactArea(page=1, x0=50, y0=80, x1=900, y1=180)

output = rebuild_scanned_pdf(source, layout, [], page_count=1, areas=[area])

text = "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(output)).pages)
assert "Unterschrift" not in text
assert "unauffaellig" in text


def test_redact_area_rejects_empty_rect():
with pytest.raises(ValueError):
RedactArea(page=1, x0=500, y0=100, x1=500, y1=200)
Expand Down
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.
22 changes: 18 additions & 4 deletions docs/user-guide/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,27 @@ that should be covered:
![The area redaction editor](../assets/screenshots/result-pdf-area-editor.png)
</figure>

The editor shows the **original** pages, not the redacted preview — the text
redaction is applied to the export on top of the areas you draw here.

- Click an existing rectangle to remove it.
The editor draws on the pages of the **redacted preview**, so everything the
detectors already caught is blacked out while you add what they missed.
**Originalseiten anzeigen** switches the background to the unredacted pages —
useful when a black bar sits on top of what you are trying to cover —
and **Schwärzungen anzeigen** switches back.

- Click an existing rectangle to remove it. Against the redacted background,
your own areas are the ones outlined in white.
- The background follows every change: each drawn area regenerates the redacted
preview, and the pages catch up a moment later.
- **Scanned documents draw on the reconstruction.** Their export is not the
scan: the original pixels are discarded and the anonymized text is re-typeset
at the OCR positions, so the editor shows that reconstruction — the page the
areas are really applied to — and there is nothing to switch. The scan itself
stays available in the **Original** panel.
- **Fertig** (or the **Vorschau** view) takes you back to the redacted preview;
every change applies immediately, so nothing is lost either way.
- **Alle Bilder schwärzen** covers every embedded image the backend found, in
one click.
- Areas apply to the redacted-PDF preview and export only; text exports are
unaffected.
- An area is a **true redaction**, not a black rectangle drawn on top: the text
and image pixels under it are removed from the exported PDF, and an export
that cannot prove that is refused.
11 changes: 6 additions & 5 deletions e2e/screenshots/workflow.screens.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,12 @@ test('captures the documentation screenshots', async ({ page }) => {
await shoot(page, 'result-pdf')
})

// The editor draws on the *original* pages, so an empty one looks like an
// un-anonymized document. Capture it doing its job instead: the one-click
// image suggestion applied, plus one hand-drawn area over the letterhead.
// Capture the editor doing its job: the automatic redactions it opens on,
// the one-click image suggestion applied, plus one hand-drawn area over the
// letterhead.
await capture('result-pdf-area-editor', async () => {
await page.getByRole('button', { name: /Bereiche schwärzen/ }).click()
const firstPage = page.getByRole('img', { name: 'Seite 1' })
const firstPage = page.getByRole('img', { name: 'Seite 1 (geschwärzt)' })
await expect(firstPage).toBeVisible({ timeout: 60_000 })

await page.getByRole('button', { name: /Alle Bilder schwärzen/ }).click()
Expand All @@ -211,7 +211,8 @@ test('captures the documentation screenshots', async ({ page }) => {
await page.mouse.up()
}

// Long enough for the "images blacked out" toast to auto-dismiss.
// Long enough for the "images blacked out" toast to auto-dismiss, and for
// the background to catch up with the areas just drawn.
await page.waitForTimeout(4500)
await shoot(page, 'result-pdf-area-editor')
// The header is a two-view segmented control, so leaving means picking the
Expand Down
24 changes: 24 additions & 0 deletions e2e/tests/workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,30 @@ test.describe('anonymization workflow', () => {
expect((await download).suggestedFilename()).toContain('.pdf')
})

test('shows the automatic redactions in the area editor', async ({ page }) => {
await page.goto('/')

await page.locator('input[type="file"]').setInputFiles(path.join(FIXTURES, '9874562_text.pdf'))
await page.getByRole('button', { name: 'Anonymisieren' }).click()
await waitForResult(page)

await page.getByRole('button', { name: /Bereiche schwärzen/ }).click()
// The editor opens on the pages of the redacted preview: what the
// detectors already caught is blacked out while the reviewer adds to it.
await expect(page.getByRole('img', { name: 'Seite 1 (geschwärzt)' })).toBeVisible({
timeout: 60_000,
})

// The originals are one click away, for whatever the box has to cover.
await page.getByRole('button', { name: 'Originalseiten anzeigen' }).click()
await expect(page.getByRole('img', { name: 'Seite 1', exact: true })).toBeVisible()

await page.getByRole('button', { name: 'Schwärzungen anzeigen' }).click()
await expect(page.getByRole('img', { name: 'Seite 1 (geschwärzt)' })).toBeVisible({
timeout: 60_000,
})
})

test('says when a kept passage stays black in the redacted PDF anyway', async ({ page }) => {
await page.goto('/')

Expand Down
149 changes: 149 additions & 0 deletions frontend/components/anonymizer/PdfAreaEditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import PdfAreaEditor from '@/components/anonymizer/PdfAreaEditor.vue'
import { i18n } from '@/i18n'
import { anonymizeApi } from '@/services/anonymizeApi'
import { useSessionStore } from '@/stores/session'
import type { AnonymizeResponse } from '@/types/anonymizer'

// The editor is mounted against a finished result the spec sets itself; a real
// stream must never race those assignments.
vi.mock('@/services/anonymizeStream', () => ({
anonymizeFileStream: vi.fn(() => new Promise(() => {})),
anonymizeTextStream: vi.fn(() => new Promise(() => {})),
}))

const ORIGINAL_IMAGE = 'data:image/png;base64,original'
const REDACTED_IMAGE = 'data:image/png;base64,redacted'

function pageResponse(image: string, imageBoxes = 0) {
return {
data: {
pages: [
{
page: 1,
width: 595,
height: 842,
image,
image_boxes: Array.from({ length: imageBoxes }, () => ({
x0: 0,
y0: 0,
x1: 100,
y1: 100,
})),
},
],
truncated: false,
},
} as never
}

/**
* A finished single-document batch with a redacted-PDF preview, as after a
* PDF run. `sourceType` decides which drawing surface the editor must pick.
*/
function documentWithPreview(sourceType: 'pdf' | 'pdf-ocr') {
const session = useSessionStore()
session.submitFiles([new File(['%PDF-original'], 'befund.pdf', { type: 'application/pdf' })])
const doc = session.documents[0]!
doc.result = {
request_id: 'req-1',
source_type: sourceType,
entities: [],
} as unknown as AnonymizeResponse
doc.pdfPreviewBlob = new Blob(['%PDF-redacted'], { type: 'application/pdf' })
return session
}

/** Renders originals or the redacted preview depending on what is uploaded. */
function mockRenderer() {
return vi.spyOn(anonymizeApi, 'renderPdfPages').mockImplementation((file: File) => {
const redacted = file.name === 'redacted.pdf'
return Promise.resolve(
pageResponse(redacted ? REDACTED_IMAGE : ORIGINAL_IMAGE, redacted ? 0 : 2),
)
})
}

function mountEditor() {
return mount(PdfAreaEditor, { global: { plugins: [i18n] } })
}

describe('area editor drawing surface', () => {
beforeEach(() => {
setActivePinia(createPinia())
i18n.global.locale.value = 'de'
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

it('opens a native PDF on the redacted pages, and switches to the originals', async () => {
const session = documentWithPreview('pdf')
const render = mockRenderer()

const wrapper = mountEditor()
await vi.advanceTimersByTimeAsync(500)
await flushPromises()

// The redactions are what the reviewer is adding to, so they are the
// default background — no click needed to see them.
expect(render.mock.calls.map((call) => call[0]!.name).sort()).toEqual([
'befund.pdf',
'redacted.pdf',
])
expect(wrapper.get('img').attributes('src')).toBe(REDACTED_IMAGE)
expect(wrapper.get('img').attributes('alt')).toBe('Seite 1 (geschwärzt)')

// The originals stay one click away — that is what covering a signature
// or a letterhead needs.
await wrapper.get('button[aria-pressed]').trigger('click')
await flushPromises()

expect(wrapper.get('img').attributes('src')).toBe(ORIGINAL_IMAGE)
expect(wrapper.get('img').attributes('alt')).toBe('Seite 1')
session.reset()
})

it('draws on the reconstruction for a scanned PDF, never on the scan', async () => {
const session = documentWithPreview('pdf-ocr')
const render = mockRenderer()

const wrapper = mountEditor()
await vi.advanceTimersByTimeAsync(500)
await flushPromises()

// The scan is a different page geometry than the reconstruction the areas
// are applied to, so it is never rendered — let alone drawn on.
expect(render.mock.calls.map((call) => call[0]!.name)).toEqual(['redacted.pdf'])
expect(wrapper.get('img').attributes('src')).toBe(REDACTED_IMAGE)
expect(wrapper.get('img').attributes('alt')).toBe('Seite 1 (rekonstruiert, geschwärzt)')

// Nothing to switch and nothing to suggest: no toggle, and no image boxes
// (which for a scan would be one whole-page box per page).
expect(wrapper.find('button[aria-pressed]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Alle Bilder schwärzen')
session.reset()
})

it('refuses to fall back to the scan when the reconstruction is missing', async () => {
const session = documentWithPreview('pdf-ocr')
const doc = session.documents[0]!
doc.pdfPreviewBlob = null
doc.pdfPreviewError = 'Der geschwärzte PDF-Export ist fehlgeschlagen.'
const render = mockRenderer()

const wrapper = mountEditor()
await vi.advanceTimersByTimeAsync(500)
await flushPromises()

expect(render).not.toHaveBeenCalled()
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toContain('Der geschwärzte PDF-Export ist fehlgeschlagen.')
session.reset()
})
})
Loading
Loading