From 52924a60c1d138ab8cc8e86f80aff9f913a5450e Mon Sep 17 00:00:00 2001 From: Theo <80580619+theodubus@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:15:35 +0100 Subject: [PATCH] fix(frontend): stabilize text selection by memoizing page size callback --- backend/app/redaction.py | 4 ++ backend/tests/test_redact_rectangles.py | 29 ++++++++ frontend/src/App.tsx | 58 +++++++++++++++- frontend/src/api.ts | 7 +- frontend/src/components/PdfViewer.tsx | 68 +++++++++++++++++-- .../src/components/Rules/EditRuleModal.tsx | 6 +- frontend/src/components/Rules/RulesList.tsx | 6 +- .../src/components/Rules/RulesSection.tsx | 31 +++++++-- frontend/src/locales/en.json | 4 +- frontend/src/locales/fr.json | 4 +- frontend/src/types/uiRules.ts | 7 ++ 11 files changed, 200 insertions(+), 24 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..58b8e78 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,8 @@ 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 [presets, setPresets] = useState>(EMPTY_PRESETS); const [isDragOver, setIsDragOver] = useState(false); @@ -79,17 +81,29 @@ 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.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({}); return; } @@ -97,12 +111,16 @@ export default function App() { pickedFile.type === "application/pdf" || pickedFile.name.toLowerCase().endsWith(".pdf"); if (!isPdf) { setFile(null); + setCurrentPage(null); + setPageSizes({}); setErrorInfo({ rawMessage: t("form.file.invalidType") }); return; } setFile(pickedFile); setPendingSelection(null); + setCurrentPage(1); + setPageSizes({}); setRules([]); setPresets(EMPTY_PRESETS); }; @@ -124,6 +142,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 +154,35 @@ 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 handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setErrorInfo(null); @@ -157,6 +205,8 @@ export default function App() { rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, + applyImages: hasFullPageRule, + applyGraphics: hasFullPageRule, }); downloadBlob(r.pdfBlob, "redacted.pdf"); @@ -192,6 +242,8 @@ export default function App() { presetKeys={selectedPresets} t={t} onSelectionChange={setPendingSelection} + onCurrentPageChange={setCurrentPage} + onPageSizeChange={handlePageSizeChange} /> ) : (
0} onAddSelection={addPendingSelection} + 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..e59caec 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -53,8 +53,10 @@ 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; }) { - const { file, rules, presetKeys, t, onSelectionChange } = props; + const { file, rules, presetKeys, t, onSelectionChange, onCurrentPageChange, onPageSizeChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -65,6 +67,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageRefs = useRef>([]); const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); @@ -89,6 +92,7 @@ export function PdfViewer(props: { let loadedDoc: PdfDocumentProxy | null = null; onSelectionChange(null); + onCurrentPageChange(null); pageScalesRef.current = []; setPdfDoc(null); setError(null); @@ -109,6 +113,7 @@ export function PdfViewer(props: { } setPdfDoc(doc); + onCurrentPageChange(1); } catch { if (!active) return; setError(t("viewer.error.load")); @@ -121,7 +126,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, onSelectionChange]); + }, [file, onSelectionChange, onCurrentPageChange]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -140,6 +145,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 +200,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,6 +211,37 @@ 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 = () => { const selection = window.getSelection(); @@ -265,13 +302,14 @@ export function PdfViewer(props: { } onSelectionChange({ text: selectedText, rects }); + onCurrentPageChange(pageIndex + 1); }; document.addEventListener("selectionchange", computeSelection); return () => { document.removeEventListener("selectionchange", computeSelection); }; - }, [onSelectionChange]); + }, [onSelectionChange, onCurrentPageChange]); if (error) { return
{error}
; @@ -283,7 +321,14 @@ export function PdfViewer(props: {
{pageNumbers.map((pageNumber) => ( -
+
{ + pageRefs.current[pageNumber - 1] = el; + }} + > { @@ -355,6 +400,17 @@ 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 selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); for (const selectionRule of selectionRules) { for (const rect of selectionRule.rects) { @@ -393,7 +449,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") 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..be4d394 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") => 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" ? : null}
- {r.kind !== "selection" ? ( + {r.kind !== "selection" && r.kind !== "page" ? ( -
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 362c615..bad3fed 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -71,5 +71,7 @@ "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" } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 4957e73..4af1a99 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -71,5 +71,7 @@ "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" } diff --git a/frontend/src/types/uiRules.ts b/frontend/src/types/uiRules.ts index dae1d51..f3d32dd 100644 --- a/frontend/src/types/uiRules.ts +++ b/frontend/src/types/uiRules.ts @@ -31,4 +31,11 @@ export type UiRule = kind: "selection"; value: string; rects: UiRect[]; + } + | { + id: string; + kind: "page"; + value: string; + pageNumber: number; + rect: UiRect; };