From 4f3603988642a65d7c21527e679ed0bb9ce11c54 Mon Sep 17 00:00:00 2001 From: Theo <80580619+theodubus@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:37:26 +0100 Subject: [PATCH] feat(frontend): add draw-rectangle redaction workflow --- backend/app/redaction.py | 4 + backend/tests/test_redact_rectangles.py | 29 +++ frontend/src/App.tsx | 90 +++++++- frontend/src/api.ts | 7 +- frontend/src/components/PdfViewer.tsx | 198 +++++++++++++++++- .../src/components/Rules/EditRuleModal.tsx | 6 +- frontend/src/components/Rules/RulesList.tsx | 6 +- .../src/components/Rules/RulesSection.tsx | 46 +++- frontend/src/locales/en.json | 7 +- frontend/src/locales/fr.json | 7 +- frontend/src/styles.css | 38 ++++ frontend/src/types/uiRules.ts | 15 ++ 12 files changed, 426 insertions(+), 27 deletions(-) diff --git a/backend/app/redaction.py b/backend/app/redaction.py index 91cb0f8..e00e54f 100644 --- a/backend/app/redaction.py +++ b/backend/app/redaction.py @@ -37,6 +37,10 @@ def _tighten_rect_vertical(rect: pymupdf.Rect) -> pymupdf.Rect: if h <= 0: return rect + # Les zones volumineuses (ex: page complète) ne doivent pas être compressées verticalement. + if h > 24.0: + return rect + target = h * 0.60 if target < 3.0: target = 3.0 diff --git a/backend/tests/test_redact_rectangles.py b/backend/tests/test_redact_rectangles.py index 8528600..b03f718 100644 --- a/backend/tests/test_redact_rectangles.py +++ b/backend/tests/test_redact_rectangles.py @@ -130,3 +130,32 @@ def test_redaction_does_not_modify_original_fixture_on_disk() -> None: after_bytes = SECRET_FIXTURE.read_bytes() assert before_bytes == after_bytes, "Fixture PDF on disk must remain byte-identical" assert SECRET in extract_text(after_bytes), "Original must still contain the secret" + + +@pytest.mark.integration +def test_redact_rectangles_full_page_rect_redacts_target() -> None: + pdf_in = SECRET_FIXTURE.read_bytes() + original_text = extract_text(pdf_in) + assert SECRET in original_text + + doc = pymupdf.open(stream=pdf_in, filetype="pdf") + try: + page = doc[0] + bounds = page.rect + full_page_rect = {"page": 0, "x0": bounds.x0, "y0": bounds.y0, "x1": bounds.x1, "y1": bounds.y1} + finally: + doc.close() + + payload = make_payload([full_page_rect], patterns=[SECRET]) + + resp = CLIENT.post( + "/redact/rectangles", + files={"file": ("input.pdf", pdf_in, "application/pdf")}, + data={"payload": json.dumps(payload)}, + ) + + assert resp.status_code == 200 + assert resp.headers.get("x-redaction-audit-status") == "pass" + + out_text = extract_text(resp.content) + assert SECRET not in out_text diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 941088b..b653fde 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { useI18n } from "./i18n"; import { redactApply } from "./api"; import type { PresetKey, RuleInput } from "./api"; @@ -29,6 +29,9 @@ export default function App() { const [file, setFile] = useState(null); const [rules, setRules] = useState([]); const [pendingSelection, setPendingSelection] = useState(null); + const [currentPage, setCurrentPage] = useState(null); + const [pageSizes, setPageSizes] = useState>({}); + const [isDrawingRect, setIsDrawingRect] = useState(false); const [presets, setPresets] = useState>(EMPTY_PRESETS); const [isDragOver, setIsDragOver] = useState(false); @@ -42,6 +45,7 @@ export default function App() { } | null>(null); const fileInputRef = useRef(null); + const nextRectangleNumberRef = useRef(1); const clearNotices = () => { setErrorInfo(null); @@ -79,17 +83,33 @@ export default function App() { }, [rules]); const rectsForApi = useMemo(() => { - return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); + return rules.flatMap((r) => + r.kind === "selection" ? r.rects : r.kind === "page" || r.kind === "rectangle" ? [r.rect] : [] + ); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; + const hasFullPageRule = useMemo(() => rules.some((r) => r.kind === "page"), [rules]); + + const handlePageSizeChange = useCallback((pageNumber: number, size: { width: number; height: number }) => { + setPageSizes((prev) => { + const existing = prev[pageNumber]; + if (existing && existing.width === size.width && existing.height === size.height) return prev; + return { ...prev, [pageNumber]: size }; + }); + }, []); + const loadPdfFile = (pickedFile: File | null) => { clearNotices(); if (!pickedFile) { setFile(null); setPendingSelection(null); + setCurrentPage(null); + setPageSizes({}); + setIsDrawingRect(false); + nextRectangleNumberRef.current = 1; return; } @@ -97,12 +117,20 @@ export default function App() { pickedFile.type === "application/pdf" || pickedFile.name.toLowerCase().endsWith(".pdf"); if (!isPdf) { setFile(null); + setCurrentPage(null); + setPageSizes({}); + setIsDrawingRect(false); + nextRectangleNumberRef.current = 1; setErrorInfo({ rawMessage: t("form.file.invalidType") }); return; } setFile(pickedFile); setPendingSelection(null); + setCurrentPage(1); + setPageSizes({}); + setIsDrawingRect(false); + nextRectangleNumberRef.current = 1; setRules([]); setPresets(EMPTY_PRESETS); }; @@ -124,6 +152,7 @@ export default function App() { const trimmed = pendingSelection.text.trim(); if (!trimmed || pendingSelection.rects.length === 0) return; + const newRule: UiRule = { id: newId(), kind: "selection", @@ -135,6 +164,53 @@ export default function App() { setPendingSelection(null); }; + + const addCurrentPageRule = () => { + const pageNumber = currentPage; + if (!pageNumber) return; + clearNotices(); + + const exists = rules.some((rule) => rule.kind === "page" && rule.pageNumber === pageNumber); + if (exists) return; + + const pageSize = pageSizes[pageNumber]; + if (!pageSize) return; + + const newRule: UiRule = { + id: newId(), + kind: "page", + value: `${t("rules.page.title")} ${pageNumber}`, + pageNumber, + rect: { + page: pageNumber - 1, + x0: 0, + y0: 0, + x1: pageSize.width, + y1: pageSize.height, + }, + }; + + setRules((prev) => [...prev, newRule]); + }; + + + const addDrawnRectangleRule = (params: { pageNumber: number; rect: UiRect }) => { + clearNotices(); + const rectangleNumber = nextRectangleNumberRef.current; + nextRectangleNumberRef.current += 1; + + const newRule: UiRule = { + id: newId(), + kind: "rectangle", + value: `${t("rules.rectangle.title")} ${rectangleNumber} (${t("rules.page.title")} ${params.pageNumber})`, + rectangleNumber, + pageNumber: params.pageNumber, + rect: params.rect, + }; + + setRules((prev) => [...prev, newRule]); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setErrorInfo(null); @@ -157,6 +233,8 @@ export default function App() { rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, + applyImages: hasFullPageRule, + applyGraphics: hasFullPageRule, }); downloadBlob(r.pdfBlob, "redacted.pdf"); @@ -192,6 +270,10 @@ export default function App() { presetKeys={selectedPresets} t={t} onSelectionChange={setPendingSelection} + onCurrentPageChange={setCurrentPage} + onPageSizeChange={handlePageSizeChange} + isDrawingRect={isDrawingRect} + onAddDrawnRect={addDrawnRectangleRule} /> ) : (
0} onAddSelection={addPendingSelection} + isDrawingRect={isDrawingRect} + onToggleDrawSelection={() => setIsDrawingRect((prev) => !prev)} + canCensorPage={!!currentPage && !!pageSizes[currentPage]} + onCensorPage={addCurrentPageRule} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 360248a..ee9916c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -70,6 +70,8 @@ export async function redactApply(params: { rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; + applyImages?: boolean; + applyGraphics?: boolean; }): Promise { const form = new FormData(); form.append("file", params.file); @@ -111,7 +113,10 @@ export async function redactApply(params: { searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, - options: {}, + options: { + apply_images: !!params.applyImages, + apply_graphics: !!params.applyGraphics, + }, // audit additionnel facultatif : on laisse null (audit_plan gère déjà search/regex/presets) audit: null, }; diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index e69dc85..4389c8a 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; import type { PresetKey } from "../api"; import type { UiRect, UiRule } from "../types/uiRules"; @@ -28,6 +29,15 @@ type PdfDocumentProxy = { destroy: () => void; }; + +type DrawDraft = { + pageIndex: number; + startX: number; + startY: number; + currentX: number; + currentY: number; +}; + type PdfJsLib = { GlobalWorkerOptions: { workerSrc: string }; getDocument: (params: { data: Uint8Array }) => { promise: Promise }; @@ -53,8 +63,12 @@ export function PdfViewer(props: { presetKeys: PresetKey[]; t: (k: string) => string; onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; + onCurrentPageChange: (pageNumber: number | null) => void; + onPageSizeChange: (pageNumber: number, size: { width: number; height: number }) => void; + isDrawingRect: boolean; + onAddDrawnRect: (params: { pageNumber: number; rect: UiRect }) => void; }) { - const { file, rules, presetKeys, t, onSelectionChange } = props; + const { file, rules, presetKeys, t, onSelectionChange, onCurrentPageChange, onPageSizeChange, isDrawingRect, onAddDrawnRect } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -65,8 +79,10 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageRefs = useRef>([]); const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); + const [drawDraft, setDrawDraft] = useState(null); useEffect(() => { const el = containerRef.current; @@ -89,10 +105,12 @@ export function PdfViewer(props: { let loadedDoc: PdfDocumentProxy | null = null; onSelectionChange(null); + onCurrentPageChange(null); pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); + setDrawDraft(null); (async () => { try { @@ -109,6 +127,7 @@ export function PdfViewer(props: { } setPdfDoc(doc); + onCurrentPageChange(1); } catch { if (!active) return; setError(t("viewer.error.load")); @@ -121,7 +140,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, onSelectionChange]); + }, [file, onSelectionChange, onCurrentPageChange]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -140,6 +159,7 @@ export function PdfViewer(props: { const page = await pdfDoc.getPage(pageNumber); const baseViewport = page.getViewport({ scale: 1 }); + onPageSizeChange(pageNumber, { width: baseViewport.width, height: baseViewport.height }); const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); @@ -194,7 +214,7 @@ export function PdfViewer(props: { return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys, onPageSizeChange]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { @@ -205,8 +225,44 @@ export function PdfViewer(props: { } }, [rules, presetKeys]); + useEffect(() => { + if (!containerRef.current || pageNumbers.length === 0) return; + + const container = containerRef.current; + const observer = new IntersectionObserver( + (entries) => { + let bestPage: number | null = null; + let bestRatio = 0; + + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const page = Number((entry.target as HTMLElement).dataset.pageNumber ?? "0"); + if (!page) continue; + if (entry.intersectionRatio > bestRatio) { + bestRatio = entry.intersectionRatio; + bestPage = page; + } + } + + if (bestPage) onCurrentPageChange(bestPage); + }, + { root: container, threshold: [0.25, 0.5, 0.75] }, + ); + + for (const pageEl of pageRefs.current) { + if (pageEl) observer.observe(pageEl); + } + + return () => observer.disconnect(); + }, [pageNumbers, onCurrentPageChange]); + useEffect(() => { const computeSelection = () => { + if (isDrawingRect) { + onSelectionChange(null); + return; + } + const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { onSelectionChange(null); @@ -265,13 +321,84 @@ export function PdfViewer(props: { } onSelectionChange({ text: selectedText, rects }); + onCurrentPageChange(pageIndex + 1); }; document.addEventListener("selectionchange", computeSelection); return () => { document.removeEventListener("selectionchange", computeSelection); }; - }, [onSelectionChange]); + }, [onSelectionChange, onCurrentPageChange, isDrawingRect]); + + + const clampPoint = (value: number, max: number) => Math.max(0, Math.min(value, max)); + + const beginDraw = (pageIndex: number, event: ReactPointerEvent) => { + if (!isDrawingRect) return; + + const layer = event.currentTarget; + const bounds = layer.getBoundingClientRect(); + const x = clampPoint(event.clientX - bounds.left, bounds.width); + const y = clampPoint(event.clientY - bounds.top, bounds.height); + + layer.setPointerCapture(event.pointerId); + setDrawDraft({ pageIndex, startX: x, startY: y, currentX: x, currentY: y }); + event.preventDefault(); + }; + + const moveDraw = (pageIndex: number, event: ReactPointerEvent) => { + if (!isDrawingRect || !drawDraft || drawDraft.pageIndex !== pageIndex) return; + + const layer = event.currentTarget; + const bounds = layer.getBoundingClientRect(); + const x = clampPoint(event.clientX - bounds.left, bounds.width); + const y = clampPoint(event.clientY - bounds.top, bounds.height); + + setDrawDraft((prev) => (prev && prev.pageIndex === pageIndex ? { ...prev, currentX: x, currentY: y } : prev)); + event.preventDefault(); + }; + + const endDraw = (pageIndex: number, event: ReactPointerEvent) => { + if (!isDrawingRect || !drawDraft || drawDraft.pageIndex !== pageIndex) return; + + const layer = event.currentTarget; + if (layer.hasPointerCapture(event.pointerId)) { + layer.releasePointerCapture(event.pointerId); + } + + const bounds = layer.getBoundingClientRect(); + const x = clampPoint(event.clientX - bounds.left, bounds.width); + const y = clampPoint(event.clientY - bounds.top, bounds.height); + + const left = Math.min(drawDraft.startX, x); + const right = Math.max(drawDraft.startX, x); + const top = Math.min(drawDraft.startY, y); + const bottom = Math.max(drawDraft.startY, y); + + const minSize = 4; + if (right - left >= minSize && bottom - top >= minSize) { + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (scale > 0) { + onAddDrawnRect({ + pageNumber: pageIndex + 1, + rect: { + page: pageIndex, + x0: left / scale, + y0: top / scale, + x1: right / scale, + y1: bottom / scale, + }, + }); + } + } + + setDrawDraft(null); + event.preventDefault(); + }; + + const cancelDraw = () => { + if (drawDraft) setDrawDraft(null); + }; if (error) { return
{error}
; @@ -283,7 +410,14 @@ export function PdfViewer(props: {
{pageNumbers.map((pageNumber) => ( -
+
{ + pageRefs.current[pageNumber - 1] = el; + }} + > { @@ -297,7 +431,26 @@ export function PdfViewer(props: { }} />
beginDraw(pageNumber - 1, event)} + onPointerMove={(event) => moveDraw(pageNumber - 1, event)} + onPointerUp={(event) => endDraw(pageNumber - 1, event)} + onPointerCancel={cancelDraw} + > + {drawDraft && drawDraft.pageIndex === pageNumber - 1 ? ( +
+ ) : null} +
+
{ textLayerRefs.current[pageNumber - 1] = el; }} @@ -355,6 +508,29 @@ function applyPreviewHighlights( highlightPhonePresetMatches(textLayer, previewLayer, layerBounds); } + const pageRules = rules.filter((r): r is Extract => r.kind === "page"); + for (const pageRule of pageRules) { + if (pageRule.pageNumber !== pageIndex + 1) continue; + drawPreviewRect(previewLayer, { + left: 0, + top: 0, + width: layerBounds.width, + height: layerBounds.height, + }); + } + + const rectangleRules = rules.filter((r): r is Extract => r.kind === "rectangle"); + for (const rectangleRule of rectangleRules) { + if (rectangleRule.pageNumber !== pageIndex + 1) continue; + const rect = rectangleRule.rect; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }, String(rectangleRule.rectangleNumber)); + } + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); for (const selectionRule of selectionRules) { for (const rect of selectionRule.rects) { @@ -372,6 +548,7 @@ function applyPreviewHighlights( function drawPreviewRect( previewLayer: HTMLDivElement, rect: { left: number; top: number; width: number; height: number }, + label?: string, ) { const width = rect.width; const height = rect.height; @@ -383,6 +560,13 @@ function drawPreviewRect( highlight.style.top = `${rect.top}px`; highlight.style.width = `${width}px`; highlight.style.height = `${height}px`; + if (label) { + const badge = document.createElement("div"); + badge.className = "redactionPreviewRectLabel"; + badge.textContent = label; + highlight.appendChild(badge); + } + previewLayer.appendChild(highlight); } @@ -393,7 +577,7 @@ function collectMatches( ): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { - if (rule.kind === "selection") continue; + if (rule.kind === "selection" || rule.kind === "page" || rule.kind === "rectangle") continue; const value = rule.value.trim(); if (!value) continue; const ranges = diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index 36ef724..f7323f6 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -21,7 +21,7 @@ type UiRuleNoId = export function EditRuleModal(props: { t: (k: string) => string; - rule: UiRule | null; + rule: Extract | null; onClose: () => void; onSave: (updated: UiRuleNoId) => void; }) { @@ -35,10 +35,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule && rule.kind !== "selection"; + const isOpen = !!rule; useEffect(() => { - if (!rule || rule.kind === "selection") return; + if (!rule) return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index ebb8509..6e28fb7 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -78,7 +78,7 @@ function RuleOptionPills(props: { export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind | "selection") => string; + kindLabel: (k: RuleKind | "selection" | "page" | "rectangle") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -138,11 +138,11 @@ export function RulesList(props: { {kindLabel(r.kind)}
- {r.kind !== "selection" ? : null} + {r.kind !== "selection" && r.kind !== "page" && r.kind !== "rectangle" ? : null}
- {r.kind !== "selection" ? ( + {r.kind !== "selection" && r.kind !== "page" && r.kind !== "rectangle" ? ( -
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 362c615..714abb1 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -71,5 +71,10 @@ "rules.options.summary": "Options", "rules.action.drawSelection": "Draw selection", "rules.action.censorPage": "Redact page", - "rules.option.respectAccents": "Respect accents" + "rules.option.respectAccents": "Respect accents", + "rules.badge.page": "Full page", + "rules.page.title": "Page", + "rules.badge.rectangle": "Rectangle", + "rules.rectangle.title": "Rectangle", + "rules.action.stopDrawingSelection": "Stop drawing" } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 4957e73..2eb43f8 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -71,5 +71,10 @@ "rules.options.summary": "Options", "rules.action.drawSelection": "Dessiner sélection", "rules.action.censorPage": "Censurer la page", - "rules.option.respectAccents": "Respecter les accents" + "rules.option.respectAccents": "Respecter les accents", + "rules.badge.page": "Page complète", + "rules.page.title": "Page", + "rules.badge.rectangle": "Rectangle", + "rules.rectangle.title": "Rectangle", + "rules.action.stopDrawingSelection": "Arrêter dessin" } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f076b77..bd7c0e6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -281,6 +281,44 @@ body, box-shadow: inset 0 0 0 1px rgba(32, 93, 220, 0.24); } + +.pdfDrawLayer { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.pdfDrawLayerActive { + pointer-events: auto; + cursor: crosshair; +} + +.pdfTextLayerNoPointer { + pointer-events: none; + user-select: none; + -webkit-user-select: none; +} + +.redactionPreviewRectLabel { + position: absolute; + top: 0; + right: 0; + min-width: 26px; + height: 26px; + padding: 2px 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 20px; + color: #1e66c2; + background: rgba(255, 255, 255, 0.6); + border-left: 1px solid rgba(32, 93, 220, 0.5); + border-bottom: 1px solid rgba(32, 93, 220, 0.5); +} + + .pdfTextLayer br, .textLayer br { position: absolute; diff --git a/frontend/src/types/uiRules.ts b/frontend/src/types/uiRules.ts index dae1d51..e7aa355 100644 --- a/frontend/src/types/uiRules.ts +++ b/frontend/src/types/uiRules.ts @@ -31,4 +31,19 @@ export type UiRule = kind: "selection"; value: string; rects: UiRect[]; + } + | { + id: string; + kind: "page"; + value: string; + pageNumber: number; + rect: UiRect; + } + | { + id: string; + kind: "rectangle"; + value: string; + rectangleNumber: number; + pageNumber: number; + rect: UiRect; };