diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8f951..6696b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/src/utils/pdf_export.py b/backend/src/utils/pdf_export.py index e06f972..826dd4c 100644 --- a/backend/src/utils/pdf_export.py +++ b/backend/src/utils/pdf_export.py @@ -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 / @@ -213,6 +270,7 @@ def _redact_native_true( document.close() _verify_native(output, needles) + _verify_areas(output, areas) return output @@ -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 diff --git a/backend/tests/unit/test_pdf_export.py b/backend/tests/unit/test_pdf_export.py index 6e57759..0c9c36e 100644 --- a/backend/tests/unit/test_pdf_export.py +++ b/backend/tests/unit/test_pdf_export.py @@ -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) diff --git a/docs/assets/screenshots/result-pdf-area-editor.png b/docs/assets/screenshots/result-pdf-area-editor.png index 6e40014..7f9897c 100644 Binary files a/docs/assets/screenshots/result-pdf-area-editor.png and b/docs/assets/screenshots/result-pdf-area-editor.png differ diff --git a/docs/user-guide/review.md b/docs/user-guide/review.md index 2c21bbd..3d45172 100644 --- a/docs/user-guide/review.md +++ b/docs/user-guide/review.md @@ -183,13 +183,27 @@ that should be covered:  -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. diff --git a/e2e/screenshots/workflow.screens.spec.ts b/e2e/screenshots/workflow.screens.spec.ts index 89c47ff..955daaa 100644 --- a/e2e/screenshots/workflow.screens.spec.ts +++ b/e2e/screenshots/workflow.screens.spec.ts @@ -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() @@ -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 diff --git a/e2e/tests/workflow.spec.ts b/e2e/tests/workflow.spec.ts index e76a5ea..83967d1 100644 --- a/e2e/tests/workflow.spec.ts +++ b/e2e/tests/workflow.spec.ts @@ -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('/') diff --git a/frontend/components/anonymizer/PdfAreaEditor.test.ts b/frontend/components/anonymizer/PdfAreaEditor.test.ts new file mode 100644 index 0000000..bbc2750 --- /dev/null +++ b/frontend/components/anonymizer/PdfAreaEditor.test.ts @@ -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() + }) +}) diff --git a/frontend/components/anonymizer/PdfAreaEditor.vue b/frontend/components/anonymizer/PdfAreaEditor.vue index 0f198a2..ee727a5 100644 --- a/frontend/components/anonymizer/PdfAreaEditor.vue +++ b/frontend/components/anonymizer/PdfAreaEditor.vue @@ -7,39 +7,69 @@ class="shrink-0 space-y-1.5 border-b border-default bg-surface-muted px-4 py-2 text-xs text-content-muted" >
{{ t('areas.hint') }}
+{{ hint }}
- {{ session.pdfPagesError }} + {{ surfaceError }}
-