diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f25aa..785dac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/src/routers/v1/endpoints/export.py b/backend/src/routers/v1/endpoints/export.py index 35765c9..ffe2652 100644 --- a/backend/src/routers/v1/endpoints/export.py +++ b/backend/src/routers/v1/endpoints/export.py @@ -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 @@ -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( diff --git a/backend/src/utils/pdf_export.py b/backend/src/utils/pdf_export.py index 826dd4c..661005f 100644 --- a/backend/src/utils/pdf_export.py +++ b/backend/src/utils/pdf_export.py @@ -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 @@ -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) @@ -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() @@ -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. diff --git a/backend/tests/integration/test_api.py b/backend/tests/integration/test_api.py index 193fc24..8fd582d 100644 --- a/backend/tests/integration/test_api.py +++ b/backend/tests/integration/test_api.py @@ -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 diff --git a/backend/tests/unit/test_pdf_export.py b/backend/tests/unit/test_pdf_export.py index 0c9c36e..20ad82e 100644 --- a/backend/tests/unit/test_pdf_export.py +++ b/backend/tests/unit/test_pdf_export.py @@ -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 diff --git a/docs/assets/screenshots/batch-documents.png b/docs/assets/screenshots/batch-documents.png index e314e74..44105b4 100644 Binary files a/docs/assets/screenshots/batch-documents.png and b/docs/assets/screenshots/batch-documents.png differ diff --git a/docs/assets/screenshots/input-file-selected.png b/docs/assets/screenshots/input-file-selected.png index 5acc34a..f8e0a41 100644 Binary files a/docs/assets/screenshots/input-file-selected.png and b/docs/assets/screenshots/input-file-selected.png differ diff --git a/docs/assets/screenshots/result-entities-selected.png b/docs/assets/screenshots/result-entities-selected.png index 6b91382..431a75d 100644 Binary files a/docs/assets/screenshots/result-entities-selected.png and b/docs/assets/screenshots/result-entities-selected.png differ diff --git a/docs/assets/screenshots/result-entity-selected.png b/docs/assets/screenshots/result-entity-selected.png index ad2d194..2d211c7 100644 Binary files a/docs/assets/screenshots/result-entity-selected.png and b/docs/assets/screenshots/result-entity-selected.png differ diff --git a/docs/assets/screenshots/result-export-menu.png b/docs/assets/screenshots/result-export-menu.png index d296521..64137bf 100644 Binary files a/docs/assets/screenshots/result-export-menu.png and b/docs/assets/screenshots/result-export-menu.png differ diff --git a/docs/assets/screenshots/result-overview.png b/docs/assets/screenshots/result-overview.png index 4690527..c833a18 100644 Binary files a/docs/assets/screenshots/result-overview.png and b/docs/assets/screenshots/result-overview.png differ diff --git a/docs/assets/screenshots/result-pdf-area-editor.png b/docs/assets/screenshots/result-pdf-area-editor.png index 7f9897c..817e099 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/assets/screenshots/result-pdf.png b/docs/assets/screenshots/result-pdf.png index 330867f..304a025 100644 Binary files a/docs/assets/screenshots/result-pdf.png and b/docs/assets/screenshots/result-pdf.png differ diff --git a/docs/assets/screenshots/result-review-panel.png b/docs/assets/screenshots/result-review-panel.png index d7c2794..b17bbe0 100644 Binary files a/docs/assets/screenshots/result-review-panel.png and b/docs/assets/screenshots/result-review-panel.png differ diff --git a/docs/assets/screenshots/result-review-required.png b/docs/assets/screenshots/result-review-required.png index 97fb0fe..edd2118 100644 Binary files a/docs/assets/screenshots/result-review-required.png and b/docs/assets/screenshots/result-review-required.png differ diff --git a/docs/assets/screenshots/settings-expert-mode.png b/docs/assets/screenshots/settings-expert-mode.png index 5a98588..a01f46f 100644 Binary files a/docs/assets/screenshots/settings-expert-mode.png and b/docs/assets/screenshots/settings-expert-mode.png differ diff --git a/docs/user-guide/export.md b/docs/user-guide/export.md index e415fb7..90f3d9f 100644 --- a/docs/user-guide/export.md +++ b/docs/user-guide/export.md @@ -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. diff --git a/docs/user-guide/review.md b/docs/user-guide/review.md index 3d45172..a1d888d 100644 --- a/docs/user-guide/review.md +++ b/docs/user-guide/review.md @@ -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
@@ -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. | diff --git a/e2e/tests/workflow.spec.ts b/e2e/tests/workflow.spec.ts index 1ecd2f6..b8346fe 100644 --- a/e2e/tests/workflow.spec.ts +++ b/e2e/tests/workflow.spec.ts @@ -78,6 +78,71 @@ test.describe('anonymization workflow', () => { await expect(outputPanel).not.toContainText('Max Mustermann', { timeout: 30_000 }) }) + test('checks in the result whether a redacted name is really gone', async ({ page }) => { + await page.goto('/') + + await page.getByLabel('Text einfügen').fill(DISCHARGE_LETTER) + await page.getByRole('button', { name: 'Anonymisieren' }).click() + await waitForResult(page) + + const output = page.getByRole('heading', { name: 'Anonymisierter Text' }) + const outputPanel = page.locator('section', { has: output }).last() + + // One click from a find to "is this text still in the output?". + await page.locator('[data-entity-index]', { hasText: 'Max Mustermann' }).first().click() + const details = page.getByLabel('Details zur ausgewählten Entität') + await details.getByRole('button', { name: 'Im Ergebnis suchen' }).click() + + const search = outputPanel.getByRole('search') + const field = search.getByLabel('In dieser Ansicht suchen') + await expect(field).toHaveValue('Max Mustermann') + await expect(search).toContainText('Kein Treffer') + await expect(outputPanel.locator('[data-match-index]')).toHaveCount(0) + + // The placeholder that replaced it IS there — and is marked in the panel. + await field.fill('[PERSON_1]') + await expect(search).toContainText('1/1') + await expect(outputPanel.locator('[data-match-index]')).toHaveCount(1) + + // The search belongs to one panel: the source review is untouched by it. + const sourcePanel = page + .locator('section', { has: page.getByRole('heading', { name: 'Quellprüfung' }) }) + .last() + await expect(sourcePanel.locator('[data-match-index]')).toHaveCount(0) + }) + + test('opens the search in the panel the reviewer is working in', async ({ page }) => { + await page.goto('/') + + await page.getByLabel('Text einfügen').fill(DISCHARGE_LETTER) + await page.getByRole('button', { name: 'Anonymisieren' }).click() + await waitForResult(page) + + const outputPanel = page + .locator('section', { has: page.getByRole('heading', { name: 'Anonymisierter Text' }) }) + .last() + const sourcePanel = page + .locator('section', { has: page.getByRole('heading', { name: 'Quellprüfung' }) }) + .last() + + // Nothing touched yet: the shortcut lands on the result, which is what a + // final check is about. + await page.keyboard.press('ControlOrMeta+f') + await expect(outputPanel.getByRole('search')).toBeVisible() + await page.getByLabel('In dieser Ansicht suchen').fill('[PERSON_1]') + await expect(outputPanel.getByRole('search')).toContainText('1/1') + + // Escape closes it again. + await page.keyboard.press('Escape') + await expect(outputPanel.getByRole('search')).toHaveCount(0) + + // After working in the source review, the shortcut follows the reviewer. + await sourcePanel.locator('[data-entity-index]').first().click() + await page.keyboard.press('ControlOrMeta+f') + await expect(sourcePanel.getByRole('search')).toBeVisible() + await expect(outputPanel.getByRole('search')).toHaveCount(0) + }) + test('selects several finds at once and changes them in one go', async ({ page }) => { await page.goto('/') @@ -234,6 +299,35 @@ test.describe('anonymization workflow', () => { expect((await download).suggestedFilename()).toContain('.pdf') }) + test('checks a PDF result in the text view, which can mark the hit', 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) + + // The result panel starts on the redacted PDF, which carries no search: + // a browser PDF viewer cannot be searched or highlighted from the app. + const pdfPanel = page + .locator('section', { has: page.getByRole('heading', { name: 'Geschwärztes PDF' }) }) + .last() + await expect(pdfPanel.getByRole('button', { name: 'Suchen', exact: true })).toHaveCount(0) + + // "Im Ergebnis suchen" therefore switches to the anonymized TEXT, where the + // answer can actually be shown. + await page.locator('[data-entity-index]').first().click() + await page + .getByLabel('Details zur ausgewählten Entität') + .getByRole('button', { name: 'Im Ergebnis suchen' }) + .click() + + const outputPanel = page + .locator('section', { has: page.getByRole('heading', { name: 'Anonymisierter Text' }) }) + .last() + await expect(outputPanel.getByRole('search')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Geschwärztes PDF' })).toHaveCount(0) + }) + test('shows the automatic redactions in the area editor', async ({ page }) => { await page.goto('/') diff --git a/frontend/components/anonymizer/EntityDetailPanel.vue b/frontend/components/anonymizer/EntityDetailPanel.vue index 2d4886f..bef5966 100644 --- a/frontend/components/anonymizer/EntityDetailPanel.vue +++ b/frontend/components/anonymizer/EntityDetailPanel.vue @@ -96,6 +96,17 @@ > {{ t('detail.select_all_occurrences', { count: occurrenceCount }, occurrenceCount) }} + + + import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { X } from '@lucide/vue' +import { Search, X } from '@lucide/vue' import BaseButton from '@/components/common/BaseButton.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import StatusBadge from '@/components/common/StatusBadge.vue' @@ -142,6 +153,8 @@ const props = defineProps() const emit = defineEmits<{ (e: 'close'): void + /** Look this text up in the result panel's search. */ + (e: 'search', term: string): void }>() const { t } = useI18n() diff --git a/frontend/components/anonymizer/EntityHighlights.vue b/frontend/components/anonymizer/EntityHighlights.vue index 2d7e8ef..2c91919 100644 --- a/frontend/components/anonymizer/EntityHighlights.vue +++ b/frontend/components/anonymizer/EntityHighlights.vue @@ -20,7 +20,7 @@ class="relative min-h-0 flex-1 overflow-y-auto p-6 font-sans text-[15px] leading-relaxed whitespace-pre-wrap break-words text-content" @scroll.passive="hidePopover" > - + + + + diff --git a/frontend/components/anonymizer/ResultView.vue b/frontend/components/anonymizer/ResultView.vue index dc1dbc4..13e4974 100644 --- a/frontend/components/anonymizer/ResultView.vue +++ b/frontend/components/anonymizer/ResultView.vue @@ -118,6 +118,26 @@ + + @@ -206,9 +226,15 @@
-
+

{{ t('result.panels.original') }}

+