From 698b61c3814e08ad31c07600b279d0922a992537 Mon Sep 17 00:00:00 2001 From: Theo <80580619+theodubus@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:37:14 +0100 Subject: [PATCH] docs: expand product, security, contribution, and deployment guides --- CONTRIBUTING.md | 72 +++++++ README.md | 177 ++++++++++++++-- backend/app/redaction.py | 4 + backend/tests/test_redact_rectangles.py | 29 +++ docs/DEPLOYMENT.md | 124 +++++++++++ docs/SECURITY.md | 61 ++++-- frontend/src/App.tsx | 94 ++++++++- frontend/src/api.ts | 13 +- 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 ++ 16 files changed, 833 insertions(+), 64 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/DEPLOYMENT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..382690d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to RedactPDF + +Merci pour ta contribution ❤️ + +## Objectif du projet + +RedactPDF vise un caviardage PDF **réellement destructif** (sur l’export), avec audit post-traitement. + +## Principes de contribution + +1. **Priorité sécurité** + - Toute modif qui peut impacter la suppression réelle des données doit être testée. +2. **Ne pas casser l’audit** + - Si l’audit échoue, l’export ne doit pas être livré comme succès. +3. **Reproductibilité** + - Favoriser des fixtures déterministes et des tests automatisés. + +## Setup local + +### Backend +```bash +pip install -e "backend[dev]" +cd backend +uvicorn app.main:app --reload +``` + +### Frontend +```bash +cd frontend +npm install +npm run dev +``` + +## Tests + +Voir [docs/TESTING.md](docs/TESTING.md). + +En bref: +```bash +cd backend +pytest +ruff check . +``` + +Frontend: +```bash +cd frontend +npm run build +``` + +## Pull requests + +Merci d’inclure dans la PR: + +- **Contexte**: problème observé / besoin métier +- **Solution**: ce qui change techniquement +- **Impact sécurité**: texte/images/graphics/metadata/annotations/attachments +- **Validation**: commandes et résultats +- **Limites** éventuelles + +## Zones sensibles (à relire attentivement) + +- `backend/app/redaction.py` +- `backend/app/audit.py` et pipeline associé +- `frontend/src/components/PdfViewer.tsx` +- `frontend/src/api.ts` + +## Bonnes pratiques produit + +- Préférer des comportements explicitement “strict” pour les usages sensibles +- Documenter clairement les limites (PDF aplatis, scans, OCR, etc.) +- Éviter les promesses de sécurité non vérifiées diff --git a/README.md b/README.md index 84f73d4..ad9bca7 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,178 @@ # RedactPDF -Local, verifiable PDF redaction (removes content instead of masking). -## Backend +Outil de caviardage PDF **local et vérifiable** : l’objectif est de **retirer réellement** les données sensibles d’un PDF exporté, et pas seulement de les masquer visuellement. -Install dev dependencies: -```bash -pip install -e "backend[dev]" -``` +--- + +## Pourquoi ce projet ? + +Le mot “censure PDF” peut vouloir dire plusieurs choses très différentes en pratique. + +- **Masquage visuel (overlay / rectangle noir dessiné)** + - On voit du noir à l’écran. + - Mais le texte, l’image ou l’objet d’origine peut rester récupérable. +- **Aplatissement / rasterisation** + - On convertit la page en image (ou en forme “figée”). + - Peut réduire la récupérabilité du texte, mais dégrade souvent la qualité et l’accessibilité. +- **Caviardage réel (redaction)** + - Le contenu ciblé est supprimé de la structure PDF exportée. + - C’est le mode visé par RedactPDF. + +RedactPDF suit une approche “sécurité d’abord” : appliquer la redaction, puis auditer le résultat avant de livrer le PDF exporté. + +--- + +## Fonctionnalités actuelles + +### Censure par requêtes +- Recherche exacte +- Regex +- Options de matching (casse, sous-mot/mot entier, accents, etc.) + +### Presets +- Emails +- Téléphones +- Cartes bancaires + +### Censure visuelle guidée +- Sélection texte au curseur +- Censure de page complète +- Dessin de rectangles (click-glisser) -Run tests: +### Prévisualisation +- Surcouches de preview avant export +- Numérotation des rectangles dessinés pour faciliter le contrôle opérateur +### Durcissement de l’export +- Audit post-redaction (bloque la sortie si fuite détectée) +- Support options de sanitation (métadonnées, annotations, pièces jointes) selon configuration + +--- + +## Limites connues (importantes) + +1. **PDF “aplatis” / scans / pages-image** + - Si le document est essentiellement une image, les stratégies de redaction fine sont plus limitées. + - Dans certains scénarios, un rectangle qui chevauche une image peut conduire à retirer un objet image entier (si mode strict), ou à un masquage partiel visuel (si mode non strict). + +2. **Censure partielle irréversible d’image/vectoriel (fine-grain)** + - Pas encore implémentée comme workflow natif robuste. + - Aujourd’hui, on est plutôt sur des modes “supprimer l’objet touché” vs “ne pas le supprimer”, selon options. + +3. **OCR hors périmètre de base** + - Le pipeline de base ne reconstruit pas automatiquement du texte OCR pour scans complexes. + +--- + +## Démarrage rapide (développeurs) + +### Prérequis +- Python 3.10+ +- Node.js 20+ +- npm + +### 1) Cloner ```bash -cd backend -pytest +git clone +cd RedactPDF ``` -Run the API locally: - +### 2) Backend ```bash +pip install -e "backend[dev]" cd backend uvicorn app.main:app --reload ``` +Backend dispo sur `http://127.0.0.1:8000` (par défaut). + +### 3) Frontend +Dans un second terminal: +```bash +cd frontend +npm install +npm run dev +``` +Frontend dispo sur `http://127.0.0.1:5173` (ou port affiché par Vite). + +--- + +## Utilisation (opérateur) + +1. Charger un PDF. +2. Ajouter des règles (requêtes/regex/presets), ou utiliser: + - **Censurer la page** + - **Dessiner sélection** (rectangle) + - **Sélection texte** +3. Vérifier la preview. +4. Cliquer **Censurer et télécharger**. +5. Si audit KO: corriger les règles et recommencer. + +--- + +## Installation via Docker (sans build local complexe) + +Voir le guide détaillé: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). + +Résumé: +- Construire les images backend+frontend, +- Exposer les ports, +- Lancer les services via `docker compose up -d`. + +--- + +## Installation via installeur / exécutable (utilisateur non technique) + +Voir le plan détaillé: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). + +Résumé: +- Fournir un package “un clic” qui: + - embarque backend + frontend, + - démarre les services localement, + - ouvre automatiquement l’URL locale dans le navigateur. + +--- + +## Médias de documentation (placeholders) + +Tu peux déposer ici des captures/gifs/vidéos, puis les référencer dans ce README. + +Exemples recommandés: + +- `docs/media/01-upload-and-preview.gif` + - Montrer: chargement d’un PDF + preview. +- `docs/media/02-text-selection-redaction.gif` + - Montrer: sélection texte au curseur puis ajout de règle. +- `docs/media/03-draw-rectangle.gif` + - Montrer: bouton “Dessiner sélection”, click-glisser, apparition `Rectangle X (Page Y)`. +- `docs/media/04-full-page-redaction.gif` + - Montrer: “Censurer la page” + preview pleine page. +- `docs/media/05-audit-fail-example.png` + - Montrer: modal d’échec audit avec détails. +- `docs/media/06-audit-pass-download.png` + - Montrer: succès + téléchargement. + +Tu pourras ensuite ajouter des sections du type: + +```md +![Draw rectangle demo](docs/media/03-draw-rectangle.gif) +``` -## Security +--- -See [docs/SECURITY.md](docs/SECURITY.md). +## Roadmap / idées d’amélioration -## Fixtures +- Censure partielle **irréversible** d’image et de graphiques (sans supprimer l’objet entier) +- Import/export de listes de requêtes de censure (profils réutilisables) +- OCR avancé pour PDFs scannés +- Plus d’outils visuels (édition de rectangles, regroupement, templates) +- E2E tests de bout en bout sur workflows UI complets -See [backend/tests/fixtures/README.md](backend/tests/fixtures/README.md). +--- -## Testing +## Documentation complémentaire -See [docs/TESTING.md](docs/TESTING.md). +- Sécurité: [docs/SECURITY.md](docs/SECURITY.md) +- Tests: [docs/TESTING.md](docs/TESTING.md) +- Contribution: [CONTRIBUTING.md](CONTRIBUTING.md) +- Déploiement / packaging: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) +- Fixtures PDF de test: [backend/tests/fixtures/README.md](backend/tests/fixtures/README.md) 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/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..afeb7c1 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,124 @@ +# Deployment, Docker and Desktop Packaging (Plan) + +Ce document décrit: + +1. comment faire tourner RedactPDF avec Docker, +2. comment livrer une expérience “un clic” pour utilisateurs non techniques. + +--- + +## 1) Docker (backend + frontend) + +## Objectif +Avoir une commande qui démarre l’application complète localement, de manière reproductible. + +## Étapes recommandées + +1. **Dockerfile backend** + - Base Python slim + - Installation dépendances backend + - Exécution `uvicorn app.main:app --host 0.0.0.0 --port 8000` + +2. **Dockerfile frontend** + Deux approches: + - dev: Vite dev server (simple pour debug) + - prod: build static + serveur web (nginx/caddy) + +3. **Reverse proxy / routage API** + - Le frontend doit appeler le backend via un endpoint stable (`/api/...`) + - En prod, servir frontend + proxy API depuis un seul host/port est recommandé + +4. **docker-compose.yml** + - Service `backend` + - Service `frontend` + - Variables d’env + - Healthchecks + +5. **Volumes (optionnel)** + - Si besoin d’exports persistants ou logs partagés + +6. **Sécurité runtime minimale** + - user non-root dans les containers + - images pinées + - surface réseau minimale + +## Commandes cibles (exemple attendu) + +```bash +docker compose build +docker compose up -d +docker compose logs -f +``` + +Puis ouvrir l’URL affichée (ex: `http://localhost:8080`). + +--- + +## 2) Packaging “un seul fichier / un clic” + +## Objectif +Permettre à un utilisateur non technique de: +- double-cliquer, +- lancer backend + frontend, +- ouvrir automatiquement le navigateur sur la bonne URL. + +## Architecture recommandée + +### Option A (recommandée): Shell desktop léger + webview/browser launch + +- Un “launcher” desktop (Python/Go/Node) qui: + 1. vérifie ports disponibles, + 2. démarre backend local, + 3. sert frontend buildé (ou lance un mini serveur statique), + 4. ouvre le navigateur par défaut, + 5. surveille les process et les arrête proprement à la fermeture. + +### Option B: Application desktop embarquée (Electron / Tauri) + +- Embedding UI + process backend intégré. +- Plus lourd, mais UX plus contrôlée. + +## Outils packaging possibles + +- **Windows**: Inno Setup / NSIS / MSI +- **macOS**: `.app` + notarization +- **Linux**: AppImage / deb / rpm + +## Points à gérer impérativement + +1. **Cycle de vie process** + - start/stop propre backend+frontend +2. **Mises à jour** + - stratégie de versioning, migration +3. **Logs support** + - emplacement clair pour diagnostic +4. **Conflits de ports** + - fallback automatique (port alternatif) +5. **Durcissement local** + - bind localhost uniquement + +--- + +## 3) Validation minimale avant diffusion + +- Smoke tests: + - chargement PDF + - redaction requête + - redaction rectangle + - redaction page complète + - audit fail/pass +- Vérification export sur documents contenant: + - texte simple + - images + - vector graphics + - annotations / liens / pièces jointes + +--- + +## 4) Notes produit + +- Documenter clairement la différence entre: + - masquage visuel, + - suppression réelle, + - limites sur PDFs aplatis / scans. +- Prévoir un mode “strict sécurité” visible dans l’UX pour les cas sensibles. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index de634b6..3a7ee85 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,29 +1,50 @@ -# Security Model (Draft) +# Security Model ## Scope -This project provides a local PDF redaction tool intended to permanently remove sensitive content from PDF files while preserving normal selection/search for non-redacted text. + +RedactPDF is a local PDF redaction tool intended to produce a **new exported PDF** where targeted sensitive content is no longer recoverable through standard extraction paths. ## Security Invariants (Non-negotiable) -1. **Never modify the original file** - The tool must always export a new PDF and must not overwrite the input. -2. **No visual-only masking** - Overlays/black rectangles that merely cover content are not acceptable. Redaction must remove underlying data. +1. **Never modify the original file** + - Input PDF must remain untouched. +2. **No fake success** + - If post-export audit fails, export must be blocked. +3. **Prefer real removal over visual masking** + - Visual black overlays alone are not sufficient for sensitive workflows. +4. **Operator preview before apply** + - UI preview is required to reduce human targeting mistakes. + +## Current Guarantees (high level) + +- Export is done as a new file. +- Redaction is followed by automated audit checks. +- Backend supports strict options for images/graphics + sanitation. + +## Important Limitations + +1. **Flattened / scanned PDFs** + - Fine-grained redaction can be constrained. +2. **Partial irrecoverable image/vector editing** + - Not a fully general “pixel/segment-only rewrite” pipeline at this stage. +3. **OCR completeness** + - Not guaranteed for all scan/layout conditions. + +## Operational Recommendations + +For sensitive usage, prefer strict settings: -3. **Post-export verification is required** - The tool must run an automated audit after redaction (at minimum, text extraction + pattern checking). - If a forbidden pattern is still found, the tool must fail clearly and must not produce a “successful” output. +- remove touched images/graphics, +- sanitize metadata, +- remove annotations, +- remove attachments, +- verify audit output before sharing exported files. -4. **Preview before apply (UI requirement)** - The UI must provide a preview of the redaction areas before applying changes (to reduce operator errors). +## Reporting Security Issues -## Out of Scope / Limitations (for now) -- Perfect detection of sensitive data in all PDFs is not guaranteed. -- OCR on scanned PDFs is not in scope initially. -- Multi-line / complex-layout matching will be improved iteratively and must be covered by tests. +Please open a security issue with: -## Reporting -If you believe you found a redaction bypass or data leakage issue, please open a security issue with: -- a minimal reproduction PDF (if shareable), -- steps to reproduce, -- expected vs actual behavior. +- minimal reproduction document (if shareable), +- exact steps, +- expected vs actual behavior, +- platform/runtime info. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 941088b..765714c 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,34 @@ 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 hasRectangleRule = useMemo(() => rules.some((r) => r.kind === "rectangle"), [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 +118,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 +153,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 +165,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 +234,11 @@ export default function App() { rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, + applyImages: hasFullPageRule || hasRectangleRule, + applyGraphics: hasFullPageRule || hasRectangleRule, + sanitizeMetadata: true, + removeAnnotations: true, + removeAttachments: true, }); downloadBlob(r.pdfBlob, "redacted.pdf"); @@ -192,6 +274,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..fecc924 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -70,6 +70,11 @@ export async function redactApply(params: { rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; + applyImages?: boolean; + applyGraphics?: boolean; + sanitizeMetadata?: boolean; + removeAnnotations?: boolean; + removeAttachments?: boolean; }): Promise { const form = new FormData(); form.append("file", params.file); @@ -111,7 +116,13 @@ 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, + sanitize_metadata: !!params.sanitizeMetadata, + remove_annotations: !!params.removeAnnotations, + remove_attachments: !!params.removeAttachments, + }, // 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; };