diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d15d3..54ebfa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ### Added +- OCR d’identité contrôlé et révisable dans la création d’une personne, + l’import normal et la fiche de preuve, avec texte brut immuable, + transcription corrigée persistante, `manual_override`, `manual_entry` et + notes documentaires factuelles. +- Consultation complète des données OCR persistées, sélection explicite d’un + `OcrRun`, révision sans relance de Tesseract et création d’un nouveau run + lors d’une nouvelle analyse. +- Aperçu partagé avec zoom, ajustement, défilement bidirectionnel, navigation + PDF multipage, compteur de pages et provenance OCR synchronisée. +- Politique commune des dialogues GTK métiers complexes : parent transitoire, + géométrie responsive, répartition initiale 2/3–1/3, formulaire défilable et + barre d’actions fixe. - Préparation du pivot e-mail : pipeline EML asynchrone, extraction MIME sécurisée, propositions bancaires IBAN/BIC et vocabulaire contrôlé. @@ -23,6 +35,12 @@ ### Fixed +- Rafraîchissement immédiat de Workspace après import ou révision OCR et + persistance des données sur l’UUID définitif de la preuve, y compris après + fermeture et réouverture de SQLite. +- Fermeture sûre du dialogue après enregistrement d’une révision OCR, avec + protection contre le double clic, maintien ouvert en cas d’échec et absence + de relance de Tesseract. - Conservation du cadrage du graphe pendant les rechargements. - Persistance SQLite du zoom et de la position du graphe entre deux sessions. - Réduction de la fenêtre d’intégration des extractions pour maintenir les diff --git a/Makefile b/Makefile index c873c61..0ffc899 100644 --- a/Makefile +++ b/Makefile @@ -164,6 +164,7 @@ TEST_OCR_PROVENANCE_OVERLAY_GTK := tests/test_ocr_provenance_overlay_gtk TEST_EVIDENCE_METADATA_DIALOG_GTK := tests/test_evidence_metadata_dialog_gtk TEST_EVIDENCE_IDENTITY_IMPORT_GTK := tests/test_evidence_identity_import_gtk TEST_WORKSPACE_IDENTITY_OCR_GTK := tests/test_workspace_identity_ocr_gtk +TEST_DIALOG_GEOMETRY_GTK := tests/test_dialog_geometry_gtk FAKE_DOCUMENT_TOOL := tests/fake_document_tool DOCUMENT_ANALYSIS_TEST_SOURCES := \ @@ -178,6 +179,10 @@ $(TEST_DOCUMENT_TOOL_RUNNER): tests/test_document_tool_runner.c \ tests/test_document_tool_runner.c \ $(DOCUMENT_ANALYSIS_TEST_SOURCES) -o $@ $(TEST_LDFLAGS) +$(TEST_DIALOG_GEOMETRY_GTK): tests/test_dialog_geometry_gtk.c \ + src/views/dialog_geometry.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + all: $(TARGET) $(TEST_IDENTITY_OCR): tests/test_identity_ocr.c \ @@ -571,6 +576,7 @@ $(TEST_EVIDENCE_TYPE_DAO): \ $(TEST_EVIDENCE_IMPORT_DIALOG): \ tests/test_evidence_import_dialog.c \ src/views/evidence_import_dialog.c \ + src/views/dialog_geometry.c \ src/models/evidence_type.c \ src/widgets/evidence_preview_widget.c \ src/core/evidence_preview_task.c src/core/evidence_preview.c \ @@ -883,7 +889,8 @@ $(TEST_PERSON_DIALOG_LIFECYCLE): tests/test_person_dialog_lifecycle.c \ $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ - src/views/create_person_dialog.c src/core/person_dialog_lifecycle.c \ + src/views/create_person_dialog.c src/views/dialog_geometry.c \ + src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ src/core/evidence_staging.c src/core/evidence_staging_task.c \ src/core/evidence_preview_task.c src/core/evidence_preview.c \ @@ -906,7 +913,8 @@ $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ $(TEST_CREATE_PERSON_DIALOG_OCR_GTK): \ tests/test_create_person_dialog_ocr_gtk.c \ - src/views/create_person_dialog.c src/core/person_dialog_lifecycle.c \ + src/views/create_person_dialog.c src/views/dialog_geometry.c \ + src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ src/core/evidence_staging.c src/core/evidence_staging_task.c \ src/core/evidence_preview_task.c src/core/evidence_preview.c \ @@ -1122,7 +1130,8 @@ test: \ $(TEST_OCR_PROVENANCE_OVERLAY_GTK) \ $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \ $(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) \ - $(TEST_WORKSPACE_IDENTITY_OCR_GTK) + $(TEST_WORKSPACE_IDENTITY_OCR_GTK) \ + $(TEST_DIALOG_GEOMETRY_GTK) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -1216,6 +1225,7 @@ test: \ @$(TEST_EVIDENCE_METADATA_DIALOG_GTK) @$(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) @$(TEST_WORKSPACE_IDENTITY_OCR_GTK) + @$(TEST_DIALOG_GEOMETRY_GTK) @echo "Tous les tests sont valides." %.o: %.c @@ -1313,6 +1323,7 @@ clean: $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \ $(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) \ $(TEST_WORKSPACE_IDENTITY_OCR_GTK) \ + $(TEST_DIALOG_GEOMETRY_GTK) \ $(FAKE_DOCUMENT_TOOL) diff --git a/README.md b/README.md index 8f610fb..3b95cc5 100644 --- a/README.md +++ b/README.md @@ -123,20 +123,30 @@ Les outils externes sont lancés avec `GSubprocess` et des arguments séparés. ## Fonctionnalités déjà présentes -La création d’une personne propose une étape facultative d’OCR contrôlé des -documents d’identité. Elle ne démarre que sur action explicite, travaille sur -une copie vérifiée PNG, JPEG, HEIC, HEIF ou sur une page PDF choisie, et laisse -chaque champ à l’état « À vérifier ». L’acceptation, la modification ou le rejet -s’effectuent champ par champ, sans verdict d’authenticité, reconnaissance -faciale, fusion de personne ni changement automatique du statut -d’identification. +La création d’une personne et l’import normal d’une preuve proposent un OCR +contrôlé des documents d’identité. Il ne démarre que sur action explicite, +travaille sur une copie vérifiée PNG, JPEG, HEIC, HEIF ou sur une page PDF +choisie, et conserve séparément le texte OCR brut immuable et une transcription +corrigée rééditable et réinitialisable. Les propositions restent révisables : +elles peuvent être acceptées, corrigées à nouveau, restaurées depuis leur +valeur brute ou rejetées. Un champ visible mais omis par l’OCR peut être saisi +avec l’origine `manual_entry` ; une correction d’une valeur extraite conserve +l’origine `manual_override`. Des notes factuelles peuvent signaler un document +tronqué, flou, masqué ou incomplet, sans reconstruire une zone absente. -Ce parcours est également disponible lors de l’import normal d’une preuve, -avec sélection explicite ou présélection de la personne. Il traite une seule -preuve par opération. L’import multiple sans OCR reste disponible ; plusieurs -preuves doivent être OCRisées successivement, chacune dans son propre -parcours. Les dialogues concernés affichent la liste à gauche et un grand -aperçu redimensionnable à droite. +La fiche directe d’une preuve relit depuis SQLite le texte brut, la +transcription corrigée, les exécutions `OcrRun`, observations, champs, notes, +artefacts, empreintes SHA-256, personne liée et provenance graphique. Un run +est sélectionné explicitement lorsqu’un historique en contient plusieurs. +« Réviser l’analyse OCR » modifie uniquement ce run sans relancer Tesseract ; +« Relancer une nouvelle analyse » crée un nouveau run sans écraser +l’historique. + +L’import multiple sans OCR reste disponible. L’OCR groupé n’est pas pris en +charge : les preuves importées ensemble sont ensuite analysées une par une +depuis leur fiche, sur leur UUID définitif et sans doublon. Aucun de ces +parcours ne produit de verdict d’authenticité, de reconnaissance faciale, de +fusion de personne, d’identité certaine ou de rôle d’auteur automatique. Le socle actuel comprend notamment : @@ -212,8 +222,18 @@ Le socle actuel comprend notamment : définitifs qu’après confirmation globale ; - rattachement transactionnel de toutes les preuves retenues, avec rollback SQLite et suppression compensatoire des copies définitives en cas d’échec. -- aperçu asynchrone contrôlé PNG/JPEG, HEIC/HEIF, MP4/MOV, texte, EML et - première page PDF, sans OCR ni écriture SQLite ; +- aperçu asynchrone contrôlé PNG/JPEG, HEIC/HEIF, MP4/MOV, texte, EML et PDF + multipage, avec zoom de 25 à 400 %, ajustement, défilements horizontal et + vertical, navigation et compteur de pages ; la provenance OCR reste alignée + avec la page, le zoom et le défilement ; + +Les dialogues métiers complexes suivent une politique GTK commune : parent +réel via `transient_for`, modalité adaptée et présentation sans coordonnées +absolues sous Wayland. Ils visent 1200 × 800, avec un minimum utile de +800 × 600 lorsque la zone de travail le permet, un formulaire défilable à +gauche sur environ deux tiers, un aperçu redimensionnable à droite et une +barre d’actions fixe. Les alertes simples, popups `GtkDropDown` et sélecteurs +de fichiers natifs ne sont pas concernés. ### Pivot EML @@ -390,6 +410,21 @@ Les nouveaux modules doivent être accompagnés de tests couvrant : - les responsabilités mémoire ; - les régressions possibles. +Les parcours GTK OCR et aperçu doivent aussi être exécutés séparément sur un +poste avec affichage : + +```bash +G_DEBUG=fatal-criticals timeout 30s ./tests/test_create_person_dialog_ocr_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_evidence_identity_import_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_workspace_identity_ocr_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_evidence_preview_widget_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_dialog_geometry_gtk +``` + +Les tests emploient uniquement des documents `SPECIMEN`, des fichiers +temporaires et des bases SQLite temporaires fermées puis rouvertes. Un `SKIP` +lié à l’absence d’affichage ne remplace pas cette validation GTK réelle. + --- ## Développement diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9b16331..73f30ac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,28 +1,36 @@ # Architecture -`EvidencePreviewWidget` est l’adaptateur GTK unique de `EvidencePreview` pour -`CreatePersonDialog` et la fiche directe. Il encapsule les états, le rendu -EML/PDF/image/texte/vidéo, l’annulation, la génération, la garde de session et -l’arrêt du média. Les deux écrans lui transmettent uniquement une source -interne ou de staging accompagnée du SHA-256 attendu. +`EvidencePreviewWidget` est l’adaptateur GTK partagé de `EvidencePreview` pour +`CreatePersonDialog`, l’import, la révision OCR et la fiche directe. Il +encapsule les états, le rendu EML/PDF/image/texte/vidéo, l’annulation, la +génération, la garde de session et l’arrêt du média. Les écrans lui +transmettent uniquement une source interne ou de staging accompagnée du +SHA-256 attendu. ## Aperçu contrôlé multi-format `EvidencePreview` vérifie l’empreinte du fichier interne ou de staging, détecte son contenu et produit un résultat borné sans GTK. `BackgroundTask` -travaille hors du thread principal ; `CreatePersonDialog` y crée ensuite -textures, buffers et médias, rejette les générations périmées et arrête tout +travaille hors du thread principal ; le widget crée ensuite textures, buffers +et médias sur le contexte GTK, rejette les générations périmées et arrête tout média au changement ou à la fermeture. L’aperçu ne persiste rien. +Pour les images et PDF, la barre compacte fournit zoom avant, zoom arrière et +retour à l’ajustement. Une image agrandie reste défilable horizontalement et +verticalement. Les PDF multipages disposent des actions page précédente et +suivante et d’un compteur `Page X / N` ; le changement de page conserve le +zoom. `OcrProvenanceOverlay` applique le même zoom, le même défilement et la +même page afin que la provenance reste alignée. + Le mode aperçu de `EmlMimeExtractor` réutilise le parcours récursif et les décodages RFC 2047/2231 sans écrire les pièces jointes. Il préfère `text/plain`, transforme un éventuel HTML en texte inerte et retourne l’inventaire possédé. Le contrôleur vidéo indépendant de GTK orchestre pause, retour au début, détachement et libération via des actions injectées. -> **Version :** 3.1 -> **Dernière mise à jour :** 2026-07-28 -> **Schéma SQLite courant :** V14 +> **Version :** 3.2 +> **Dernière mise à jour :** 2026-07-30 +> **Schéma SQLite courant :** V17 ## Personnes contextuelles — SQLite V14 @@ -33,32 +41,64 @@ confiance et notes. Les anciens codes sont copiés littéralement depuis les rôles et le rattachement manuel dans une transaction unique. Le cœur `EvidencePreview` vérifie l’intégrité avant décodage PNG/JPEG, applique -des bornes et ne renvoie qu’un PNG mémoire réduit. Une `BackgroundTask` +des bornes et ne renvoie qu’un rendu mémoire réduit. Une `BackgroundTask` travaille hors du thread GTK ; la texture est créée sur le contexte principal. Le dialogue annule l’ancienne tâche et rejette les générations obsolètes. L’interface conserve une génération de session et les chemins stables du -projet et de sa base. PDF, vidéo, EML avancé et OCR restent exclus. +projet et de sa base. -## OCR contrôlé d’identité — SQLite V15 +## OCR contrôlé d’identité — SQLite V15 à V17 -La V15 sépare l’exécution OCR, l’observation du document et les observations -de champs. Le moteur reçoit uniquement une copie contrôlée ou une page PDF -explicitement choisie, conserve texte brut, TSV, paramètres, langues, version, -SHA-256, confiance et coordonnées, puis laisse la révision à l’utilisateur. -Les widgets n’accèdent pas à SQLite : le coordinateur persiste les décisions -acceptées ou modifiées dans la transaction finale et compense les fichiers en -cas d’échec. +`IdentityOcrWorkflow` est l’unique orchestration du moteur OCR, réutilisée par +`CreatePersonDialog`, `EvidenceIdentityOcrDialog`, l’import normal et +Workspace. La V15 sépare l’exécution OCR, l’observation du document et les +observations de champs. La V16 ajoute notamment l’origine `manual_entry` pour +un champ visible mais omis par l’OCR. La V17 ajoute une transcription corrigée +humaine distincte du texte OCR brut. + +Le moteur reçoit uniquement une copie contrôlée ou une page PDF explicitement +choisie, conserve texte brut, TSV, paramètres, langues, version, SHA-256, +confiance et coordonnées, puis laisse la révision à l’utilisateur. Le texte +brut n’est jamais remplacé. Une correction de valeur garde +`manual_override`; une saisie sans valeur OCR garde `manual_entry`. Les notes +restent factuelles et ne reconstruisent jamais une partie absente d’un +document tronqué. + +Les widgets n’accèdent pas à SQLite. Le coordinateur persiste sur l’UUID +définitif de la preuve dans une transaction unique, avec rollback, +compensation des fichiers et garde de session juste avant commit. L’import +multiple reste sans OCR groupé : chaque preuve est analysée ensuite, +individuellement et sans réimport ni doublon. La provenance visuelle est rendue par `OcrProvenanceOverlay`. La conversion des coordonnées source vers la zone affichée est indépendante de GTK et tient compte du ratio, de la réduction, de l’agrandissement et des marges. Le rectangle est transitoire : aucune annotation n’est écrite dans la preuve. Les langues proposées proviennent exclusivement de `tesseract --list-langs`. -Le DAO V15 expose des lectures possédées pour les exécutions, observations de -documents et observations de champs, ainsi que des listes ordonnées par preuve, -personne ou observation documentaire. +Les DAO structurés exposent des lectures possédées pour les runs, observations +de documents et de champs, notes et artefacts. La fiche de preuve recharge ces +données depuis SQLite, sélectionne explicitement un run dans un modèle +`GtkDropDown` stable et affiche texte brut, transcription corrigée, personne +liée, SHA-256 et provenance graphique. Réviser conserve l’UUID du run et ne +relance pas Tesseract ; relancer crée un nouveau run sans écraser +l’historique. > **Statut :** architecture courante +## Politique des dialogues GTK métiers + +`labfy_dialog_prepare()` et `labfy_dialog_present()` centralisent la séquence +des dialogues complexes : `transient_for` vers la vraie fenêtre parente, +modalité appropriée, géométrie commune, puis `gtk_window_present()`. Aucune +coordonnée absolue n’est utilisée sous GTK4/Wayland. + +La cible initiale est proche de 1200 × 800, avec un minimum utile de +800 × 600 lorsque la zone de travail le permet. Un `GtkPaned` place +initialement le formulaire défilable à gauche sur environ deux tiers et +l’aperçu à droite sur un tiers ; sa position n’est appliquée qu’après la +première allocation réelle puis reste entièrement modifiable. Les actions +restent fixes en bas. Cette politique exclut les alertes simples, les popups +`GtkDropDown` et les sélecteurs de fichiers natifs. + --- ## 1. Objectif @@ -355,7 +395,7 @@ validation du chemin ↓ création de l'arborescence ↓ -initialisation transactionnelle de SQLite V14 +initialisation transactionnelle de SQLite V17 ↓ création de l'identité de l'enquête ↓ @@ -460,9 +500,9 @@ sont supprimées après rollback. `PersonCreationTask` exécute cette orchestrat hors du thread GTK. Un changement de session annule les tâches et interdit le rafraîchissement d’une autre enquête. -Les aperçus PNG/JPEG utilisent exclusivement le fichier interne d’une preuve -existante ou sa copie de staging. PDF, vidéo, EML et autres formats ne sont -pas analysés dans cette tranche. +Les aperçus utilisent exclusivement le fichier interne d’une preuve existante +ou sa copie de staging. L’OCR d’identité n’est lancé que sur demande explicite +et sur un format compatible contrôlé. Les tâches longues utilisent l'infrastructure de tâche d'arrière-plan et le gestionnaire de tâches. @@ -791,6 +831,14 @@ Les scénarios critiques incluent : - conservation de la valeur brute ; - absence d'une dépendance optionnelle. +Les parcours OCR ferment puis rouvrent leurs bases SQLite temporaires afin de +vérifier la persistance du texte brut, de la transcription corrigée, des +champs et de l’historique multi-run. Les tests GTK réels passent par +`MainWindow`, `Workspace` et les contrôles de production, avec des fixtures +`SPECIMEN`, `G_DEBUG=fatal-criticals` et un timeout. Ils couvrent aussi +l’aperçu PNG/JPEG/PDF multipage, la provenance, la géométrie, l’annulation, +les résultats tardifs, les erreurs de transaction et les cycles de vie. + Validation : ```sh diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e6ea4c3..cd2465a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,17 +1,20 @@ # Guide de développement -Pour la tranche OCR identité, les tests ciblés sont -`tests/test_identity_ocr`, `tests/test_identity_ocr_preprocessor`, +Pour l’OCR d’identité, les tests ciblés sont `tests/test_identity_ocr`, +`tests/test_identity_ocr_preprocessor`, `tests/test_person_creation_coordinator`, -`tests/test_ocr_provenance_overlay_gtk` et -`tests/test_create_person_dialog_gtk`. Le test Tesseract réel est facultatif +`tests/test_create_person_dialog_ocr_gtk`, +`tests/test_evidence_identity_import_gtk`, +`tests/test_evidence_metadata_dialog_gtk`, +`tests/test_workspace_identity_ocr_gtk` et +`tests/test_ocr_provenance_overlay_gtk`. Le test Tesseract réel est facultatif et s’ignore explicitement si l’outil ou une langue compatible manque. Les fixtures directes du préprocesseur sont générées en mémoire ou dans un répertoire temporaire : JPEG avec APP1 EXIF, HEIC/HEIF via libheif et PDF multipage via Cairo. Elles ne doivent jamais être remplacées par un document réel. -La validation ciblée du composant partagé comprend +La validation ciblée de l’aperçu partagé comprend `test_evidence_preview_widget_gtk`, `test_evidence_preview`, `test_evidence_video_preview_controller` et `test_create_person_dialog_gtk`, ce dernier sous @@ -27,8 +30,8 @@ conteneur synthétique. Les transformations d’orientation libheif restent appliquées par le décodage standard ; seule l’image principale est rendue et le nombre d’images de premier niveau est exposé. -> **Version :** 2.1 -> **Dernière mise à jour :** 2026-07-28 +> **Version :** 2.2 +> **Dernière mise à jour :** 2026-07-30 > **Projet :** Labfy Investigation --- @@ -282,6 +285,30 @@ make -j8 \ Ces tests utilisent uniquement des fichiers `SPECIMEN`, des répertoires temporaires et des bases SQLite temporaires. +Tests GTK réels de l’OCR, de l’aperçu et de la géométrie : + +```sh +make -j8 \ + tests/test_create_person_dialog_ocr_gtk \ + tests/test_evidence_identity_import_gtk \ + tests/test_evidence_metadata_dialog_gtk \ + tests/test_workspace_identity_ocr_gtk \ + tests/test_evidence_preview_widget_gtk \ + tests/test_dialog_geometry_gtk + +G_DEBUG=fatal-criticals timeout 30s ./tests/test_create_person_dialog_ocr_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_evidence_identity_import_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_evidence_metadata_dialog_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_workspace_identity_ocr_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_evidence_preview_widget_gtk +G_DEBUG=fatal-criticals timeout 30s ./tests/test_dialog_geometry_gtk +``` + +Ils doivent cliquer sur les contrôles de production, passer par `MainWindow` +et `Workspace` lorsque le scénario le demande, puis fermer et rouvrir la base +SQLite temporaire. Un environnement sans affichage peut produire un `SKIP`, +mais celui-ci ne remplace pas l’exécution séparée sur le poste GTK réel. + La fixture manuelle est `tests/fixtures/eml/manual_smoke_test.eml`. Elle est exclusivement synthétique. Pour la validation GTK, créer une enquête neuve dans un @@ -541,6 +568,15 @@ Un dialogue : - ne réalise pas une opération longue directement ; - présente un résumé avant une écriture importante. +Les dialogues métiers complexes utilisent `labfy_dialog_prepare()` puis +`labfy_dialog_present()`. La vraie fenêtre parente est définie avec +`transient_for`; aucune coordonnée absolue n’est utilisée. Ils visent +1200 × 800 et un minimum utile de 800 × 600, avec formulaire défilable à +gauche sur environ deux tiers, aperçu à droite et barre d’actions fixe. Le +séparateur reste déplaçable et n’est pas réinitialisé après sa première +allocation. Cette règle ne concerne pas les alertes, popups `GtkDropDown` ou +sélecteurs natifs. + ### 11.3 Messages utilisateur Les détails techniques restent dans les journaux. @@ -663,3 +699,24 @@ Avant de considérer une modification terminée : - [ ] `make -j8 test` passe ; - [ ] `git diff --check` passe ; - [ ] la documentation est à jour. + +## 16. Validation manuelle du ticket #109 + +Utiliser exclusivement une enquête, une base SQLite et des documents +`SPECIMEN` temporaires. Vérifier au minimum : + +1. création d’une personne avec OCR, correction de transcription, réédition + d’un champ, `manual_entry`, notes et confirmation ; +2. visibilité immédiate de la preuve dans Workspace, puis fermeture et + réouverture de SQLite ; +3. import multiple sans OCR, puis analyse individuelle de chaque preuve depuis + sa fiche, sans doublon ; +4. sélection de plusieurs `OcrRun`, révision du seul run choisi sans + Tesseract, puis nouvelle analyse créant un run supplémentaire ; +5. aperçu PNG, JPEG et PDF `SPECIMEN` multipage : zoom, ajustement, + défilements, navigation, compteur et provenance ; +6. dialogues à 1200 × 800 et 760 × 560 : parent transitoire, formulaire + défilable, répartition 2/3–1/3 et actions toujours visibles. + +La validation manuelle réussie confirme ces parcours, mais ne clôt pas le +ticket #109 et ne couvre pas les limitations encore listées dans la roadmap. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6a62297..d2fedb6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,34 +1,23 @@ # Roadmap -La tranche 3 du ticket #109 ajoute l’OCR contrôlé des documents d’identité dans -l’assistant de création : lancement explicite, staging, révision champ par -champ et persistance transactionnelle V15. Elle ne clôt pas le ticket #109 et -n’engage aucune tranche suivante. -La provenance graphique, les langues Tesseract dynamiques et la compensation -complète des répertoires OCR font partie de cette même tranche. -La lecture structurée V15 et les scénarios de fermeture/réouverture restent -également circonscrits à la tranche 3. -L’OCR contrôlé est limité à une preuve par opération dans l’import normal. -L’import multiple sans OCR reste disponible. L’OCR multiple constitue un -chantier futur distinct ; les preuves sont actuellement analysées -successivement afin de conserver une association non ambiguë. +Le ticket #109 reste en cours. Ses tranches livrées couvrent l’OCR contrôlé et +révisable dans la création d’une personne, l’import normal et la fiche de +preuve, la transcription corrigée persistante, l’historique multi-run, la +provenance graphique, ainsi que l’aperçu avancé partagé. L’import multiple +reste sans OCR groupé : chaque preuve est analysée individuellement après +import afin de préserver une association non ambiguë. -La tranche 2 partage désormais l’aperçu entre l’assistant personne et la -fiche directe. La validation manuelle des codecs installés reste nécessaire ; -elle ne vaut pas achèvement du ticket #109 et n’inclut aucun OCR. +L’aperçu couvre PNG/JPEG, HEIC/HEIF, MP4/MOV, texte, EML passif et PDF +multipage, avec zoom, ajustement, défilements, navigation et compteur. La +politique commune des dialogues métiers fournit une géométrie responsive, un +formulaire défilable, un aperçu redimensionnable et des actions fixes. -L’aperçu contrôlé du ticket #109 couvre PNG/JPEG, HEIC/HEIF, MP4/MOV, texte, -EML passif et première page PDF. OCR et analyse avancée restent hors périmètre. +La validation manuelle de ces parcours est réussie. Elle ne vaut pas +achèvement du ticket #109. -> **Dernière mise à jour :** 2026-07-28 +> **Dernière mise à jour :** 2026-07-30 > **État du projet :** développement actif -> **Schéma SQLite courant :** V14 - -Les tranches 1 et 2 du ticket #109 couvrent les rôles contextuels multiples, -la sélection de plusieurs preuves existantes, le staging et l’import multiple -différé, ainsi que la création transactionnelle avec compensation des -fichiers. OCR, extraction de documents d’identité et analyses documentaires -avancées restent hors périmètre. +> **Schéma SQLite courant :** V17 > **Usage opérationnel :** non prêt pour la production --- @@ -142,7 +131,7 @@ La branche `main` contient notamment : - création, validation et ouverture d'enquêtes ; - session d'enquête remplaçable proprement ; -- infrastructure SQLite et migrations jusqu'à V14 ; +- infrastructure SQLite et migrations jusqu'à V17 ; - couche Database, DAO et services métier ; - import de preuves avec copie contrôlée et SHA-256 ; - vérification d'intégrité et reclassement des preuves ; @@ -170,6 +159,40 @@ Cette liste est une synthèse et non un contrat de stabilité. ## 5. Chantier actif +### Ticket #109 — Personnes, preuves et OCR d’identité + +Statut technique : + +```text +PARTIEL — VALIDATION MANUELLE DES TRANCHES LIVRÉES RÉUSSIE +``` + +Éléments livrés : + +- sélection de preuves existantes, staging et import multiple différé ; +- OCR contrôlé, correction rééditable et transcription corrigée distincte ; +- persistance transactionnelle sur l’UUID définitif avec historique multi-run ; +- consultation, révision sans Tesseract et nouvelle analyse depuis la fiche ; +- provenance graphique et aperçu partagé avec PDF multipage ; +- politique commune de géométrie des dialogues GTK métiers. + +Limitations restantes : + +1. l’OCR groupé de plusieurs preuves dans une même opération n’est pas pris en + charge ; +2. le statut contrôlé d’authenticité du document et la justification + obligatoire des statuts affirmatifs ne sont pas implémentés ; +3. la relation factuelle typée entre personne et preuve et certains états + d’identification demandés restent incomplets ; +4. une partie du vocabulaire des rôles demeure codée en dur ; +5. la provenance uniforme de tous les dérivés et la projection des seules + valeurs OCR confirmées vers les attributs structurés de la personne restent + partielles ; +6. les tests négatifs garantissant l’absence de relation automatique + attribuant l’identité ou le rôle d’auteur doivent encore être renforcés. + +Ces limites interdisent de présenter le ticket #109 comme terminé. + ### Ticket #107 — Pivot e-mail forensique Statut technique : diff --git a/include/core/evidence_preview.h b/include/core/evidence_preview.h index f13075e..602e44a 100644 --- a/include/core/evidence_preview.h +++ b/include/core/evidence_preview.h @@ -24,6 +24,7 @@ typedef struct { char *expected_sha256; char *mime_type; guint64 request_generation; + guint pdf_page; } EvidencePreviewRequest; typedef struct { EvidencePreviewKind kind; @@ -40,6 +41,7 @@ typedef struct { char *message; guint64 size_bytes; guint item_count; + guint current_page; gboolean integrity_valid; gboolean preview_available; gboolean truncated; @@ -47,6 +49,8 @@ typedef struct { EvidencePreviewRequest *evidence_preview_request_new(const char *root, const char *evidence_identifier, const char *relative_path, const char *sha256, const char *mime_type, guint64 generation); +void evidence_preview_request_set_pdf_page( + EvidencePreviewRequest *request, guint page); void evidence_preview_request_free(EvidencePreviewRequest *request); EvidencePreviewResult *evidence_preview_load( const EvidencePreviewRequest *request, GCancellable *cancellable, diff --git a/include/views/dialog_geometry.h b/include/views/dialog_geometry.h new file mode 100644 index 0000000..0d6138d --- /dev/null +++ b/include/views/dialog_geometry.h @@ -0,0 +1,30 @@ +#ifndef LABFY_DIALOG_GEOMETRY_H +#define LABFY_DIALOG_GEOMETRY_H + +#include + +typedef struct { + int initial_width; + int initial_height; + int minimum_width; + int minimum_height; +} LabfyDialogGeometry; + +LabfyDialogGeometry labfy_dialog_geometry_calculate( + int workarea_width, int workarea_height); + +void labfy_dialog_apply_standard_geometry( + GtkWindow *window, GtkWindow *parent); + +void labfy_dialog_prepare( + GtkWindow *window, + GtkWindow *parent, + gboolean modal, + gboolean destroy_with_parent); + +void labfy_dialog_present(GtkWindow *window); + +void labfy_paned_apply_initial_ratio( + GtkPaned *paned, double ratio, int minimum_start, int minimum_end); + +#endif diff --git a/include/widgets/evidence_preview_widget.h b/include/widgets/evidence_preview_widget.h index a436bf8..5852a18 100644 --- a/include/widgets/evidence_preview_widget.h +++ b/include/widgets/evidence_preview_widget.h @@ -25,6 +25,19 @@ const char *evidence_preview_widget_get_state( const EvidencePreviewWidget *widget); char *evidence_preview_widget_dup_text( const EvidencePreviewWidget *widget); +void evidence_preview_widget_zoom_in(EvidencePreviewWidget *widget); +void evidence_preview_widget_zoom_out(EvidencePreviewWidget *widget); +void evidence_preview_widget_fit(EvidencePreviewWidget *widget); +double evidence_preview_widget_get_zoom( + const EvidencePreviewWidget *widget); +gboolean evidence_preview_widget_is_fit( + const EvidencePreviewWidget *widget); +gboolean evidence_preview_widget_set_pdf_page( + EvidencePreviewWidget *widget, guint page); +guint evidence_preview_widget_get_pdf_page( + const EvidencePreviewWidget *widget); +guint evidence_preview_widget_get_pdf_page_count( + const EvidencePreviewWidget *widget); G_END_DECLS #endif diff --git a/include/widgets/ocr_provenance_overlay.h b/include/widgets/ocr_provenance_overlay.h index acd801e..f7756f5 100644 --- a/include/widgets/ocr_provenance_overlay.h +++ b/include/widgets/ocr_provenance_overlay.h @@ -15,6 +15,13 @@ void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay, const IdentityFieldObservation *field, guint64 generation); void ocr_provenance_overlay_clear(OcrProvenanceOverlay *overlay, guint64 generation); +void ocr_provenance_overlay_set_page( + OcrProvenanceOverlay *overlay, guint page, guint64 generation); +void ocr_provenance_overlay_zoom_in(OcrProvenanceOverlay *overlay); +void ocr_provenance_overlay_zoom_out(OcrProvenanceOverlay *overlay); +void ocr_provenance_overlay_fit(OcrProvenanceOverlay *overlay); +double ocr_provenance_overlay_get_zoom( + const OcrProvenanceOverlay *overlay); gboolean ocr_provenance_overlay_has_region( const OcrProvenanceOverlay *overlay); diff --git a/src/core/evidence_preview.c b/src/core/evidence_preview.c index 26ce5fd..9ab84f4 100644 --- a/src/core/evidence_preview.c +++ b/src/core/evidence_preview.c @@ -299,8 +299,8 @@ failed: if (handle != NULL) heif_image_handle_release(handle); heif_context_free(context); return FALSE; } -static gboolean load_pdf(const char *path, EvidencePreviewResult *result, - GError **error) +static gboolean load_pdf(const char *path, guint requested_page, + EvidencePreviewResult *result, GError **error) { char *uri = g_filename_to_uri(path, NULL, error); if (uri == NULL) return FALSE; @@ -309,7 +309,21 @@ static gboolean load_pdf(const char *path, EvidencePreviewResult *result, gint pages = poppler_document_get_n_pages(document); if (pages < 1) { g_object_unref(document); g_set_error_literal(error, preview_error(), 7, "Le PDF ne contient aucune page."); return FALSE; } - PopplerPage *page = poppler_document_get_page(document, 0); + if (requested_page >= (guint) pages) { + g_object_unref(document); + g_set_error(error, preview_error(), 8, + "La page PDF %u est hors limites (document de %d pages).", + requested_page + 1U, pages); + return FALSE; + } + PopplerPage *page = poppler_document_get_page( + document, (gint) requested_page); + if (page == NULL) { + g_object_unref(document); + g_set_error_literal(error, preview_error(), 7, + "La page PDF demandée est indisponible."); + return FALSE; + } double page_width = 0, page_height = 0; poppler_page_get_size(page, &page_width, &page_height); if (!valid_dimensions((gint) page_width, (gint) page_height, error)) { @@ -324,6 +338,7 @@ static gboolean load_pdf(const char *path, EvidencePreviewResult *result, cairo_t *cr = cairo_create(surface); cairo_set_source_rgb(cr, 1, 1, 1); cairo_paint(cr); cairo_scale(cr, scale, scale); poppler_page_render(page, cr); cairo_destroy(cr); result->item_count = (guint) pages; + result->current_page = requested_page + 1U; gboolean ok = surface_to_result(surface, width, height, result, error); cairo_surface_destroy(surface); g_object_unref(page); g_object_unref(document); return ok; @@ -396,6 +411,12 @@ EvidencePreviewRequest *evidence_preview_request_new(const char *root, request->mime_type = g_strdup(mime_type); request->request_generation = generation; return request; } + +void evidence_preview_request_set_pdf_page( + EvidencePreviewRequest *request, guint page) +{ + if (request != NULL) request->pdf_page = page; +} void evidence_preview_request_free(EvidencePreviewRequest *request) { if (request == NULL) return; @@ -501,7 +522,7 @@ EvidencePreviewResult *evidence_preview_load( result->kind = EVIDENCE_PREVIEW_KIND_PDF; result->effective_mime_type = g_strdup("application/pdf"); result->effective_format = g_strdup("PDF"); - ok = load_pdf(path, result, error); break; + ok = load_pdf(path, request->pdf_page, result, error); break; default: result->message = g_strdup("Format non pris en charge ou contenu invalide."); result->effective_format = g_strdup("Inconnu"); break; diff --git a/src/core/evidence_preview_task.c b/src/core/evidence_preview_task.c index a414591..559a85c 100644 --- a/src/core/evidence_preview_task.c +++ b/src/core/evidence_preview_task.c @@ -2,9 +2,12 @@ static EvidencePreviewRequest *copy_request(const EvidencePreviewRequest *r) { - return evidence_preview_request_new(r->investigation_root_path, + EvidencePreviewRequest *copy = evidence_preview_request_new( + r->investigation_root_path, r->evidence_identifier, r->relative_path, r->expected_sha256, r->mime_type, r->request_generation); + evidence_preview_request_set_pdf_page(copy, r->pdf_page); + return copy; } static gboolean worker(BackgroundTask *task, GCancellable *cancellable, gpointer data, gpointer *result, GError **error) diff --git a/src/core/identity_ocr_workflow.c b/src/core/identity_ocr_workflow.c index f799d2f..11f89a7 100644 --- a/src/core/identity_ocr_workflow.c +++ b/src/core/identity_ocr_workflow.c @@ -4,22 +4,81 @@ #include "core/identity_field_extractor.h" #include "core/ocr_analysis.h" +#include + +static gboolean identity_ocr_workflow_languages_are_valid( + const char *languages) +{ + char **codes; + gboolean valid = TRUE; + + if (languages == NULL || languages[0] == '\0') + return FALSE; + codes = g_strsplit(languages, "+", -1); + for (guint index = 0; codes[index] != NULL && valid; index++) { + const char *code = codes[index]; + + valid = strlen(code) == 3; + for (guint character = 0; valid && code[character] != '\0'; + character++) + valid = g_ascii_islower(code[character]); + } + g_strfreev(codes); + return valid; +} + +static gboolean identity_ocr_workflow_validate_request( + const IdentityOcrWorkflowRequest *request, + GError **error) +{ +#define REQUIRE_REQUEST_FIELD(condition, message) \ + G_STMT_START { \ + if (!(condition)) { \ + g_set_error_literal(error, G_IO_ERROR, \ + G_IO_ERROR_INVALID_ARGUMENT, (message)); \ + return FALSE; \ + } \ + } G_STMT_END + + REQUIRE_REQUEST_FIELD(request != NULL, + "La demande OCR d’identité est absente."); + REQUIRE_REQUEST_FIELD(request->root_path != NULL, + "Le chemin racine de l’enquête est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD(request->evidence_identifier != NULL, + "L’UUID de la preuve est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD(request->relative_path != NULL, + "Le chemin relatif de la preuve est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD(request->expected_sha256 != NULL, + "Le SHA-256 attendu est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD(request->mime_type != NULL, + "Le type MIME de la preuve est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD( + identity_ocr_document_type_is_valid(request->document_type), + "Le type de document d’identité est invalide dans la demande OCR."); + REQUIRE_REQUEST_FIELD( + identity_ocr_document_side_is_valid(request->document_side), + "La face du document d’identité est invalide dans la demande OCR."); + REQUIRE_REQUEST_FIELD( + identity_ocr_workflow_languages_are_valid(request->languages), + "Le code de langue Tesseract est invalide dans la demande OCR " + "(un code ISO 639-3 comme « fra » ou « eng » est requis)."); + REQUIRE_REQUEST_FIELD(request->preprocessing_profile != NULL, + "Le profil de prétraitement est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD(request->tesseract_executable != NULL, + "Le chemin de l’exécutable Tesseract est absent de la demande OCR."); + REQUIRE_REQUEST_FIELD(request->page_number > 0, + "Le numéro de page est invalide dans la demande OCR."); + REQUIRE_REQUEST_FIELD(request->profile <= IDENTITY_OCR_PREPROCESS_UPSCALE, + "Le profil de prétraitement est invalide dans la demande OCR."); + +#undef REQUIRE_REQUEST_FIELD + return TRUE; +} + gboolean identity_ocr_workflow_request_is_valid( const IdentityOcrWorkflowRequest *request) { - return request != NULL && - request->root_path != NULL && - request->evidence_identifier != NULL && - request->relative_path != NULL && - request->expected_sha256 != NULL && - request->mime_type != NULL && - identity_ocr_document_type_is_valid(request->document_type) && - identity_ocr_document_side_is_valid(request->document_side) && - request->languages != NULL && - request->preprocessing_profile != NULL && - request->tesseract_executable != NULL && - request->page_number > 0 && - request->profile <= IDENTITY_OCR_PREPROCESS_UPSCALE; + return identity_ocr_workflow_validate_request(request, NULL); } IdentityOcrRun *identity_ocr_workflow_execute( @@ -37,11 +96,8 @@ IdentityOcrRun *identity_ocr_workflow_execute( gsize preview_length = 0; g_return_val_if_fail(error == NULL || *error == NULL, NULL); - if (!identity_ocr_workflow_request_is_valid(request)) { - g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, - "La demande du parcours OCR d’identité est invalide."); + if (!identity_ocr_workflow_validate_request(request, error)) return NULL; - } preview_request = evidence_preview_request_new(request->root_path, request->evidence_identifier, request->relative_path, request->expected_sha256, request->mime_type, request->generation); diff --git a/src/views/create_person_dialog.c b/src/views/create_person_dialog.c index 0f72ef5..7741437 100644 --- a/src/views/create_person_dialog.c +++ b/src/views/create_person_dialog.c @@ -3,6 +3,7 @@ * @brief Formulaire GTK de création d'une personne observée. ******************************************************************************/ #include "views/create_person_dialog.h" +#include "views/dialog_geometry.h" #include "core/evidence_staging.h" #include "core/evidence_staging_task.h" #include "core/person_confirmation_summary.h" @@ -49,6 +50,7 @@ typedef struct EvidencePreviewWidget *preview; GtkLabel *progress; GtkLabel *summary; + GtkScrolledWindow *summary_scroll; GtkStack *stack; GtkButton *previous; GtkButton *next; @@ -132,25 +134,6 @@ static void create_person_dialog_on_retained_changed( static void create_person_dialog_render_ocr_fields( CreatePersonDialogState *state, IdentityOcrRun *run); -static gboolean create_person_dialog_set_initial_ocr_paned_position( - gpointer data) -{ - GtkPaned *paned = GTK_PANED(data); - int width; - int position; - if (g_object_get_data( - G_OBJECT(paned), "create-person-ocr-position-set") != NULL) - return G_SOURCE_REMOVE; - width = gtk_widget_get_width(GTK_WIDGET(paned)); - if (width <= 0) return G_SOURCE_CONTINUE; - position = MAX(500, (width * 2) / 3); - position = MIN(position, MAX(0, width - 180)); - gtk_paned_set_position(paned, position); - g_object_set_data(G_OBJECT(paned), - "create-person-ocr-position-set", GINT_TO_POINTER(1)); - return G_SOURCE_REMOVE; -} - static void identity_language_job_free(gpointer data) { IdentityLanguageJob *job = data; @@ -187,6 +170,53 @@ static const char *identity_language_label(const char *code) return code; } +static guint identity_default_language_index(const GPtrArray *codes) +{ + guint english_index = GTK_INVALID_LIST_POSITION; + + for (guint index = 0; codes != NULL && index < codes->len; index++) { + const char *code = g_ptr_array_index((GPtrArray *) codes, index); + + if (g_strcmp0(code, "fra") == 0) + return index; + if (g_strcmp0(code, "eng") == 0) + english_index = index; + } + return english_index != GTK_INVALID_LIST_POSITION + ? english_index + : (codes != NULL && codes->len > 0 + ? 0 + : GTK_INVALID_LIST_POSITION); +} + +static const char *create_person_dialog_effective_ocr_mime( + const PersonEvidenceSelectionItem *item) +{ + const char *mime = + person_evidence_selection_item_get_mime_type(item); + const char *name; + const char *suffix; + + if (mime != NULL && mime[0] != '\0') + return mime; + name = person_evidence_selection_item_get_original_name(item); + suffix = name != NULL ? strrchr(name, '.') : NULL; + if (suffix == NULL) + return NULL; + if (g_ascii_strcasecmp(suffix, ".png") == 0) + return "image/png"; + if (g_ascii_strcasecmp(suffix, ".jpg") == 0 || + g_ascii_strcasecmp(suffix, ".jpeg") == 0) + return "image/jpeg"; + if (g_ascii_strcasecmp(suffix, ".heic") == 0) + return "image/heic"; + if (g_ascii_strcasecmp(suffix, ".heif") == 0) + return "image/heif"; + if (g_ascii_strcasecmp(suffix, ".pdf") == 0) + return "application/pdf"; + return NULL; +} + static void identity_languages_completed(GObject *source, GAsyncResult *result, gpointer data) { @@ -209,9 +239,7 @@ static void identity_languages_completed(GObject *source, } gtk_drop_down_set_model(state->ocr_languages, G_LIST_MODEL(labels)); gtk_drop_down_set_selected(state->ocr_languages, - state->ocr_language_codes != NULL && - state->ocr_language_codes->len > 0 ? 0 : - GTK_INVALID_LIST_POSITION); + identity_default_language_index(state->ocr_language_codes)); gtk_widget_set_sensitive(GTK_WIDGET(state->ocr_start), state->ocr_language_codes != NULL && state->ocr_language_codes->len > 0); @@ -598,6 +626,8 @@ static void create_person_dialog_ocr_completed(GObject *source, create_person_dialog_render_ocr_fields(state, run); ocr_provenance_overlay_set_image(state->ocr_overlay, identity_ocr_run_get_preview(run), state->ocr_generation); + ocr_provenance_overlay_set_page(state->ocr_overlay, + identity_ocr_run_get_page(run), state->ocr_generation); const GPtrArray *fields = identity_ocr_run_get_fields(run); if (fields != NULL && fields->len > 0) ocr_provenance_overlay_set_field(state->ocr_overlay, @@ -653,7 +683,7 @@ static void create_person_dialog_on_ocr_start(GtkButton *button, job->identifier = g_strdup( person_evidence_selection_item_get_identifier(item)); job->sha256 = g_strdup(person_evidence_selection_item_get_sha256(item)); - job->mime = g_strdup(person_evidence_selection_item_get_mime_type(item)); + job->mime = g_strdup(create_person_dialog_effective_ocr_mime(item)); if (person_evidence_selection_item_get_origin(item) == PERSON_EVIDENCE_ORIGIN_EXISTING) { record = person_evidence_selection_item_get_record(item); @@ -1430,6 +1460,10 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat } } gtk_label_set_text(state->summary, confirmation->str); + GtkAdjustment *adjustment = gtk_scrolled_window_get_vadjustment( + state->summary_scroll); + gtk_adjustment_set_value(adjustment, + gtk_adjustment_get_lower(adjustment)); g_string_free(confirmation, TRUE); g_free(text); g_free(notes); @@ -1524,9 +1558,7 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_window_set_application(state->window, gtk_window_get_application(parent)); gtk_window_set_title(state->window, "Ajouter une personne"); - gtk_window_set_transient_for(state->window, parent); - gtk_window_set_modal(state->window, TRUE); - gtk_window_set_default_size(state->window, 960, 700); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_start(box, 16); gtk_widget_set_margin_end(box, 16); gtk_widget_set_margin_top(box, 16); gtk_widget_set_margin_bottom(box, 16); @@ -1554,6 +1586,8 @@ gboolean create_person_dialog_present(GtkWindow *parent, } state->evidence = GTK_DROP_DOWN(gtk_drop_down_new( G_LIST_MODEL(g_object_ref(state->evidence_labels)), NULL)); + gtk_widget_set_name(GTK_WIDGET(state->evidence), + "create-person-evidence-dropdown"); state->search = GTK_ENTRY(gtk_entry_new()); gtk_entry_set_placeholder_text(state->search, "Rechercher par nom, description ou type"); @@ -1565,6 +1599,8 @@ gboolean create_person_dialog_present(GtkWindow *parent, state->type_filter = GTK_DROP_DOWN( gtk_drop_down_new(G_LIST_MODEL( g_object_ref(state->type_filter_labels)), NULL)); + gtk_widget_set_name(GTK_WIDGET(state->type_filter), + "create-person-evidence-type-filter"); /* * gtk_drop_down_new() prend la propriété complète du modèle. Une * libération ici laisserait les vues internes du GtkDropDown avec un @@ -1655,10 +1691,20 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); gtk_widget_set_name(evidence_paned, "create-person-evidence-paned"); gtk_widget_set_size_request(evidence_box, 320, -1); - gtk_paned_set_start_child(GTK_PANED(evidence_paned), evidence_box); + GtkWidget *evidence_scroll = gtk_scrolled_window_new(); + gtk_widget_set_name(evidence_scroll, + "create-person-evidence-scroll"); + gtk_scrolled_window_set_policy( + GTK_SCROLLED_WINDOW(evidence_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(evidence_scroll), evidence_box); + gtk_paned_set_start_child( + GTK_PANED(evidence_paned), evidence_scroll); gtk_paned_set_end_child(GTK_PANED(evidence_paned), evidence_preview_widget_get_widget(state->preview)); - gtk_paned_set_position(GTK_PANED(evidence_paned), 420); + labfy_paned_apply_initial_ratio( + GTK_PANED(evidence_paned), 2.0 / 3.0, 480, 240); gtk_paned_set_resize_start_child( GTK_PANED(evidence_paned), FALSE); gtk_paned_set_resize_end_child(GTK_PANED(evidence_paned), TRUE); @@ -1689,6 +1735,8 @@ gboolean create_person_dialog_present(GtkWindow *parent, "Détection des langues…", NULL}; state->ocr_languages = GTK_DROP_DOWN( gtk_drop_down_new_from_strings(pending_languages)); + gtk_widget_set_name(GTK_WIDGET(state->ocr_languages), + "create-person-ocr-languages"); } state->ocr_profile = GTK_DROP_DOWN( gtk_drop_down_new_from_strings(profile_labels)); @@ -1835,9 +1883,23 @@ gboolean create_person_dialog_present(GtkWindow *parent, state->summary = GTK_LABEL(gtk_label_new("")); summary = GTK_WIDGET(state->summary); gtk_label_set_wrap(state->summary, TRUE); + gtk_label_set_wrap_mode(state->summary, PANGO_WRAP_WORD_CHAR); + gtk_label_set_max_width_chars(state->summary, 100); gtk_label_set_xalign(state->summary, 0.0f); gtk_label_set_selectable(state->summary, TRUE); - gtk_stack_add_titled(state->stack, summary, "summary", "5 — Confirmation"); + gtk_widget_set_hexpand(summary, TRUE); + gtk_widget_set_valign(summary, GTK_ALIGN_START); + state->summary_scroll = GTK_SCROLLED_WINDOW(gtk_scrolled_window_new()); + gtk_widget_set_name(GTK_WIDGET(state->summary_scroll), + "create-person-confirmation-scroll"); + gtk_scrolled_window_set_policy(state->summary_scroll, + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_propagate_natural_height( + state->summary_scroll, FALSE); + gtk_widget_set_vexpand(GTK_WIDGET(state->summary_scroll), TRUE); + gtk_scrolled_window_set_child(state->summary_scroll, summary); + gtk_stack_add_titled(state->stack, + GTK_WIDGET(state->summary_scroll), "summary", "5 — Confirmation"); state->error = GTK_LABEL(gtk_label_new(NULL)); gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_name(actions, "create-person-actions"); @@ -1900,10 +1962,9 @@ gboolean create_person_dialog_present(GtkWindow *parent, g_task_run_in_thread(task, identity_languages_worker); g_object_unref(task); } - gtk_window_present(state->window); - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, - create_person_dialog_set_initial_ocr_paned_position, - g_object_ref(ocr_paned), g_object_unref); + labfy_paned_apply_initial_ratio( + GTK_PANED(ocr_paned), 2.0 / 3.0, 520, 240); + labfy_dialog_present(state->window); return TRUE; } void create_person_dialog_result_free(CreatePersonDialogResult *result) diff --git a/src/views/create_relation_dialog.c b/src/views/create_relation_dialog.c index 498124a..efc38d0 100644 --- a/src/views/create_relation_dialog.c +++ b/src/views/create_relation_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/create_relation_dialog.h" +#include "views/dialog_geometry.h" #include "models/entity_record.h" #include "models/evidence_record.h" @@ -1231,26 +1232,7 @@ static gboolean create_relation_dialog_present_internal( : "Ajouter une relation" ); - gtk_window_set_transient_for( - state->window, - parent - ); - - gtk_window_set_modal( - state->window, - TRUE - ); - - gtk_window_set_destroy_with_parent( - state->window, - TRUE - ); - - gtk_window_set_default_size( - state->window, - 920, - 780 - ); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); root_box = gtk_box_new( @@ -1912,9 +1894,7 @@ static gboolean create_relation_dialog_present_internal( ) ); - gtk_window_present( - state->window - ); + labfy_dialog_present(state->window); return TRUE; } diff --git a/src/views/create_social_account_dialog.c b/src/views/create_social_account_dialog.c index efd9f03..c6ab4d8 100644 --- a/src/views/create_social_account_dialog.c +++ b/src/views/create_social_account_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/create_social_account_dialog.h" +#include "views/dialog_geometry.h" struct CreateSocialAccountDialogResult { @@ -200,9 +201,7 @@ gboolean create_social_account_dialog_present( state->evidence_identifiers = g_ptr_array_new_with_free_func(g_free); state->window = GTK_WINDOW(gtk_window_new()); gtk_window_set_title(state->window, "Ajouter un compte social"); - gtk_window_set_transient_for(state->window, parent); - gtk_window_set_modal(state->window, TRUE); - gtk_window_set_default_size(state->window, 620, 560); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_start(box, 16); gtk_widget_set_margin_end(box, 16); gtk_widget_set_margin_top(box, 16); gtk_widget_set_margin_bottom(box, 16); @@ -266,7 +265,7 @@ gboolean create_social_account_dialog_present( g_signal_connect(create, "clicked", G_CALLBACK(create_social_account_dialog_on_create), state); g_object_set_data_full(G_OBJECT(state->window), "social-account-state", state, create_social_account_dialog_state_free); - gtk_window_present(state->window); + labfy_dialog_present(state->window); g_free(now_text); g_date_time_unref(now); return TRUE; } diff --git a/src/views/dialog_geometry.c b/src/views/dialog_geometry.c new file mode 100644 index 0000000..629c8b7 --- /dev/null +++ b/src/views/dialog_geometry.c @@ -0,0 +1,145 @@ +#include "views/dialog_geometry.h" + +#define LABFY_DIALOG_INITIAL_WIDTH 1200 +#define LABFY_DIALOG_INITIAL_HEIGHT 800 +#define LABFY_DIALOG_MINIMUM_WIDTH 800 +#define LABFY_DIALOG_MINIMUM_HEIGHT 600 +#define LABFY_DIALOG_WORKAREA_FACTOR 0.90 + +typedef struct { + double ratio; + int minimum_start; + int minimum_end; +} LabfyPanedGeometry; + +LabfyDialogGeometry labfy_dialog_geometry_calculate( + int workarea_width, int workarea_height) +{ + LabfyDialogGeometry geometry = {0}; + int usable_width = workarea_width > 0 + ? (workarea_width >= LABFY_DIALOG_MINIMUM_WIDTH + ? workarea_width + : MAX(1, (int) ( + workarea_width * LABFY_DIALOG_WORKAREA_FACTOR))) + : LABFY_DIALOG_INITIAL_WIDTH; + int usable_height = workarea_height > 0 + ? (workarea_height >= LABFY_DIALOG_MINIMUM_HEIGHT + ? workarea_height + : MAX(1, (int) ( + workarea_height * LABFY_DIALOG_WORKAREA_FACTOR))) + : LABFY_DIALOG_INITIAL_HEIGHT; + geometry.initial_width = MIN(LABFY_DIALOG_INITIAL_WIDTH, usable_width); + geometry.initial_height = MIN(LABFY_DIALOG_INITIAL_HEIGHT, usable_height); + geometry.minimum_width = workarea_width >= LABFY_DIALOG_MINIMUM_WIDTH + ? LABFY_DIALOG_MINIMUM_WIDTH : usable_width; + geometry.minimum_height = workarea_height >= LABFY_DIALOG_MINIMUM_HEIGHT + ? LABFY_DIALOG_MINIMUM_HEIGHT : usable_height; + geometry.minimum_width = MIN( + geometry.minimum_width, geometry.initial_width); + geometry.minimum_height = MIN( + geometry.minimum_height, geometry.initial_height); + return geometry; +} + +static GdkMonitor *dialog_geometry_find_monitor( + GtkWindow *window, GtkWindow *parent) +{ + GdkDisplay *display = gtk_widget_get_display(GTK_WIDGET(window)); + GtkNative *native = parent != NULL + ? gtk_widget_get_native(GTK_WIDGET(parent)) : NULL; + GdkSurface *surface = native != NULL + ? gtk_native_get_surface(native) : NULL; + if (surface != NULL) { + GdkMonitor *monitor = + gdk_display_get_monitor_at_surface(display, surface); + if (monitor != NULL) return g_object_ref(monitor); + } + GListModel *monitors = gdk_display_get_monitors(display); + return monitors != NULL && g_list_model_get_n_items(monitors) > 0 + ? g_list_model_get_item(monitors, 0) : NULL; +} + +void labfy_dialog_apply_standard_geometry( + GtkWindow *window, GtkWindow *parent) +{ + GdkRectangle workarea = {0}; + GdkMonitor *monitor; + LabfyDialogGeometry geometry; + if (!GTK_IS_WINDOW(window)) return; + monitor = dialog_geometry_find_monitor(window, parent); + if (monitor != NULL) gdk_monitor_get_geometry(monitor, &workarea); + geometry = labfy_dialog_geometry_calculate( + workarea.width, workarea.height); + gtk_window_set_resizable(window, TRUE); + gtk_window_set_default_size(window, + geometry.initial_width, geometry.initial_height); + gtk_widget_set_size_request(GTK_WIDGET(window), + geometry.minimum_width, geometry.minimum_height); + g_clear_object(&monitor); +} + +void labfy_dialog_prepare( + GtkWindow *window, + GtkWindow *parent, + gboolean modal, + gboolean destroy_with_parent) +{ + if (!GTK_IS_WINDOW(window)) return; + gtk_window_set_transient_for(window, + GTK_IS_WINDOW(parent) ? parent : NULL); + gtk_window_set_modal(window, modal); + gtk_window_set_destroy_with_parent(window, + destroy_with_parent && GTK_IS_WINDOW(parent)); +} + +void labfy_dialog_present(GtkWindow *window) +{ + GtkWindow *parent; + + if (!GTK_IS_WINDOW(window)) return; + parent = gtk_window_get_transient_for(window); + labfy_dialog_apply_standard_geometry(window, parent); + gtk_window_present(window); +} + +static void paned_geometry_apply( + GtkPaned *paned, LabfyPanedGeometry *geometry) +{ + int width = gtk_widget_get_width(GTK_WIDGET(paned)); + int position; + if (width <= 0 || geometry == NULL) return; + position = (int) (width * geometry->ratio); + position = MAX(geometry->minimum_start, position); + position = MIN(position, MAX(0, width - geometry->minimum_end)); + gtk_paned_set_position(paned, position); +} + +static gboolean paned_geometry_idle(gpointer data) +{ + GtkPaned *paned = GTK_PANED(data); + LabfyPanedGeometry *geometry = g_object_get_data( + G_OBJECT(paned), "labfy-paned-initial-geometry"); + if (geometry == NULL) return G_SOURCE_REMOVE; + if (gtk_widget_get_width(GTK_WIDGET(paned)) <= 0) + return G_SOURCE_CONTINUE; + paned_geometry_apply(paned, geometry); + return G_SOURCE_REMOVE; +} + +void labfy_paned_apply_initial_ratio( + GtkPaned *paned, double ratio, int minimum_start, int minimum_end) +{ + LabfyPanedGeometry *geometry; + if (!GTK_IS_PANED(paned)) return; + if (g_object_get_data( + G_OBJECT(paned), "labfy-paned-initial-geometry") != NULL) + return; + geometry = g_new0(LabfyPanedGeometry, 1); + geometry->ratio = CLAMP(ratio, 0.1, 0.9); + geometry->minimum_start = MAX(0, minimum_start); + geometry->minimum_end = MAX(0, minimum_end); + g_object_set_data_full(G_OBJECT(paned), + "labfy-paned-initial-geometry", geometry, g_free); + g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, paned_geometry_idle, + g_object_ref(paned), g_object_unref); +} diff --git a/src/views/eml_analysis_dialog.c b/src/views/eml_analysis_dialog.c index b287ba3..84fc6aa 100644 --- a/src/views/eml_analysis_dialog.c +++ b/src/views/eml_analysis_dialog.c @@ -3,6 +3,7 @@ * @brief Présentation en lecture seule d'une analyse EML. ******************************************************************************/ #include "views/eml_analysis_dialog.h" +#include "views/dialog_geometry.h" #include "widgets/controlled_vocab_dropdown.h" typedef struct { GtkWindow *window; GtkWidget *proposals_box; GtkWidget *summary_label; @@ -234,8 +235,7 @@ void eml_analysis_dialog_present(GtkWindow *parent, state->callback = callback; state->user_data = user_data; window = GTK_WINDOW(gtk_window_new()); gtk_window_set_title(window, "Analyse locale du courriel EML"); - gtk_window_set_transient_for(window, parent); gtk_window_set_modal(window, TRUE); - gtk_window_set_default_size(window, 720, 560); + labfy_dialog_prepare(window, parent, TRUE, TRUE); state->window = window; box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_start(box, 14); gtk_widget_set_margin_end(box, 14); @@ -314,7 +314,7 @@ void eml_analysis_dialog_present(GtkWindow *parent, G_CALLBACK(eml_analysis_dialog_on_window_close), state); g_object_set_data_full(G_OBJECT(window), "eml-dialog-state", state, eml_analysis_dialog_state_free); - gtk_window_present(window); + labfy_dialog_present(window); g_free(received); g_free(emails); g_free(domains); g_free(sender_ips); g_free(destination_ips); } @@ -418,9 +418,7 @@ void eml_analysis_dialog_present_pipeline( state->callback = callback; state->user_data = user_data; state->window = GTK_WINDOW(gtk_window_new()); gtk_window_set_title(state->window, "Révision de l’analyse EML"); - gtk_window_set_transient_for(state->window, parent); - gtk_window_set_modal(state->window, TRUE); - gtk_window_set_default_size(state->window, 900, 700); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_start(outer, 14); gtk_widget_set_margin_end(outer, 14); @@ -538,7 +536,7 @@ void eml_analysis_dialog_present_pipeline( G_CALLBACK(eml_analysis_dialog_on_window_close), state); g_object_set_data_full(G_OBJECT(state->window), "eml-dialog-state", state, eml_analysis_dialog_state_free); - gtk_window_present(state->window); + labfy_dialog_present(state->window); g_free(received); g_string_free(attachments, TRUE); g_string_free(texts, TRUE); diff --git a/src/views/evidence_identity_ocr_dialog.c b/src/views/evidence_identity_ocr_dialog.c index 026f2f3..3ee0788 100644 --- a/src/views/evidence_identity_ocr_dialog.c +++ b/src/views/evidence_identity_ocr_dialog.c @@ -1,4 +1,5 @@ #include "views/evidence_identity_ocr_dialog.h" +#include "views/dialog_geometry.h" #include "core/file_hash.h" #include "core/identity_ocr_workflow.h" @@ -129,24 +130,6 @@ static void on_reset_transcription(GtkButton *button, gpointer data) gtk_widget_set_sensitive(GTK_WIDGET(state->save_transcription), TRUE); } -static gboolean set_initial_paned_position(gpointer data) -{ - GtkWidget *paned = data; - int width; - int position; - if (g_object_get_data( - G_OBJECT(paned), "identity-initial-position-set") != NULL) - return G_SOURCE_REMOVE; - width = gtk_widget_get_width(paned); - if (width <= 0) return G_SOURCE_CONTINUE; - position = MAX(520, (width * 2) / 3); - position = MIN(position, MAX(0, width - 180)); - gtk_paned_set_position(GTK_PANED(paned), position); - g_object_set_data(G_OBJECT(paned), - "identity-initial-position-set", GINT_TO_POINTER(1)); - return G_SOURCE_REMOVE; -} - static const char *mime_from_path(const char *path) { const char *dot = path != NULL ? strrchr(path, '.') : NULL; @@ -545,6 +528,8 @@ static void workflow_completed(GObject *source, GAsyncResult *result, GTK_WIDGET(state->save_transcription), TRUE); ocr_provenance_overlay_set_image(state->overlay, identity_ocr_run_get_preview(state->run), state->generation); + ocr_provenance_overlay_set_page(state->overlay, + identity_ocr_run_get_page(state->run), state->generation); gtk_label_set_text(state->status, "OCR terminé. Vérifiez chaque proposition."); gtk_widget_set_sensitive( @@ -668,11 +653,9 @@ gboolean evidence_identity_ocr_dialog_present( state->window = GTK_WINDOW(gtk_window_new()); gtk_window_set_application(state->window, gtk_window_get_application(parent)); - gtk_window_set_transient_for(state->window, parent); - gtk_window_set_modal(state->window, TRUE); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); gtk_window_set_title(state->window, "Import — OCR d’identité facultatif"); - gtk_window_set_default_size(state->window, 1200, 800); GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_widget_set_name(root, "identity-form-content"); gtk_widget_set_margin_start(root, 14); @@ -881,9 +864,9 @@ gboolean evidence_identity_ocr_dialog_present( state->tesseract_path != NULL); if (state->tesseract_path != NULL && !populate_languages(state, error)) goto failure_window; - gtk_window_present(state->window); - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, - set_initial_paned_position, g_object_ref(paned), g_object_unref); + labfy_paned_apply_initial_ratio( + GTK_PANED(paned), 2.0 / 3.0, 520, 240); + labfy_dialog_present(state->window); return TRUE; failure_window: diff --git a/src/views/evidence_import_dialog.c b/src/views/evidence_import_dialog.c index 8ed5cfa..13bed61 100644 --- a/src/views/evidence_import_dialog.c +++ b/src/views/evidence_import_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/evidence_import_dialog.h" +#include "views/dialog_geometry.h" #include "models/evidence_type.h" #include "core/file_hash.h" #include "core/task_manager.h" @@ -974,10 +975,7 @@ gboolean evidence_import_dialog_present( "Importer une preuve" ); - gtk_window_set_transient_for( - state->window, - parent - ); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); gtk_window_set_application( state->window, @@ -986,22 +984,6 @@ gboolean evidence_import_dialog_present( ) ); - gtk_window_set_modal( - state->window, - TRUE - ); - - gtk_window_set_destroy_with_parent( - state->window, - TRUE - ); - - gtk_window_set_default_size( - state->window, - 1200, - 800 - ); - root_box = gtk_box_new( GTK_ORIENTATION_VERTICAL, @@ -1411,10 +1393,26 @@ gboolean evidence_import_dialog_present( button_box ); + GtkWidget *left_panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + GtkWidget *form_scroll = gtk_scrolled_window_new(); + gtk_widget_set_name(form_scroll, "evidence-import-form-scroll"); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(form_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(form_scroll, TRUE); + g_object_ref(button_box); + gtk_box_remove(GTK_BOX(root_box), button_box); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(form_scroll), root_box); + gtk_box_append(GTK_BOX(left_panel), form_scroll); + gtk_widget_set_name(button_box, "evidence-import-actions"); + gtk_widget_set_margin_end(button_box, 18); + gtk_widget_set_margin_bottom(button_box, 12); + gtk_box_append(GTK_BOX(left_panel), button_box); + g_object_unref(button_box); paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); gtk_widget_set_name(paned, "evidence-import-paned"); - gtk_paned_set_start_child(GTK_PANED(paned), root_box); - gtk_widget_set_size_request(root_box, 320, -1); + gtk_paned_set_start_child(GTK_PANED(paned), left_panel); + gtk_widget_set_size_request(left_panel, 320, -1); state->preview_task_manager = task_manager_new(); state->preview = evidence_preview_widget_new( state->preview_task_manager, NULL, NULL); @@ -1425,7 +1423,8 @@ gboolean evidence_import_dialog_present( } gtk_paned_set_end_child(GTK_PANED(paned), evidence_preview_widget_get_widget(state->preview)); - gtk_paned_set_position(GTK_PANED(paned), 420); + labfy_paned_apply_initial_ratio( + GTK_PANED(paned), 2.0 / 3.0, 480, 240); gtk_paned_set_resize_start_child(GTK_PANED(paned), FALSE); gtk_paned_set_resize_end_child(GTK_PANED(paned), TRUE); gtk_window_set_child(state->window, paned); @@ -1500,9 +1499,7 @@ gboolean evidence_import_dialog_present( import_button ); - gtk_window_present( - state->window - ); + labfy_dialog_present(state->window); return TRUE; } diff --git a/src/views/evidence_metadata_dialog.c b/src/views/evidence_metadata_dialog.c index ec1694e..f4a0cd0 100644 --- a/src/views/evidence_metadata_dialog.c +++ b/src/views/evidence_metadata_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/evidence_metadata_dialog.h" +#include "views/dialog_geometry.h" #include "models/evidence_type.h" #include "dao/identity_ocr_dao.h" @@ -258,6 +259,8 @@ static void evidence_metadata_dialog_render_ocr( IdentityOcrRunRecord *run = g_ptr_array_index( context->ocr_runs, selected); + ocr_provenance_overlay_set_page(context->ocr_overlay, + (guint) run->page_number, context->ocr_generation); const char *selected_identifier = g_ptr_array_index( context->ocr_run_identifiers, selected); if (g_strcmp0(run->id, selected_identifier) != 0) return; @@ -492,13 +495,10 @@ gboolean evidence_metadata_dialog_present_with_ocr( evidence_record_get_type_identifier(record)) == 0) selected = index; } gtk_window_set_title(context->window, "Modifier les métadonnées"); - gtk_window_set_default_size(context->window, 560, 420); - gtk_window_set_modal(context->window, TRUE); - gtk_window_set_destroy_with_parent(context->window, TRUE); + labfy_dialog_prepare(context->window, parent, TRUE, TRUE); if (parent != NULL) { gtk_window_set_application(context->window, gtk_window_get_application(parent)); - gtk_window_set_transient_for(context->window, parent); } g_object_set_data_full(G_OBJECT(context->window), "evidence-metadata-context", context, evidence_metadata_dialog_context_free); @@ -577,7 +577,7 @@ gboolean evidence_metadata_dialog_present_with_ocr( gtk_box_append(GTK_BOX(buttons), cancel); gtk_box_append(GTK_BOX(buttons), save); gtk_box_append(GTK_BOX(main_box), buttons); gtk_window_set_child(context->window, main_box); - gtk_window_present(context->window); + labfy_dialog_present(context->window); return TRUE; } diff --git a/src/views/manage_entity_evidence_dialog.c b/src/views/manage_entity_evidence_dialog.c index 304e00d..d734d53 100644 --- a/src/views/manage_entity_evidence_dialog.c +++ b/src/views/manage_entity_evidence_dialog.c @@ -3,6 +3,7 @@ * @brief Dialogue de sélection des preuves associées à une entité. ******************************************************************************/ #include "views/manage_entity_evidence_dialog.h" +#include "views/dialog_geometry.h" #include "widgets/evidence_preview_widget.h" #include typedef struct @@ -116,9 +117,7 @@ gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, state->import_callback = import_callback; state->window = GTK_WINDOW(gtk_window_new()); gtk_window_set_title(state->window, "Pièces jointes de la personne"); - gtk_window_set_transient_for(state->window, parent); - gtk_window_set_modal(state->window, TRUE); - gtk_window_set_default_size(state->window, 1200, 800); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_margin_top(root, 16); gtk_widget_set_margin_bottom(root, 16); gtk_widget_set_margin_start(root, 16); gtk_widget_set_margin_end(root, 16); @@ -178,7 +177,8 @@ gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, gtk_paned_set_start_child(GTK_PANED(content), scroll); gtk_paned_set_end_child(GTK_PANED(content), evidence_preview_widget_get_widget(state->preview)); - gtk_paned_set_position(GTK_PANED(content), 380); + labfy_paned_apply_initial_ratio( + GTK_PANED(content), 2.0 / 3.0, 480, 240); gtk_paned_set_resize_start_child(GTK_PANED(content), FALSE); gtk_paned_set_resize_end_child(GTK_PANED(content), TRUE); save = gtk_button_new_with_label("Enregistrer les pièces jointes"); @@ -194,5 +194,5 @@ gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, g_signal_connect(save, "clicked", G_CALLBACK(manage_evidence_on_save), state); g_signal_connect(import, "clicked", G_CALLBACK(manage_evidence_on_import), state); g_signal_connect(state->window, "close-request", G_CALLBACK(manage_evidence_on_close), state); - gtk_window_present(state->window); return TRUE; + labfy_dialog_present(state->window); return TRUE; } diff --git a/src/views/metadata_analysis_dialog.c b/src/views/metadata_analysis_dialog.c index b1933fe..6a2980f 100644 --- a/src/views/metadata_analysis_dialog.c +++ b/src/views/metadata_analysis_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/metadata_analysis_dialog.h" +#include "views/dialog_geometry.h" static gboolean metadata_analysis_dialog_on_escape( GtkWidget *widget, GVariant *arguments, gpointer user_data) @@ -30,9 +31,7 @@ void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary, window = gtk_window_new(); gtk_window_set_title(GTK_WINDOW(window), "Métadonnées de la preuve"); - gtk_window_set_default_size(GTK_WINDOW(window), 760, 560); - gtk_window_set_modal(GTK_WINDOW(window), TRUE); - if (parent != NULL) gtk_window_set_transient_for(GTK_WINDOW(window), parent); + labfy_dialog_prepare(GTK_WINDOW(window), parent, TRUE, TRUE); content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_margin_top(content, 16); gtk_widget_set_margin_bottom(content, 16); @@ -81,7 +80,7 @@ void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary, } gtk_box_append(GTK_BOX(content), close_button); gtk_window_set_child(GTK_WINDOW(window), content); - gtk_window_present(GTK_WINDOW(window)); + labfy_dialog_present(GTK_WINDOW(window)); g_free(heading_text); g_free(path_text); } diff --git a/src/views/osint_dns_review_dialog.c b/src/views/osint_dns_review_dialog.c index a9370c4..2e6a158 100644 --- a/src/views/osint_dns_review_dialog.c +++ b/src/views/osint_dns_review_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/osint_dns_review_dialog.h" +#include "views/dialog_geometry.h" #include "models/osint_dns_proposal.h" @@ -211,11 +212,7 @@ void osint_dns_review_dialog_present( context->user_data = user_data; gtk_window_set_title(context->window, "Intégrer les propositions DNS"); - gtk_window_set_default_size(context->window, 720, 560); - gtk_window_set_modal(context->window, TRUE); - gtk_window_set_destroy_with_parent(context->window, TRUE); - if (parent_window != NULL) - gtk_window_set_transient_for(context->window, parent_window); + labfy_dialog_prepare(context->window, parent_window, TRUE, TRUE); g_object_set_data_full( G_OBJECT(context->window), "osint-dns-review-context", context, osint_dns_review_dialog_context_free @@ -279,5 +276,5 @@ void osint_dns_review_dialog_present( gtk_box_append(GTK_BOX(main_box), scrolled_window); gtk_box_append(GTK_BOX(main_box), button_box); gtk_window_set_child(context->window, main_box); - gtk_window_present(context->window); + labfy_dialog_present(context->window); } diff --git a/src/views/osint_execution_history_dialog.c b/src/views/osint_execution_history_dialog.c index 4496754..4bca7d8 100644 --- a/src/views/osint_execution_history_dialog.c +++ b/src/views/osint_execution_history_dialog.c @@ -4,6 +4,7 @@ ******************************************************************************/ #include "views/osint_execution_history_dialog.h" +#include "views/dialog_geometry.h" #include "core/osint_execution_integrity.h" #include "models/osint_execution_record.h" @@ -153,10 +154,7 @@ void osint_execution_history_dialog_present( context->records = g_ptr_array_ref(records); context->linked_objects = g_hash_table_ref(linked_objects); gtk_window_set_title(context->window, "Historique OSINT"); - gtk_window_set_default_size(context->window, 980, 650); - gtk_window_set_modal(context->window, TRUE); - gtk_window_set_destroy_with_parent(context->window, TRUE); - if (parent_window != NULL) gtk_window_set_transient_for(context->window, parent_window); + labfy_dialog_prepare(context->window, parent_window, TRUE, TRUE); g_object_set_data_full(G_OBJECT(context->window), "osint-history-context", context, osint_execution_history_dialog_context_free); main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); @@ -214,5 +212,5 @@ void osint_execution_history_dialog_present( gtk_window_set_child(context->window, main_box); osint_execution_history_dialog_on_record_clicked( GTK_BUTTON(gtk_widget_get_first_child(history_box)), context); - gtk_window_present(context->window); + labfy_dialog_present(context->window); } diff --git a/src/views/rib_ocr_review_dialog.c b/src/views/rib_ocr_review_dialog.c index 3925b94..a965f47 100644 --- a/src/views/rib_ocr_review_dialog.c +++ b/src/views/rib_ocr_review_dialog.c @@ -3,6 +3,7 @@ * @brief Révision humaine des résultats OCR d'un RIB. ******************************************************************************/ #include "views/rib_ocr_review_dialog.h" +#include "views/dialog_geometry.h" #include "core/iban_analyzer.h" typedef struct { GtkWindow *window; GtkEntry *iban; GtkLabel *error; RibOcrReviewCallback callback; gpointer data; gboolean completed; } State; @@ -36,8 +37,7 @@ void rib_ocr_review_dialog_present(GtkWindow *parent, const char *ocr_text, state->data = user_data; state->iban = GTK_ENTRY(gtk_entry_new()); state->error = GTK_LABEL(gtk_label_new(NULL)); gtk_window_set_title(state->window, "Réviser l’analyse OCR du RIB"); - gtk_window_set_transient_for(state->window, parent); gtk_window_set_modal(state->window, TRUE); - gtk_window_set_default_size(state->window, 760, 620); + labfy_dialog_prepare(state->window, parent, TRUE, TRUE); gtk_widget_set_margin_top(root, 16); gtk_widget_set_margin_bottom(root, 16); gtk_widget_set_margin_start(root, 16); gtk_widget_set_margin_end(root, 16); gtk_text_view_set_editable(GTK_TEXT_VIEW(view), FALSE); @@ -55,5 +55,5 @@ void rib_ocr_review_dialog_present(GtkWindow *parent, const char *ocr_text, g_object_set_data_full(G_OBJECT(state->window), "state", state, state_free); g_signal_connect(confirm, "clicked", G_CALLBACK(on_confirm), state); g_signal_connect(state->window, "close-request", G_CALLBACK(on_close), state); - gtk_window_present(state->window); + labfy_dialog_present(state->window); } diff --git a/src/widgets/evidence_preview_widget.c b/src/widgets/evidence_preview_widget.c index 18cfe2f..4a9740f 100644 --- a/src/widgets/evidence_preview_widget.c +++ b/src/widgets/evidence_preview_widget.c @@ -6,9 +6,19 @@ struct EvidencePreviewWidget { GtkWidget *root; GtkLabel *metadata; GtkLabel *status; + GtkWidget *toolbar; GtkStack *stack; GtkPicture *image; GtkPicture *pdf; + GtkScrolledWindow *image_scroll; + GtkScrolledWindow *pdf_scroll; + GtkButton *zoom_out; + GtkButton *fit; + GtkButton *zoom_in; + GtkLabel *zoom_label; + GtkButton *previous_page; + GtkButton *next_page; + GtkLabel *page_label; GtkVideo *video; GtkTextView *text; GtkTextView *email; @@ -19,6 +29,16 @@ struct EvidencePreviewWidget { gpointer session_data; guint64 generation; char *identifier; + EvidencePreviewRequest *request; + char *metadata_text; + gint raster_width; + gint raster_height; + guint zoom_index; + gboolean fit_mode; + gboolean raster_available; + gboolean pdf_available; + guint pdf_page; + guint pdf_page_count; }; typedef struct { @@ -77,6 +97,148 @@ static void preview_widget_reset_content(EvidencePreviewWidget *widget) gtk_text_buffer_set_text(gtk_text_view_get_buffer(widget->email), "", -1); } +static const double zoom_factors[] = { + 0.25, 0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 3.00, 4.00 +}; + +static GtkPicture *preview_widget_active_picture( + EvidencePreviewWidget *widget) +{ + return widget->pdf_available ? widget->pdf : widget->image; +} + +static GtkScrolledWindow *preview_widget_active_scroll( + EvidencePreviewWidget *widget) +{ + return widget->pdf_available ? widget->pdf_scroll : widget->image_scroll; +} + +static void preview_widget_update_page_controls( + EvidencePreviewWidget *widget) +{ + char *text = g_strdup_printf("Page %u / %u", + widget->pdf_page_count > 0 ? widget->pdf_page + 1U : 0U, + widget->pdf_page_count); + gtk_label_set_text(widget->page_label, text); + g_free(text); + gtk_widget_set_visible(GTK_WIDGET(widget->previous_page), + widget->pdf_available); + gtk_widget_set_visible(GTK_WIDGET(widget->next_page), + widget->pdf_available); + gtk_widget_set_visible(GTK_WIDGET(widget->page_label), + widget->pdf_available); + gtk_widget_set_sensitive(GTK_WIDGET(widget->previous_page), + widget->pdf_available && widget->pdf_page > 0); + gtk_widget_set_sensitive(GTK_WIDGET(widget->next_page), + widget->pdf_available && + widget->pdf_page + 1U < widget->pdf_page_count); +} + +static void preview_widget_apply_zoom(EvidencePreviewWidget *widget) +{ + GtkPicture *picture; + GtkScrolledWindow *scroll; + double factor; + char *text; + if (widget == NULL) return; + picture = preview_widget_active_picture(widget); + scroll = preview_widget_active_scroll(widget); + gtk_widget_set_visible(GTK_WIDGET(widget->zoom_out), + widget->raster_available); + gtk_widget_set_visible(GTK_WIDGET(widget->fit), + widget->raster_available); + gtk_widget_set_visible(GTK_WIDGET(widget->zoom_in), + widget->raster_available); + gtk_widget_set_visible(GTK_WIDGET(widget->zoom_label), + widget->raster_available); + if (!widget->raster_available) return; + factor = zoom_factors[widget->zoom_index]; + text = widget->fit_mode ? g_strdup("Ajusté") : + g_strdup_printf("%.0f %%", factor * 100.0); + gtk_label_set_text(widget->zoom_label, text); + g_free(text); + gtk_widget_set_sensitive(GTK_WIDGET(widget->zoom_out), + widget->fit_mode || widget->zoom_index > 0); + gtk_widget_set_sensitive(GTK_WIDGET(widget->zoom_in), + widget->fit_mode || + widget->zoom_index + 1U < G_N_ELEMENTS(zoom_factors)); + gtk_widget_set_sensitive(GTK_WIDGET(widget->fit), + !widget->fit_mode); + if (widget->fit_mode) { + gtk_picture_set_can_shrink(picture, TRUE); + gtk_picture_set_content_fit(picture, GTK_CONTENT_FIT_CONTAIN); + gtk_widget_set_size_request(GTK_WIDGET(picture), -1, -1); + gtk_widget_set_hexpand(GTK_WIDGET(picture), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(picture), TRUE); + gtk_scrolled_window_set_policy(scroll, + GTK_POLICY_NEVER, GTK_POLICY_NEVER); + } else { + gtk_picture_set_can_shrink(picture, FALSE); + gtk_picture_set_content_fit(picture, GTK_CONTENT_FIT_CONTAIN); + gtk_widget_set_hexpand(GTK_WIDGET(picture), FALSE); + gtk_widget_set_vexpand(GTK_WIDGET(picture), FALSE); + gtk_widget_set_size_request(GTK_WIDGET(picture), + MAX(1, (gint) (widget->raster_width * factor)), + MAX(1, (gint) (widget->raster_height * factor))); + gtk_scrolled_window_set_policy(scroll, + GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + } +} + +void evidence_preview_widget_zoom_in(EvidencePreviewWidget *widget) +{ + if (widget == NULL || !widget->raster_available) return; + if (widget->fit_mode) { + widget->fit_mode = FALSE; + widget->zoom_index = 4; + } else if (widget->zoom_index + 1U < G_N_ELEMENTS(zoom_factors)) + widget->zoom_index++; + preview_widget_apply_zoom(widget); +} + +void evidence_preview_widget_zoom_out(EvidencePreviewWidget *widget) +{ + if (widget == NULL || !widget->raster_available) return; + if (widget->fit_mode) { + widget->fit_mode = FALSE; + widget->zoom_index = 2; + } else if (widget->zoom_index > 0) + widget->zoom_index--; + preview_widget_apply_zoom(widget); +} + +void evidence_preview_widget_fit(EvidencePreviewWidget *widget) +{ + if (widget == NULL || !widget->raster_available) return; + widget->fit_mode = TRUE; + preview_widget_apply_zoom(widget); +} + +double evidence_preview_widget_get_zoom( + const EvidencePreviewWidget *widget) +{ + return widget != NULL && !widget->fit_mode + ? zoom_factors[widget->zoom_index] : 0.0; +} + +gboolean evidence_preview_widget_is_fit( + const EvidencePreviewWidget *widget) +{ + return widget != NULL && widget->fit_mode; +} + +guint evidence_preview_widget_get_pdf_page( + const EvidencePreviewWidget *widget) +{ + return widget != NULL ? widget->pdf_page : 0; +} + +guint evidence_preview_widget_get_pdf_page_count( + const EvidencePreviewWidget *widget) +{ + return widget != NULL ? widget->pdf_page_count : 0; +} + void evidence_preview_widget_cancel(EvidencePreviewWidget *widget) { if (widget == NULL) return; @@ -89,6 +251,8 @@ void evidence_preview_widget_cancel(EvidencePreviewWidget *widget) if (widget->root != NULL) preview_widget_reset_content(widget); g_clear_pointer(&widget->identifier, g_free); + g_clear_pointer(&widget->request, evidence_preview_request_free); + g_clear_pointer(&widget->metadata_text, g_free); } void evidence_preview_widget_clear(EvidencePreviewWidget *widget) @@ -98,6 +262,12 @@ void evidence_preview_widget_clear(EvidencePreviewWidget *widget) gtk_label_set_text(widget->metadata, ""); gtk_label_set_text(widget->status, "Aucune preuve sélectionnée."); gtk_stack_set_visible_child_name(widget->stack, "empty"); + widget->raster_available = FALSE; + widget->pdf_available = FALSE; + widget->pdf_page = 0; + widget->pdf_page_count = 0; + preview_widget_apply_zoom(widget); + preview_widget_update_page_controls(widget); } static void preview_widget_completed(BackgroundTask *task, gpointer data) @@ -128,9 +298,25 @@ static void preview_widget_completed(BackgroundTask *task, gpointer data) result->kind == EVIDENCE_PREVIEW_KIND_PDF ? "pdf" : "image"); gtk_label_set_text(widget->status, "Aperçu intègre."); + widget->raster_available = TRUE; + widget->pdf_available = + result->kind == EVIDENCE_PREVIEW_KIND_PDF; + widget->raster_width = result->width; + widget->raster_height = result->height; + if (widget->pdf_available) { + widget->pdf_page_count = result->item_count; + widget->pdf_page = result->current_page - 1U; + } else { + widget->pdf_page_count = 0; + widget->pdf_page = 0; + } + preview_widget_apply_zoom(widget); + preview_widget_update_page_controls(widget); g_object_unref(texture); } } else if (result->kind == EVIDENCE_PREVIEW_KIND_VIDEO) { + widget->raster_available = FALSE; + widget->pdf_available = FALSE; GtkMediaStream *media = gtk_media_file_new_for_filename( result->controlled_path); gtk_media_stream_set_volume(media, 0.0); @@ -145,6 +331,8 @@ static void preview_widget_completed(BackgroundTask *task, gpointer data) gtk_label_set_text(widget->status, result->message); } else if (result->kind == EVIDENCE_PREVIEW_KIND_TEXT || result->kind == EVIDENCE_PREVIEW_KIND_EMAIL) { + widget->raster_available = FALSE; + widget->pdf_available = FALSE; GtkTextView *view = result->kind == EVIDENCE_PREVIEW_KIND_EMAIL ? widget->email : widget->text; gtk_text_buffer_set_text(gtk_text_view_get_buffer(view), @@ -155,6 +343,8 @@ static void preview_widget_completed(BackgroundTask *task, gpointer data) gtk_label_set_text(widget->status, result->truncated ? "Aperçu tronqué." : "Aperçu intègre."); } else { + widget->raster_available = FALSE; + widget->pdf_available = FALSE; gtk_stack_set_visible_child_name(widget->stack, result->kind == EVIDENCE_PREVIEW_KIND_UNSUPPORTED ? "unsupported" : "error"); @@ -162,6 +352,10 @@ static void preview_widget_completed(BackgroundTask *task, gpointer data) result->message != NULL ? result->message : "Aperçu illisible."); } + if (!widget->raster_available) { + preview_widget_apply_zoom(widget); + preview_widget_update_page_controls(widget); + } if (error != NULL) { gtk_stack_set_visible_child_name(widget->stack, "error"); gtk_label_set_text(widget->status, error->message); @@ -182,6 +376,100 @@ static void preview_widget_completed(BackgroundTask *task, gpointer data) g_clear_object(&root); } +static void preview_widget_start_request(EvidencePreviewWidget *widget, + guint pdf_page) +{ + EvidencePreviewRequest *owned; + PreviewWidgetContext *context; + GError *error = NULL; + if (widget == NULL || widget->request == NULL) return; + widget->generation++; + if (widget->task != NULL) { + background_task_cancel(widget->task); + background_task_unref(widget->task); + widget->task = NULL; + } + owned = evidence_preview_request_new( + widget->request->investigation_root_path, + widget->request->evidence_identifier, + widget->request->relative_path, + widget->request->expected_sha256, + widget->request->mime_type, widget->generation); + evidence_preview_request_set_pdf_page(owned, pdf_page); + gtk_label_set_text(widget->metadata, + widget->metadata_text != NULL ? widget->metadata_text : ""); + gtk_label_set_text(widget->status, + "Détection, vérification et chargement en cours…"); + gtk_stack_set_visible_child_name(widget->stack, "loading"); + context = g_new0(PreviewWidgetContext, 1); + g_weak_ref_init(&context->root, G_OBJECT(widget->root)); + context->identifier = g_strdup(widget->identifier); + context->generation = widget->generation; + if (owned != NULL) + widget->task = evidence_preview_task_start(widget->task_manager, + owned, preview_widget_completed, context, + preview_widget_context_free, &error); + evidence_preview_request_free(owned); + if (widget->task == NULL) { + preview_widget_context_free(context); + gtk_stack_set_visible_child_name(widget->stack, "error"); + gtk_label_set_text(widget->status, + error != NULL ? error->message : "Aperçu impossible."); + } + g_clear_error(&error); +} + +gboolean evidence_preview_widget_set_pdf_page( + EvidencePreviewWidget *widget, guint page) +{ + if (widget == NULL || !widget->pdf_available || + page >= widget->pdf_page_count || page == widget->pdf_page) + return FALSE; + widget->pdf_page = page; + preview_widget_update_page_controls(widget); + preview_widget_start_request(widget, page); + return TRUE; +} + +static void preview_widget_on_zoom_out( + GtkButton *button, gpointer data) +{ + (void) button; + evidence_preview_widget_zoom_out(data); +} + +static void preview_widget_on_fit(GtkButton *button, gpointer data) +{ + (void) button; + evidence_preview_widget_fit(data); +} + +static void preview_widget_on_zoom_in( + GtkButton *button, gpointer data) +{ + (void) button; + evidence_preview_widget_zoom_in(data); +} + +static void preview_widget_on_previous( + GtkButton *button, gpointer data) +{ + EvidencePreviewWidget *widget = data; + (void) button; + if (widget->pdf_page > 0) + evidence_preview_widget_set_pdf_page( + widget, widget->pdf_page - 1U); +} + +static void preview_widget_on_next(GtkButton *button, gpointer data) +{ + EvidencePreviewWidget *widget = data; + (void) button; + if (widget->pdf_page + 1U < widget->pdf_page_count) + evidence_preview_widget_set_pdf_page( + widget, widget->pdf_page + 1U); +} + EvidencePreviewWidget *evidence_preview_widget_new( TaskManager *task_manager, EvidencePreviewWidgetSessionCheck session_check, @@ -199,7 +487,10 @@ EvidencePreviewWidget *evidence_preview_widget_new( widget->task_manager = task_manager; widget->session_check = session_check; widget->session_data = session_data; + widget->fit_mode = TRUE; + widget->zoom_index = 3; widget->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_name(widget->root, "evidence-preview-widget"); g_object_weak_ref(G_OBJECT(widget->root), preview_widget_root_finalized, widget); widget->metadata = GTK_LABEL(gtk_label_new("")); @@ -207,6 +498,65 @@ EvidencePreviewWidget *evidence_preview_widget_new( gtk_label_set_selectable(widget->metadata, TRUE); widget->status = GTK_LABEL(gtk_label_new("Aucune preuve sélectionnée.")); gtk_label_set_xalign(widget->status, 0.0f); + widget->toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_name(widget->toolbar, "evidence-preview-toolbar"); + widget->zoom_out = GTK_BUTTON(gtk_button_new_from_icon_name( + "zoom-out-symbolic")); + widget->fit = GTK_BUTTON(gtk_button_new_from_icon_name( + "zoom-fit-best-symbolic")); + widget->zoom_in = GTK_BUTTON(gtk_button_new_from_icon_name( + "zoom-in-symbolic")); + widget->previous_page = GTK_BUTTON(gtk_button_new_from_icon_name( + "go-previous-symbolic")); + widget->next_page = GTK_BUTTON(gtk_button_new_from_icon_name( + "go-next-symbolic")); + widget->zoom_label = GTK_LABEL(gtk_label_new("Ajusté")); + widget->page_label = GTK_LABEL(gtk_label_new("Page 0 / 0")); + gtk_widget_set_name(GTK_WIDGET(widget->zoom_out), + "evidence-preview-zoom-out"); + gtk_widget_set_name(GTK_WIDGET(widget->fit), + "evidence-preview-fit"); + gtk_widget_set_name(GTK_WIDGET(widget->zoom_in), + "evidence-preview-zoom-in"); + gtk_widget_set_name(GTK_WIDGET(widget->previous_page), + "evidence-preview-previous-page"); + gtk_widget_set_name(GTK_WIDGET(widget->next_page), + "evidence-preview-next-page"); + gtk_widget_set_name(GTK_WIDGET(widget->zoom_label), + "evidence-preview-zoom-label"); + gtk_widget_set_name(GTK_WIDGET(widget->page_label), + "evidence-preview-page-label"); + gtk_widget_set_tooltip_text(GTK_WIDGET(widget->zoom_out), "Zoom arrière"); + gtk_widget_set_tooltip_text(GTK_WIDGET(widget->fit), + "Ajuster à la fenêtre"); + gtk_widget_set_tooltip_text(GTK_WIDGET(widget->zoom_in), "Zoom avant"); + gtk_widget_set_tooltip_text(GTK_WIDGET(widget->previous_page), + "Page précédente"); + gtk_widget_set_tooltip_text(GTK_WIDGET(widget->next_page), + "Page suivante"); + gtk_box_append(GTK_BOX(widget->toolbar), + GTK_WIDGET(widget->zoom_out)); + gtk_box_append(GTK_BOX(widget->toolbar), GTK_WIDGET(widget->fit)); + gtk_box_append(GTK_BOX(widget->toolbar), GTK_WIDGET(widget->zoom_in)); + gtk_box_append(GTK_BOX(widget->toolbar), GTK_WIDGET(widget->zoom_label)); + GtkWidget *spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(spacer, TRUE); + gtk_box_append(GTK_BOX(widget->toolbar), spacer); + gtk_box_append(GTK_BOX(widget->toolbar), + GTK_WIDGET(widget->previous_page)); + gtk_box_append(GTK_BOX(widget->toolbar), GTK_WIDGET(widget->page_label)); + gtk_box_append(GTK_BOX(widget->toolbar), + GTK_WIDGET(widget->next_page)); + g_signal_connect(widget->zoom_out, "clicked", + G_CALLBACK(preview_widget_on_zoom_out), widget); + g_signal_connect(widget->fit, "clicked", + G_CALLBACK(preview_widget_on_fit), widget); + g_signal_connect(widget->zoom_in, "clicked", + G_CALLBACK(preview_widget_on_zoom_in), widget); + g_signal_connect(widget->previous_page, "clicked", + G_CALLBACK(preview_widget_on_previous), widget); + g_signal_connect(widget->next_page, "clicked", + G_CALLBACK(preview_widget_on_next), widget); widget->image = GTK_PICTURE(gtk_picture_new()); widget->pdf = GTK_PICTURE(gtk_picture_new()); widget->video = GTK_VIDEO(gtk_video_new()); @@ -220,8 +570,25 @@ EvidencePreviewWidget *evidence_preview_widget_new( GtkPicture *picture = i == 0 ? widget->image : widget->pdf; gtk_picture_set_can_shrink(picture, TRUE); gtk_picture_set_content_fit(picture, GTK_CONTENT_FIT_CONTAIN); - gtk_widget_set_size_request(GTK_WIDGET(picture), 480, 260); + gtk_widget_set_hexpand(GTK_WIDGET(picture), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(picture), TRUE); } + widget->image_scroll = GTK_SCROLLED_WINDOW(gtk_scrolled_window_new()); + widget->pdf_scroll = GTK_SCROLLED_WINDOW(gtk_scrolled_window_new()); + gtk_widget_set_name(GTK_WIDGET(widget->image_scroll), + "evidence-preview-image-scroll"); + gtk_widget_set_name(GTK_WIDGET(widget->pdf_scroll), + "evidence-preview-pdf-scroll"); + gtk_widget_set_size_request(GTK_WIDGET(widget->image_scroll), 480, 260); + gtk_widget_set_size_request(GTK_WIDGET(widget->pdf_scroll), 480, 260); + gtk_widget_set_hexpand(GTK_WIDGET(widget->image_scroll), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(widget->image_scroll), TRUE); + gtk_widget_set_hexpand(GTK_WIDGET(widget->pdf_scroll), TRUE); + gtk_widget_set_vexpand(GTK_WIDGET(widget->pdf_scroll), TRUE); + gtk_scrolled_window_set_child(widget->image_scroll, + GTK_WIDGET(widget->image)); + gtk_scrolled_window_set_child(widget->pdf_scroll, + GTK_WIDGET(widget->pdf)); gtk_widget_set_size_request(GTK_WIDGET(widget->video), 480, 260); gtk_video_set_autoplay(widget->video, FALSE); widget->video_controller = evidence_video_preview_controller_new( @@ -251,11 +618,13 @@ EvidencePreviewWidget *evidence_preview_widget_new( gtk_stack_add_named(widget->stack, gtk_label_new("Aucun aperçu."), "empty"); gtk_stack_add_named(widget->stack, gtk_label_new("Chargement de l’aperçu…"), "loading"); - gtk_stack_add_named(widget->stack, GTK_WIDGET(widget->image), "image"); + gtk_stack_add_named(widget->stack, + GTK_WIDGET(widget->image_scroll), "image"); gtk_stack_add_named(widget->stack, GTK_WIDGET(widget->video), "video"); gtk_stack_add_named(widget->stack, text_scroll, "text"); gtk_stack_add_named(widget->stack, email_scroll, "email"); - gtk_stack_add_named(widget->stack, GTK_WIDGET(widget->pdf), "pdf"); + gtk_stack_add_named(widget->stack, + GTK_WIDGET(widget->pdf_scroll), "pdf"); gtk_stack_add_named(widget->stack, gtk_label_new("Format reconnu mais aperçu indisponible."), "unsupported"); @@ -264,7 +633,10 @@ EvidencePreviewWidget *evidence_preview_widget_new( gtk_stack_set_visible_child_name(widget->stack, "empty"); gtk_box_append(GTK_BOX(widget->root), GTK_WIDGET(widget->metadata)); gtk_box_append(GTK_BOX(widget->root), GTK_WIDGET(widget->status)); + gtk_box_append(GTK_BOX(widget->root), widget->toolbar); gtk_box_append(GTK_BOX(widget->root), GTK_WIDGET(widget->stack)); + preview_widget_apply_zoom(widget); + preview_widget_update_page_controls(widget); g_object_set_data(G_OBJECT(widget->root), "evidence-preview-widget", widget); return widget; @@ -277,35 +649,23 @@ GtkWidget *evidence_preview_widget_get_widget( void evidence_preview_widget_show(EvidencePreviewWidget *widget, const EvidencePreviewRequest *request, const char *metadata) { - EvidencePreviewRequest *owned; - PreviewWidgetContext *context; - GError *error = NULL; if (widget == NULL || request == NULL) return; evidence_preview_widget_cancel(widget); widget->identifier = g_strdup(request->evidence_identifier); - owned = evidence_preview_request_new(request->investigation_root_path, + widget->request = evidence_preview_request_new( + request->investigation_root_path, request->evidence_identifier, request->relative_path, request->expected_sha256, request->mime_type, widget->generation); - gtk_label_set_text(widget->metadata, metadata != NULL ? metadata : ""); - gtk_label_set_text(widget->status, - "Détection, vérification et chargement en cours…"); - gtk_stack_set_visible_child_name(widget->stack, "loading"); - context = g_new0(PreviewWidgetContext, 1); - g_weak_ref_init(&context->root, G_OBJECT(widget->root)); - context->identifier = g_strdup(widget->identifier); - context->generation = widget->generation; - if (owned != NULL) - widget->task = evidence_preview_task_start(widget->task_manager, - owned, preview_widget_completed, context, - preview_widget_context_free, &error); - evidence_preview_request_free(owned); - if (widget->task == NULL) { - preview_widget_context_free(context); - gtk_stack_set_visible_child_name(widget->stack, "error"); - gtk_label_set_text(widget->status, - error != NULL ? error->message : "Aperçu impossible."); - } - g_clear_error(&error); + widget->metadata_text = g_strdup(metadata); + widget->fit_mode = TRUE; + widget->zoom_index = 3; + widget->raster_available = FALSE; + widget->pdf_available = FALSE; + widget->pdf_page = request->pdf_page; + widget->pdf_page_count = 0; + preview_widget_apply_zoom(widget); + preview_widget_update_page_controls(widget); + preview_widget_start_request(widget, request->pdf_page); } void evidence_preview_widget_free(EvidencePreviewWidget *widget) diff --git a/src/widgets/ocr_provenance_overlay.c b/src/widgets/ocr_provenance_overlay.c index 118e82a..0bebd70 100644 --- a/src/widgets/ocr_provenance_overlay.c +++ b/src/widgets/ocr_provenance_overlay.c @@ -3,6 +3,8 @@ struct OcrProvenanceOverlay { GtkWidget *root; + GtkWidget *layer; + GtkScrolledWindow *scroll; GtkPicture *picture; GtkDrawingArea *drawing; GtkLabel *details; @@ -10,6 +12,19 @@ struct OcrProvenanceOverlay { IdentitySourceBox box; gboolean has_box; guint64 generation; + guint page; + guint zoom_index; + gboolean fit_mode; + gint image_width; + gint image_height; + GtkLabel *zoom_label; + GtkButton *zoom_out; + GtkButton *fit; + GtkButton *zoom_in; +}; + +static const double provenance_zoom_factors[] = { + 0.25, 0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 3.00, 4.00 }; static void draw_region(GtkDrawingArea *area, cairo_t *cr, int width, @@ -36,22 +51,119 @@ OcrProvenanceOverlay *ocr_provenance_overlay_new(void) { OcrProvenanceOverlay *overlay = g_new0(OcrProvenanceOverlay, 1); overlay->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); - GtkWidget *layer = gtk_overlay_new(); + overlay->fit_mode = TRUE; + overlay->zoom_index = 3; + GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + overlay->zoom_out = GTK_BUTTON(gtk_button_new_from_icon_name( + "zoom-out-symbolic")); + overlay->fit = GTK_BUTTON(gtk_button_new_from_icon_name( + "zoom-fit-best-symbolic")); + overlay->zoom_in = GTK_BUTTON(gtk_button_new_from_icon_name( + "zoom-in-symbolic")); + overlay->zoom_label = GTK_LABEL(gtk_label_new("Ajusté")); + gtk_widget_set_tooltip_text(GTK_WIDGET(overlay->zoom_out), "Zoom arrière"); + gtk_widget_set_tooltip_text(GTK_WIDGET(overlay->fit), + "Ajuster à la fenêtre"); + gtk_widget_set_tooltip_text(GTK_WIDGET(overlay->zoom_in), "Zoom avant"); + gtk_box_append(GTK_BOX(toolbar), GTK_WIDGET(overlay->zoom_out)); + gtk_box_append(GTK_BOX(toolbar), GTK_WIDGET(overlay->fit)); + gtk_box_append(GTK_BOX(toolbar), GTK_WIDGET(overlay->zoom_in)); + gtk_box_append(GTK_BOX(toolbar), GTK_WIDGET(overlay->zoom_label)); + overlay->layer = gtk_overlay_new(); overlay->picture = GTK_PICTURE(gtk_picture_new()); gtk_picture_set_content_fit(overlay->picture, GTK_CONTENT_FIT_CONTAIN); overlay->drawing = GTK_DRAWING_AREA(gtk_drawing_area_new()); gtk_drawing_area_set_draw_func(overlay->drawing, draw_region, overlay, NULL); - gtk_overlay_set_child(GTK_OVERLAY(layer), GTK_WIDGET(overlay->picture)); - gtk_overlay_add_overlay(GTK_OVERLAY(layer), GTK_WIDGET(overlay->drawing)); - gtk_widget_set_size_request(layer, 360, 220); + gtk_overlay_set_child(GTK_OVERLAY(overlay->layer), + GTK_WIDGET(overlay->picture)); + gtk_overlay_add_overlay(GTK_OVERLAY(overlay->layer), + GTK_WIDGET(overlay->drawing)); + overlay->scroll = GTK_SCROLLED_WINDOW(gtk_scrolled_window_new()); + gtk_widget_set_size_request(GTK_WIDGET(overlay->scroll), 360, 220); + gtk_scrolled_window_set_child(overlay->scroll, overlay->layer); overlay->details = GTK_LABEL(gtk_label_new("Zone source indisponible")); gtk_label_set_wrap(overlay->details, TRUE); - gtk_box_append(GTK_BOX(overlay->root), layer); + gtk_box_append(GTK_BOX(overlay->root), toolbar); + gtk_box_append(GTK_BOX(overlay->root), GTK_WIDGET(overlay->scroll)); gtk_box_append(GTK_BOX(overlay->root), GTK_WIDGET(overlay->details)); + g_signal_connect_swapped(overlay->zoom_out, "clicked", + G_CALLBACK(ocr_provenance_overlay_zoom_out), overlay); + g_signal_connect_swapped(overlay->fit, "clicked", + G_CALLBACK(ocr_provenance_overlay_fit), overlay); + g_signal_connect_swapped(overlay->zoom_in, "clicked", + G_CALLBACK(ocr_provenance_overlay_zoom_in), overlay); return overlay; } +static void provenance_apply_zoom(OcrProvenanceOverlay *overlay) +{ + double factor = provenance_zoom_factors[overlay->zoom_index]; + char *label = overlay->fit_mode ? g_strdup("Ajusté") : + g_strdup_printf("%.0f %%", factor * 100.0); + gtk_label_set_text(overlay->zoom_label, label); + g_free(label); + gtk_widget_set_sensitive(GTK_WIDGET(overlay->zoom_out), + overlay->fit_mode || overlay->zoom_index > 0); + gtk_widget_set_sensitive(GTK_WIDGET(overlay->zoom_in), + overlay->fit_mode || overlay->zoom_index + 1U < + G_N_ELEMENTS(provenance_zoom_factors)); + gtk_widget_set_sensitive(GTK_WIDGET(overlay->fit), !overlay->fit_mode); + if (overlay->fit_mode) { + gtk_widget_set_size_request(overlay->layer, -1, -1); + gtk_widget_set_hexpand(overlay->layer, TRUE); + gtk_widget_set_vexpand(overlay->layer, TRUE); + gtk_scrolled_window_set_policy(overlay->scroll, + GTK_POLICY_NEVER, GTK_POLICY_NEVER); + } else { + gtk_widget_set_hexpand(overlay->layer, FALSE); + gtk_widget_set_vexpand(overlay->layer, FALSE); + gtk_widget_set_size_request(overlay->layer, + MAX(1, (gint) (overlay->image_width * factor)), + MAX(1, (gint) (overlay->image_height * factor))); + gtk_scrolled_window_set_policy(overlay->scroll, + GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + } + gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); +} + +void ocr_provenance_overlay_zoom_in(OcrProvenanceOverlay *overlay) +{ + if (overlay == NULL || overlay->texture == NULL) return; + if (overlay->fit_mode) { + overlay->fit_mode = FALSE; + overlay->zoom_index = 4; + } else if (overlay->zoom_index + 1U < + G_N_ELEMENTS(provenance_zoom_factors)) + overlay->zoom_index++; + provenance_apply_zoom(overlay); +} + +void ocr_provenance_overlay_zoom_out(OcrProvenanceOverlay *overlay) +{ + if (overlay == NULL || overlay->texture == NULL) return; + if (overlay->fit_mode) { + overlay->fit_mode = FALSE; + overlay->zoom_index = 2; + } else if (overlay->zoom_index > 0) + overlay->zoom_index--; + provenance_apply_zoom(overlay); +} + +void ocr_provenance_overlay_fit(OcrProvenanceOverlay *overlay) +{ + if (overlay == NULL || overlay->texture == NULL) return; + overlay->fit_mode = TRUE; + provenance_apply_zoom(overlay); +} + +double ocr_provenance_overlay_get_zoom( + const OcrProvenanceOverlay *overlay) +{ + return overlay != NULL && !overlay->fit_mode + ? provenance_zoom_factors[overlay->zoom_index] : 0.0; +} + void ocr_provenance_overlay_free(OcrProvenanceOverlay *overlay) { if (overlay == NULL) return; @@ -72,8 +184,15 @@ void ocr_provenance_overlay_set_image(OcrProvenanceOverlay *overlay, g_clear_object(&overlay->texture); overlay->texture = bytes != NULL ? gdk_texture_new_from_bytes(bytes, NULL) : NULL; + overlay->image_width = overlay->texture != NULL + ? gdk_texture_get_width(overlay->texture) : 0; + overlay->image_height = overlay->texture != NULL + ? gdk_texture_get_height(overlay->texture) : 0; gtk_picture_set_paintable(overlay->picture, overlay->texture != NULL ? GDK_PAINTABLE(overlay->texture) : NULL); + overlay->fit_mode = TRUE; + overlay->zoom_index = 3; + provenance_apply_zoom(overlay); gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); } @@ -84,7 +203,8 @@ void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay, overlay->generation = generation; const IdentitySourceBox *box = field != NULL ? identity_field_observation_get_box(field) : NULL; - overlay->has_box = box != NULL && box->available; + overlay->has_box = box != NULL && box->available && + (overlay->page == 0 || box->page == (gint) overlay->page); if (overlay->has_box) overlay->box = *box; char *text = overlay->has_box ? g_strdup_printf("%s — confiance %.1f — page %d — " @@ -99,6 +219,20 @@ void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay, gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); } +void ocr_provenance_overlay_set_page( + OcrProvenanceOverlay *overlay, guint page, guint64 generation) +{ + if (overlay == NULL || generation < overlay->generation) return; + overlay->generation = generation; + overlay->page = page; + if (overlay->has_box && overlay->box.page != (gint) page) { + overlay->has_box = FALSE; + gtk_label_set_text(overlay->details, + "Zone source indisponible sur cette page"); + gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); + } +} + void ocr_provenance_overlay_clear(OcrProvenanceOverlay *overlay, guint64 generation) { diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 028f5c9..a5c3e91 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -1183,6 +1183,8 @@ static void workspace_render_identity_ocr(Workspace *workspace) WorkspaceIdentityOcrRecord *record = g_ptr_array_index( workspace->identity_ocr_records, selected); IdentityOcrRun *run = record->run; + ocr_provenance_overlay_set_page(workspace->identity_ocr_overlay, + identity_ocr_run_get_page(run), selected + 1U); char *summary = g_strdup_printf( "Run actif : %s\nUTC : %s\nPersonne liée : %s\n" "Document : %s — %s — page %u\nLangues : %s\n" diff --git a/tests/test_create_person_dialog_gtk.c b/tests/test_create_person_dialog_gtk.c index 5de9df0..d328472 100644 --- a/tests/test_create_person_dialog_gtk.c +++ b/tests/test_create_person_dialog_gtk.c @@ -47,27 +47,6 @@ static GtkWidget *find_entry(GtkWidget *widget, const char *placeholder) return NULL; } -static GtkDropDown *find_evidence_drop_down(GtkWidget *widget) -{ - if (GTK_IS_DROP_DOWN(widget)) { - GListModel *model = gtk_drop_down_get_model(GTK_DROP_DOWN(widget)); - GtkStringObject *first = model != NULL && - g_list_model_get_n_items(model) > 0 - ? g_list_model_get_item(model, 0) : NULL; - gboolean matches = first != NULL && g_strcmp0( - gtk_string_object_get_string(first), - "Aucune preuve associée") == 0; - g_clear_object(&first); - if (matches) return GTK_DROP_DOWN(widget); - } - for (GtkWidget *child = gtk_widget_get_first_child(widget); - child != NULL; child = gtk_widget_get_next_sibling(child)) { - GtkDropDown *match = find_evidence_drop_down(child); - if (match != NULL) return match; - } - return NULL; -} - static GtkDropDown *find_drop_down_starting_with(GtkWidget *widget, const char *first_label) { @@ -89,55 +68,50 @@ static GtkDropDown *find_drop_down_starting_with(GtkWidget *widget, return NULL; } -static GtkWidget *find_first_button(GtkWidget *widget) +static GtkWidget *find_named(GtkWidget *widget, const char *name) { - if (GTK_IS_BUTTON(widget)) return widget; + if (g_strcmp0(gtk_widget_get_name(widget), name) == 0) + return widget; for (GtkWidget *child = gtk_widget_get_first_child(widget); child != NULL; child = gtk_widget_get_next_sibling(child)) { - GtkWidget *match = find_first_button(child); + GtkWidget *match = find_named(child, name); if (match != NULL) return match; } return NULL; } -static GtkListView *find_list_view(GtkWidget *widget, gboolean mapped_only) +static gboolean frame_seen(GtkWidget *widget, GdkFrameClock *clock, + gpointer data) { - if (GTK_IS_LIST_VIEW(widget) && - (!mapped_only || gtk_widget_get_mapped(widget))) - return GTK_LIST_VIEW(widget); - for (GtkWidget *child = gtk_widget_get_first_child(widget); - child != NULL; child = gtk_widget_get_next_sibling(child)) { - GtkListView *match = find_list_view(child, mapped_only); - if (match != NULL) return match; - } - return NULL; + gboolean *seen = data; + (void) widget; + (void) clock; + *seen = TRUE; + return G_SOURCE_REMOVE; } -static GtkListView *find_popup_list_view(GtkDropDown *dropdown) +static void wait_for_frame(GtkWidget *widget) { - GtkWidget *root = GTK_WIDGET(gtk_widget_get_root(GTK_WIDGET(dropdown))); - GtkListView *list_view; - GtkWidget *button = find_first_button(GTK_WIDGET(dropdown)); - g_assert_nonnull(button); - if (GTK_IS_TOGGLE_BUTTON(button)) - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), TRUE); - else - gtk_widget_activate(button); - while (g_main_context_iteration(NULL, FALSE)) {} - list_view = find_list_view(GTK_WIDGET(dropdown), TRUE); - if (list_view == NULL && root != NULL) - list_view = find_list_view(root, TRUE); - return list_view; + gboolean seen = FALSE; + gtk_widget_add_tick_callback(widget, frame_seen, &seen, NULL); + while (!seen) + g_main_context_iteration(NULL, TRUE); } -static void activate_popup_item(GtkListView *list_view, guint position) +static void select_drop_down(GtkDropDown *dropdown, guint position) { - GtkSelectionModel *selection = gtk_list_view_get_model(list_view); - g_assert_nonnull(selection); - g_assert_true(gtk_selection_model_select_item( - selection, position, TRUE)); - g_signal_emit_by_name(list_view, "activate", position); - while (g_main_context_iteration(NULL, FALSE)) {} + g_test_message("sélecteur=%s position=%u mapped=%d visible=%d", + gtk_widget_get_name(GTK_WIDGET(dropdown)), position, + gtk_widget_get_mapped(GTK_WIDGET(dropdown)), + gtk_widget_get_visible(GTK_WIDGET(dropdown))); + g_assert_true(gtk_widget_get_mapped(GTK_WIDGET(dropdown))); + g_assert_true(gtk_widget_activate(GTK_WIDGET(dropdown))); + wait_for_frame(GTK_WIDGET(dropdown)); + g_assert_true(gtk_widget_activate(GTK_WIDGET(dropdown))); + wait_for_frame(GTK_WIDGET(dropdown)); + gtk_drop_down_set_selected(dropdown, position); + g_main_context_iteration(NULL, FALSE); + g_assert_cmpuint(gtk_drop_down_get_selected(dropdown), ==, position); } static GtkWidget *find_label_containing(GtkWidget *widget, const char *text) @@ -183,6 +157,12 @@ static gboolean click_cancel(gpointer data) GtkWidget *button, *designation, *search; GtkDropDown *evidence, *retained_type, *type_filter; g_assert_nonnull(dialog); + g_assert_true(gtk_window_get_transient_for(dialog) == + context->main_window); + g_assert_true(gtk_window_get_modal(dialog)); + g_assert_true(gtk_window_get_destroy_with_parent(dialog)); + gtk_window_set_default_size(dialog, 760, 560); + wait_for_frame(GTK_WIDGET(dialog)); designation = find_entry(GTK_WIDGET(dialog), "Personne présumée liée aux comptes"); g_assert_nonnull(designation); @@ -190,41 +170,32 @@ static gboolean click_cancel(gpointer data) button = find_button(GTK_WIDGET(dialog), "Suivant"); g_assert_nonnull(button); g_signal_emit_by_name(button, "clicked"); + wait_for_frame(GTK_WIDGET(button)); g_signal_emit_by_name(button, "clicked"); + wait_for_frame(GTK_WIDGET(button)); search = find_entry(GTK_WIDGET(dialog), "Rechercher par nom, description ou type"); g_assert_nonnull(search); - evidence = find_evidence_drop_down(GTK_WIDGET(dialog)); + evidence = GTK_DROP_DOWN(find_named(GTK_WIDGET(dialog), + "create-person-evidence-dropdown")); g_assert_nonnull(evidence); context->evidence_model = gtk_drop_down_get_model(evidence); g_assert_nonnull(context->evidence_model); g_object_weak_ref(G_OBJECT(context->evidence_model), evidence_model_destroyed, context); - GtkListView *popup_list = find_popup_list_view(evidence); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 1); - popup_list = find_popup_list_view(evidence); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 2); - popup_list = find_popup_list_view(evidence); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 0); - popup_list = find_popup_list_view(evidence); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 1); + select_drop_down(evidence, 1); + select_drop_down(evidence, 2); + select_drop_down(evidence, 0); + select_drop_down(evidence, 1); gtk_editable_set_text(GTK_EDITABLE(search), "recherche"); g_assert_true(gtk_drop_down_get_model(evidence) == context->evidence_model); g_assert_false(context->evidence_model_destroyed); - type_filter = find_drop_down_starting_with( - GTK_WIDGET(dialog), "Tous les types"); + type_filter = GTK_DROP_DOWN(find_named(GTK_WIDGET(dialog), + "create-person-evidence-type-filter")); g_assert_nonnull(type_filter); - popup_list = find_popup_list_view(type_filter); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 2); - popup_list = find_popup_list_view(evidence); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 1); + select_drop_down(type_filter, 2); + select_drop_down(evidence, 1); g_assert_cmpuint(gtk_drop_down_get_selected(evidence), ==, 1); button = find_button(GTK_WIDGET(dialog), "Ajouter à la sélection"); g_assert_nonnull(button); @@ -236,15 +207,11 @@ static gboolean click_cancel(gpointer data) g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "Type : email (email)")); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "— email")); - popup_list = find_popup_list_view(type_filter); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 0); + select_drop_down(type_filter, 0); gtk_editable_set_text(GTK_EDITABLE(search), "AUTRE"); g_assert_true(gtk_drop_down_get_model(evidence) == context->evidence_model); - popup_list = find_popup_list_view(evidence); - g_assert_nonnull(popup_list); - activate_popup_item(popup_list, 1); + select_drop_down(evidence, 1); button = find_button(GTK_WIDGET(dialog), "Ajouter à la sélection"); g_assert_nonnull(button); g_signal_emit_by_name(button, "clicked"); @@ -317,6 +284,8 @@ int main(int argc, char **argv) g_print("SKIP: aucun affichage GTK disponible.\n"); return 0; } + g_object_set(gtk_settings_get_default(), + "gtk-enable-animations", FALSE, NULL); context.application = gtk_application_new( "org.labfy.Investigation.DialogTest", G_APPLICATION_NON_UNIQUE); diff --git a/tests/test_create_person_dialog_ocr_gtk.c b/tests/test_create_person_dialog_ocr_gtk.c index b8cf387..312425b 100644 --- a/tests/test_create_person_dialog_ocr_gtk.c +++ b/tests/test_create_person_dialog_ocr_gtk.c @@ -22,6 +22,9 @@ typedef struct { guint guard; guint completion_count; guint layout_index; + guint confirmation_stage; + int confirmation_width; + int confirmation_height; gboolean layout_resize_pending; gboolean passed; gboolean persisted; @@ -284,12 +287,26 @@ static gboolean drive(gpointer data) g_assert_cmpstr(gtk_stack_get_visible_child_name(transcription), ==,"raw"); } + { + GtkDropDown *languages=GTK_DROP_DOWN(find_named(root, + "create-person-ocr-languages")); + g_assert_true(GTK_IS_DROP_DOWN(languages)); + guint selected=gtk_drop_down_get_selected(languages); + GListModel *model=gtk_drop_down_get_model(languages); + GtkStringObject *selected_item=g_list_model_get_item( + model,selected); + g_assert_nonnull(selected_item); + g_assert_cmpstr(gtk_string_object_get_string(selected_item),==, + "Français (fra)"); + g_object_unref(selected_item); + } if(find_button(root,"Analyser comme document d’identité")==NULL) return G_SOURCE_CONTINUE; if(!gtk_widget_get_sensitive(find_button(root, "Analyser comme document d’identité")))return G_SOURCE_CONTINUE; g_signal_emit_by_name(find_button(root, "Analyser comme document d’identité"),"clicked"); + g_assert_nonnull(find_label(root,"OCR contrôlé en cours")); context->phase++;break; case 1: if(find_label(root,"OCR terminé")==NULL)return G_SOURCE_CONTINUE; @@ -406,6 +423,60 @@ static gboolean drive(gpointer data) "Analyser comme document d’identité"),"clicked"); context->phase++;break; case 3: + if(context->confirmation_stage>0){ + GtkWidget *scroll=find_named(root, + "create-person-confirmation-scroll"); + GtkWidget *actions=find_named(root,"create-person-actions"); + static const int widths[]={1200,1000,800,760}; + static const int heights[]={800,700,600,560}; + g_assert_true(GTK_IS_SCROLLED_WINDOW(scroll)); + g_assert_true(gtk_widget_get_mapped(scroll)); + g_assert_true(gtk_widget_get_mapped(actions)); + if(context->confirmation_stage==1){ + g_assert_cmpint( + gtk_widget_get_width(GTK_WIDGET(context->dialog)), + ==,context->confirmation_width); + g_assert_cmpint( + gtk_widget_get_height(GTK_WIDGET(context->dialog)), + ==,context->confirmation_height); + GtkAdjustment *adjustment= + gtk_scrolled_window_get_vadjustment( + GTK_SCROLLED_WINDOW(scroll)); + gtk_adjustment_set_value(adjustment, + gtk_adjustment_get_upper(adjustment)- + gtk_adjustment_get_page_size(adjustment)); + g_assert_cmpfloat( + gtk_adjustment_get_value(adjustment),>=,0.0); + gtk_adjustment_set_value(adjustment, + gtk_adjustment_get_lower(adjustment)); + g_signal_emit_by_name( + find_button(root,"Précédent"),"clicked"); + g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); + g_assert_cmpfloat(gtk_adjustment_get_value(adjustment),==, + gtk_adjustment_get_lower(adjustment)); + g_assert_cmpint( + gtk_widget_get_height(GTK_WIDGET(context->dialog)), + ==,context->confirmation_height); + }else{ + int width=0,height=0; + gtk_window_get_default_size( + context->dialog,&width,&height); + g_assert_cmpint(width,==, + widths[context->confirmation_stage-2]); + g_assert_cmpint(height,==, + heights[context->confirmation_stage-2]); + } + if(context->confirmation_stage<=4){ + guint index=context->confirmation_stage-1; + gtk_window_set_default_size(context->dialog, + widths[index],heights[index]); + context->confirmation_stage++; + break; + } + g_signal_emit_by_name(find_button(root, + "Créer la personne"),"clicked"); + context->phase++;break; + } if(find_label(root,"OCR terminé")==NULL)return G_SOURCE_CONTINUE; { GtkStack *transcription=GTK_STACK(find_named(root, @@ -492,14 +563,16 @@ static gboolean drive(gpointer data) button=g_object_get_data( G_OBJECT(pdf_third_row),"identity-reject-button"); g_assert_nonnull(button);g_signal_emit_by_name(button,"clicked"); + context->confirmation_width=gtk_widget_get_width( + GTK_WIDGET(context->dialog)); + context->confirmation_height=gtk_widget_get_height( + GTK_WIDGET(context->dialog)); g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); g_assert_nonnull(find_label(root,"page 2")); g_assert_nonnull(find_label(root,"PAGE 2 VALEUR FINALE")); g_assert_nonnull(find_label(root,"authenticité n’est pas établie")); g_assert_null(find_label(root,"brut : PAGE2")); - g_signal_emit_by_name(find_button(root, - "Créer la personne"),"clicked"); - context->phase++;break; + context->confirmation_stage=1;break; case 4: if(context->completion_count!=1)return G_SOURCE_CONTINUE; g_assert_true(context->persisted); @@ -577,7 +650,7 @@ static void activate(GtkApplication *application,gpointer data) "document",png_size,png_sha,"2026-07-28T10:00:00Z",NULL,NULL, "SPECIMEN synthétique",EVIDENCE_INTEGRITY_STATUS_VALID,&error); g_assert_nonnull(record);evidence_record_set_display_metadata( - record,"Document d’identité","image/png");g_ptr_array_add(records,record); + record,"Document d’identité",NULL);g_ptr_array_add(records,record); record=evidence_record_new( "10000000-0000-4000-8000-000000000002","SPECIMEN-page-2.pdf", "SPECIMEN-page-2.pdf", @@ -611,6 +684,10 @@ static void activate(GtkApplication *application,gpointer data) GList *windows=gtk_application_get_windows(application); context->dialog=windows->data==context->main_window ?GTK_WINDOW(windows->next->data):GTK_WINDOW(windows->data); + g_assert_true(gtk_window_get_transient_for(context->dialog)== + context->main_window); + g_assert_true(gtk_window_get_modal(context->dialog)); + g_assert_true(gtk_window_get_destroy_with_parent(context->dialog)); GtkWidget *designation=find_entry(GTK_WIDGET(context->dialog), "Personne présumée liée aux comptes"); gtk_editable_set_text(GTK_EDITABLE(designation),"PERSONNE SPECIMEN"); diff --git a/tests/test_dialog_geometry_gtk.c b/tests/test_dialog_geometry_gtk.c new file mode 100644 index 0000000..6fc3e35 --- /dev/null +++ b/tests/test_dialog_geometry_gtk.c @@ -0,0 +1,119 @@ +#include "views/dialog_geometry.h" + +static void test_calculations(void) +{ + LabfyDialogGeometry geometry = + labfy_dialog_geometry_calculate(1920, 1080); + g_assert_cmpint(geometry.initial_width, ==, 1200); + g_assert_cmpint(geometry.initial_height, ==, 800); + g_assert_cmpint(geometry.minimum_width, ==, 800); + g_assert_cmpint(geometry.minimum_height, ==, 600); + geometry = labfy_dialog_geometry_calculate(800, 600); + g_assert_cmpint(geometry.initial_width, ==, 800); + g_assert_cmpint(geometry.initial_height, ==, 600); + g_assert_cmpint(geometry.minimum_width, ==, 800); + g_assert_cmpint(geometry.minimum_height, ==, 600); + geometry = labfy_dialog_geometry_calculate(760, 560); + g_assert_cmpint(geometry.initial_width, ==, 684); + g_assert_cmpint(geometry.initial_height, ==, 504); + g_assert_cmpint(geometry.minimum_width, ==, 684); + g_assert_cmpint(geometry.minimum_height, ==, 504); + geometry = labfy_dialog_geometry_calculate(0, 0); + g_assert_cmpint(geometry.initial_width, ==, 1200); + g_assert_cmpint(geometry.initial_height, ==, 800); +} + +static gboolean frame_seen(GtkWidget *widget, GdkFrameClock *clock, + gpointer data) +{ + gboolean *seen = data; + (void) widget; + (void) clock; + *seen = TRUE; + return G_SOURCE_REMOVE; +} + +static void wait_for_frame(GtkWidget *widget) +{ + gboolean seen = FALSE; + gtk_widget_add_tick_callback(widget, frame_seen, &seen, NULL); + while (!seen) + g_main_context_iteration(NULL, TRUE); +} + +static void test_paned_applied_once(void) +{ + GtkWindow *window = GTK_WINDOW(gtk_window_new()); + GtkPaned *paned = GTK_PANED( + gtk_paned_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_paned_set_start_child(paned, gtk_label_new("Formulaire")); + gtk_paned_set_end_child(paned, gtk_label_new("Aperçu")); + gtk_window_set_default_size(window, 900, 600); + gtk_window_set_child(window, GTK_WIDGET(paned)); + labfy_paned_apply_initial_ratio(paned, 2.0 / 3.0, 320, 180); + gtk_window_present(window); + for (guint frame = 0; frame < 6; frame++) + wait_for_frame(GTK_WIDGET(window)); + int expected = (gtk_widget_get_width(GTK_WIDGET(paned)) * 2) / 3; + g_assert_cmpint(ABS(gtk_paned_get_position(paned) - expected), <=, 2); + gtk_paned_set_position(paned, 450); + gtk_window_set_default_size(window, 1000, 700); + wait_for_frame(GTK_WIDGET(window)); + g_assert_cmpint(gtk_paned_get_position(paned), ==, 450); + labfy_paned_apply_initial_ratio(paned, 0.5, 100, 100); + g_assert_cmpint(gtk_paned_get_position(paned), ==, 450); + gtk_window_destroy(window); +} + +static void test_parent_and_presentation(void) +{ + for (guint cycle = 0; cycle < 20; cycle++) { + GtkWindow *parent = GTK_WINDOW(gtk_window_new()); + GtkWindow *dialog = GTK_WINDOW(gtk_window_new()); + GtkWindow *nested = GTK_WINDOW(gtk_window_new()); + GtkWindow *dialog_observer = dialog; + GtkWindow *nested_observer = nested; + int width = 0; + int height = 0; + + gtk_window_present(parent); + labfy_dialog_prepare(dialog, parent, TRUE, TRUE); + g_assert_true(gtk_window_get_transient_for(dialog) == parent); + g_assert_true(gtk_window_get_modal(dialog)); + g_assert_true(gtk_window_get_destroy_with_parent(dialog)); + labfy_dialog_present(dialog); + gtk_window_get_default_size(dialog, &width, &height); + g_assert_cmpint(width, >, 0); + g_assert_cmpint(height, >, 0); + g_assert_true(gtk_widget_get_visible(GTK_WIDGET(dialog))); + + labfy_dialog_prepare(nested, dialog, TRUE, TRUE); + g_assert_true(gtk_window_get_transient_for(nested) == dialog); + labfy_dialog_present(nested); + g_assert_true(gtk_widget_get_visible(GTK_WIDGET(nested))); + + g_object_add_weak_pointer( + G_OBJECT(dialog), (gpointer *) &dialog_observer); + g_object_add_weak_pointer( + G_OBJECT(nested), (gpointer *) &nested_observer); + gtk_window_destroy(parent); + while (g_main_context_iteration(NULL, FALSE)); + g_assert_null(dialog_observer); + g_assert_null(nested_observer); + } +} + +int main(int argc, char **argv) +{ + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + g_test_init(&argc, &argv, NULL); + g_test_add_func("/dialog-geometry/calculations", test_calculations); + g_test_add_func("/dialog-geometry/paned-applied-once", + test_paned_applied_once); + g_test_add_func("/dialog-geometry/parent-and-presentation", + test_parent_and_presentation); + return g_test_run(); +} diff --git a/tests/test_evidence_identity_import_gtk.c b/tests/test_evidence_identity_import_gtk.c index e089feb..64294bd 100644 --- a/tests/test_evidence_identity_import_gtk.c +++ b/tests/test_evidence_identity_import_gtk.c @@ -452,6 +452,10 @@ static gboolean drive(gpointer data) if (context->phase == 0) { GtkWindow *dialog = find_window(context, "Importer une preuve"); if (dialog == NULL) return G_SOURCE_CONTINUE; + g_assert_true(gtk_window_get_transient_for(dialog) == + context->main_window); + g_assert_true(gtk_window_get_modal(dialog)); + g_assert_true(gtk_window_get_destroy_with_parent(dialog)); GtkWidget *paned = find_named(GTK_WIDGET(dialog), "evidence-import-paned"); g_assert_true(GTK_IS_PANED(paned)); @@ -465,11 +469,12 @@ static gboolean drive(gpointer data) GtkWindow *dialog = find_window( context, "Import — OCR d’identité facultatif"); if (dialog == NULL) return G_SOURCE_CONTINUE; + g_assert_nonnull(gtk_window_get_transient_for(dialog)); + g_assert_true(gtk_window_get_modal(dialog)); + g_assert_true(gtk_window_get_destroy_with_parent(dialog)); GtkWidget *root = GTK_WIDGET(dialog); int default_width = 0; int default_height = 0; - if (!context->layout_resize_pending) - gtk_window_set_default_size(dialog, 1200, 800); gtk_window_get_default_size( dialog, &default_width, &default_height); g_assert_cmpint(default_width, ==, @@ -490,14 +495,16 @@ static gboolean drive(gpointer data) g_assert_true(gtk_paned_get_start_child(GTK_PANED(paned)) == left); g_assert_nonnull(gtk_paned_get_end_child(GTK_PANED(paned))); g_assert_false(is_descendant_of(actions, form_scroll)); - if (g_object_get_data( - G_OBJECT(paned), "identity-initial-position-set") == NULL) + if (gtk_widget_get_width(paned) <= 0 || + gtk_paned_get_position(GTK_PANED(paned)) <= 0) return G_SOURCE_CONTINUE; if (!context->layout_resize_pending) { int initial_position = gtk_paned_get_position(GTK_PANED(paned)); - g_assert_cmpint(initial_position, >=, 780); - g_assert_cmpint(initial_position, <=, 820); + int expected_position = + (gtk_widget_get_width(paned) * 2) / 3; + g_assert_cmpint( + ABS(initial_position - expected_position), <=, 3); gtk_paned_set_position(GTK_PANED(paned), 650); g_assert_cmpint( gtk_paned_get_position(GTK_PANED(paned)), ==, 650); diff --git a/tests/test_evidence_metadata_dialog_gtk.c b/tests/test_evidence_metadata_dialog_gtk.c index 49ae2b5..8f79b06 100644 --- a/tests/test_evidence_metadata_dialog_gtk.c +++ b/tests/test_evidence_metadata_dialog_gtk.c @@ -322,6 +322,12 @@ static gboolean drive(gpointer data) g_test_message("drive phase=%u fenêtre=%s cycles=%u", context->phase, dialog != NULL ? "oui" : "non", context->close_cycles); + if (dialog != NULL) { + g_assert_true(gtk_window_get_transient_for(dialog) == + context->main_window); + g_assert_true(gtk_window_get_modal(dialog)); + g_assert_true(gtk_window_get_destroy_with_parent(dialog)); + } if (dialog == NULL) { if (context->phase == 1) { insert_run(context, FALSE); diff --git a/tests/test_evidence_preview.c b/tests/test_evidence_preview.c index 403a2ff..1f7ea90 100644 --- a/tests/test_evidence_preview.c +++ b/tests/test_evidence_preview.c @@ -222,6 +222,64 @@ static void test_preview_pdf_first_page(void) g_assert_cmpint(g_rmdir(root), ==, 0); g_free(path); g_free(root); } +static void test_preview_pdf_multiple_pages_and_limits(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-preview-pdf-pages-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN-3-pages.pdf", NULL); + cairo_surface_t *surface = cairo_pdf_surface_create(path, 320, 200); + cairo_t *cr = cairo_create(surface); + for (guint page = 1; page <= 3; page++) { + char *label = g_strdup_printf("PAGE %u SPECIMEN", page); + cairo_set_source_rgb(cr, page / 3.0, 0.2, 0.4); + cairo_paint(cr); + cairo_set_source_rgb(cr, 1, 1, 1); + cairo_move_to(cr, 20, 40); + cairo_show_text(cr, label); + cairo_show_page(cr); + g_free(label); + } + cairo_destroy(cr); + cairo_surface_destroy(surface); + char *sha256 = NULL; + guint64 size = 0; + g_assert_true(file_hash_compute_sha256( + path, NULL, &sha256, &size, &error)); + g_assert_no_error(error); + EvidencePreviewRequest *request = evidence_preview_request_new( + root, "10000000-0000-4000-8000-000000000120", + "SPECIMEN-3-pages.pdf", sha256, "application/pdf", 20); + EvidencePreviewResult *first = + evidence_preview_load(request, NULL, &error); + g_assert_no_error(error); + g_assert_cmpuint(first->item_count, ==, 3); + g_assert_cmpuint(first->current_page, ==, 1); + evidence_preview_result_free(first); + evidence_preview_request_set_pdf_page(request, 1); + EvidencePreviewResult *second = + evidence_preview_load(request, NULL, &error); + g_assert_no_error(error); + g_assert_cmpuint(second->current_page, ==, 2); + evidence_preview_result_free(second); + evidence_preview_request_set_pdf_page(request, 2); + EvidencePreviewResult *last = + evidence_preview_load(request, NULL, &error); + g_assert_no_error(error); + g_assert_cmpuint(last->current_page, ==, 3); + evidence_preview_result_free(last); + evidence_preview_request_set_pdf_page(request, 3); + g_assert_null(evidence_preview_load(request, NULL, &error)); + g_assert_error(error, + g_quark_from_static_string("evidence-preview-error"), 8); + g_clear_error(&error); + evidence_preview_request_free(request); + g_free(sha256); + g_assert_cmpint(g_remove(path), ==, 0); + g_assert_cmpint(g_rmdir(root), ==, 0); + g_free(path); + g_free(root); +} + static gboolean write_heic_specimen(const char *path, gint width, gint height) { struct heif_context *context = heif_context_alloc(); @@ -379,6 +437,8 @@ int main(int argc, char **argv) test_preview_dispatch_text_video_email); g_test_add_func("/evidence-preview/pdf-first-page", test_preview_pdf_first_page); + g_test_add_func("/evidence-preview/pdf-multiple-pages-limits", + test_preview_pdf_multiple_pages_and_limits); g_test_add_func("/evidence-preview/real-heic", test_preview_real_heic); g_test_add_func("/evidence-preview/heic-cancel-invalid", diff --git a/tests/test_evidence_preview_widget_gtk.c b/tests/test_evidence_preview_widget_gtk.c index c76d7d2..3fda935 100644 --- a/tests/test_evidence_preview_widget_gtk.c +++ b/tests/test_evidence_preview_widget_gtk.c @@ -11,6 +11,58 @@ static void drain_until(EvidencePreviewWidget *widget, const char *state) g_main_context_iteration(NULL, TRUE); } +static gboolean mark_frame(GtkWidget *widget, GdkFrameClock *clock, + gpointer data) +{ + gboolean *frame_seen = data; + (void) widget; + (void) clock; + *frame_seen = TRUE; + return G_SOURCE_REMOVE; +} + +static void wait_for_frame(GtkWidget *widget) +{ + gboolean frame_seen = FALSE; + gtk_widget_add_tick_callback(widget, mark_frame, &frame_seen, NULL); + while (!frame_seen) + g_main_context_iteration(NULL, TRUE); +} + +static gboolean task_manager_has_running_task(TaskManager *manager) +{ + gsize count = task_manager_get_count(manager); + for (gsize index = 0; index < count; index++) { + BackgroundTask *task = task_manager_get_task(manager, index); + BackgroundTaskState state = background_task_get_state(task); + background_task_unref(task); + if (state == BACKGROUND_TASK_STATE_PENDING || + state == BACKGROUND_TASK_STATE_RUNNING) + return TRUE; + } + return FALSE; +} + +static GtkWidget *find_named(GtkWidget *widget, const char *name) +{ + if (g_strcmp0(gtk_widget_get_name(widget), name) == 0) return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *found = find_named(child, name); + if (found != NULL) return found; + } + return NULL; +} + +static void click_named(EvidencePreviewWidget *widget, const char *name) +{ + GtkWidget *button = find_named( + evidence_preview_widget_get_widget(widget), name); + g_assert_nonnull(button); + g_assert_true(gtk_widget_get_sensitive(button)); + g_signal_emit_by_name(button, "clicked"); +} + static void test_widget_lifecycle_and_eml(void) { static const char eml[] = @@ -89,12 +141,17 @@ static void write_pdf(const char *path) { cairo_surface_t *surface = cairo_pdf_surface_create(path, 640, 360); cairo_t *cr = cairo_create(surface); - cairo_set_source_rgb(cr, 1, 1, 1); - cairo_paint(cr); - cairo_set_source_rgb(cr, 0, 0, 0); - cairo_move_to(cr, 30, 80); - cairo_show_text(cr, "PAGE PDF SPECIMEN"); - cairo_show_page(cr); + for (guint page = 1; page <= 3; page++) { + char *label = g_strdup_printf("PAGE PDF %u SPECIMEN", page); + cairo_set_source_rgb(cr, page == 1 ? 0.8 : 0.2, + page == 2 ? 0.8 : 0.2, page == 3 ? 0.8 : 0.2); + cairo_paint(cr); + cairo_set_source_rgb(cr, 0, 0, 0); + cairo_move_to(cr, 30, 80); + cairo_show_text(cr, label); + cairo_show_page(cr); + g_free(label); + } cairo_destroy(cr); cairo_surface_destroy(surface); } @@ -161,6 +218,34 @@ static void test_paned_png_jpeg_pdf(void) evidence_preview_widget_show(widget, png_request, "PNG SPECIMEN"); drain_until(widget, "image"); g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "image"); + g_assert_true(evidence_preview_widget_is_fit(widget)); + click_named(widget, "evidence-preview-zoom-in"); + g_assert_cmpfloat(evidence_preview_widget_get_zoom(widget), ==, 1.25); + for (guint index = 0; index < 4; index++) + click_named(widget, "evidence-preview-zoom-in"); + g_assert_cmpfloat(evidence_preview_widget_get_zoom(widget), ==, 4.0); + wait_for_frame(GTK_WIDGET(window)); + GtkScrolledWindow *image_scroll = GTK_SCROLLED_WINDOW(find_named( + evidence_preview_widget_get_widget(widget), + "evidence-preview-image-scroll")); + GtkAdjustment *horizontal = + gtk_scrolled_window_get_hadjustment(image_scroll); + GtkAdjustment *vertical = + gtk_scrolled_window_get_vadjustment(image_scroll); + g_assert_cmpfloat(gtk_adjustment_get_upper(horizontal), >, + gtk_adjustment_get_page_size(horizontal)); + g_assert_cmpfloat(gtk_adjustment_get_upper(vertical), >, + gtk_adjustment_get_page_size(vertical)); + gtk_adjustment_set_value(horizontal, + gtk_adjustment_get_upper(horizontal) / 3.0); + gtk_adjustment_set_value(vertical, + gtk_adjustment_get_upper(vertical) / 3.0); + g_assert_cmpfloat(gtk_adjustment_get_value(horizontal), >, 0.0); + g_assert_cmpfloat(gtk_adjustment_get_value(vertical), >, 0.0); + click_named(widget, "evidence-preview-zoom-out"); + g_assert_cmpfloat(evidence_preview_widget_get_zoom(widget), ==, 3.0); + click_named(widget, "evidence-preview-fit"); + g_assert_true(evidence_preview_widget_is_fit(widget)); EvidencePreviewRequest *jpeg_request = request_for( directory, "SPECIMEN.jpg", "10000000-0000-4000-8000-000000000002", "image/jpeg", 2); @@ -173,10 +258,48 @@ static void test_paned_png_jpeg_pdf(void) evidence_preview_widget_show(widget, pdf_request, "PDF SPECIMEN"); drain_until(widget, "pdf"); g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "pdf"); + g_assert_cmpuint(evidence_preview_widget_get_pdf_page_count(widget), ==, 3); + g_assert_cmpuint(evidence_preview_widget_get_pdf_page(widget), ==, 0); + GtkWidget *previous = find_named( + evidence_preview_widget_get_widget(widget), + "evidence-preview-previous-page"); + GtkWidget *next = find_named( + evidence_preview_widget_get_widget(widget), + "evidence-preview-next-page"); + GtkLabel *page_label = GTK_LABEL(find_named( + evidence_preview_widget_get_widget(widget), + "evidence-preview-page-label")); + g_assert_false(gtk_widget_get_sensitive(previous)); + g_assert_true(gtk_widget_get_sensitive(next)); + g_assert_cmpstr(gtk_label_get_text(page_label), ==, "Page 1 / 3"); + click_named(widget, "evidence-preview-next-page"); + drain_until(widget, "pdf"); + g_assert_cmpuint(evidence_preview_widget_get_pdf_page(widget), ==, 1); + g_assert_cmpstr(gtk_label_get_text(page_label), ==, "Page 2 / 3"); + click_named(widget, "evidence-preview-zoom-in"); + g_assert_cmpfloat(evidence_preview_widget_get_zoom(widget), ==, 1.25); + click_named(widget, "evidence-preview-next-page"); + drain_until(widget, "pdf"); + g_assert_cmpuint(evidence_preview_widget_get_pdf_page(widget), ==, 2); + g_assert_false(gtk_widget_get_sensitive(next)); + g_assert_cmpfloat(evidence_preview_widget_get_zoom(widget), ==, 1.25); + click_named(widget, "evidence-preview-previous-page"); + click_named(widget, "evidence-preview-previous-page"); + drain_until(widget, "pdf"); + g_assert_cmpuint(evidence_preview_widget_get_pdf_page(widget), ==, 0); + g_assert_false(gtk_widget_get_sensitive(previous)); + click_named(widget, "evidence-preview-next-page"); + click_named(widget, "evidence-preview-next-page"); + click_named(widget, "evidence-preview-previous-page"); + drain_until(widget, "pdf"); + g_assert_cmpuint(evidence_preview_widget_get_pdf_page(widget), ==, 1); gtk_window_set_default_size(window, 760, 560); gtk_paned_set_position(GTK_PANED(paned), 300); while (g_main_context_iteration(NULL, FALSE)); g_assert_cmpint(gtk_paned_get_position(GTK_PANED(paned)), ==, 300); + g_assert_true(gtk_widget_get_mapped(find_named( + evidence_preview_widget_get_widget(widget), + "evidence-preview-toolbar"))); evidence_preview_widget_show(widget, png_request, "résultat ancien"); evidence_preview_widget_show(widget, jpeg_request, "résultat courant"); drain_until(widget, "image"); @@ -199,6 +322,46 @@ static void test_paned_png_jpeg_pdf(void) g_free(directory); } +static void test_pdf_twenty_open_close_cycles(void) +{ + GError *error = NULL; + char *directory = + g_dir_make_tmp("labfy-preview-cycles-XXXXXX", &error); + g_assert_no_error(error); + char *pdf = g_build_filename(directory, "SPECIMEN-multipage.pdf", NULL); + write_pdf(pdf); + TaskManager *manager = task_manager_new(); + EvidencePreviewRequest *request = request_for( + directory, "SPECIMEN-multipage.pdf", + "10000000-0000-4000-8000-000000000020", "application/pdf", 20); + for (guint cycle = 0; cycle < 20; cycle++) { + EvidencePreviewWidget *widget = + evidence_preview_widget_new(manager, NULL, NULL); + GtkWindow *window = GTK_WINDOW(gtk_window_new()); + gtk_window_set_default_size(window, 760, 560); + gtk_window_set_child(window, + evidence_preview_widget_get_widget(widget)); + gtk_window_present(window); + evidence_preview_widget_show(widget, request, "PDF SPECIMEN"); + if ((cycle % 2U) == 0) { + drain_until(widget, "pdf"); + click_named(widget, "evidence-preview-next-page"); + } + gtk_window_destroy(window); + evidence_preview_widget_cancel(widget); + evidence_preview_widget_free(widget); + } + while (task_manager_has_running_task(manager)) + g_main_context_iteration(NULL, TRUE); + while (g_main_context_iteration(NULL, FALSE)); + task_manager_free(manager); + evidence_preview_request_free(request); + g_unlink(pdf); + g_rmdir(directory); + g_free(pdf); + g_free(directory); +} + int main(int argc, char **argv) { if (!gtk_init_check()) { @@ -210,5 +373,7 @@ int main(int argc, char **argv) test_widget_lifecycle_and_eml); g_test_add_func("/evidence-preview-widget/paned-png-jpeg-pdf", test_paned_png_jpeg_pdf); + g_test_add_func("/evidence-preview-widget/pdf-twenty-open-close-cycles", + test_pdf_twenty_open_close_cycles); return g_test_run(); } diff --git a/tests/test_ocr_provenance_overlay_gtk.c b/tests/test_ocr_provenance_overlay_gtk.c index 8707e30..d300b51 100644 --- a/tests/test_ocr_provenance_overlay_gtk.c +++ b/tests/test_ocr_provenance_overlay_gtk.c @@ -1,4 +1,5 @@ #include "widgets/ocr_provenance_overlay.h" +#include int main(int argc, char **argv) { @@ -10,22 +11,45 @@ int main(int argc, char **argv) OcrProvenanceOverlay *overlay = ocr_provenance_overlay_new(); GtkWidget *widget = ocr_provenance_overlay_get_widget(overlay); g_object_ref_sink(widget); + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, 800, 500); + char *path = g_strdup_printf("%s/labfy-provenance-%u.png", + g_get_tmp_dir(), g_random_int()); + g_assert_cmpint(cairo_surface_write_to_png(surface, path), + ==, CAIRO_STATUS_SUCCESS); + cairo_surface_destroy(surface); + char *contents = NULL; + gsize length = 0; + g_assert_true(g_file_get_contents(path, &contents, &length, NULL)); + GBytes *bytes = g_bytes_new_take(contents, length); + ocr_provenance_overlay_set_image(overlay, bytes, 1); + ocr_provenance_overlay_set_page(overlay, 2, 2); IdentitySourceBox box = {.page=2,.x=10,.y=20,.width=80,.height=25, .image_width=800,.image_height=500,.available=TRUE}; IdentityFieldObservation *field = identity_field_observation_new( "surname", "SPECIMEN", 88, &box, 0); ocr_provenance_overlay_set_field(overlay, field, 2); g_assert_true(ocr_provenance_overlay_has_region(overlay)); + ocr_provenance_overlay_zoom_in(overlay); + g_assert_cmpfloat(ocr_provenance_overlay_get_zoom(overlay), ==, 1.25); + ocr_provenance_overlay_zoom_out(overlay); + ocr_provenance_overlay_fit(overlay); + g_assert_cmpfloat(ocr_provenance_overlay_get_zoom(overlay), ==, 0.0); + ocr_provenance_overlay_set_page(overlay, 3, 3); + g_assert_false(ocr_provenance_overlay_has_region(overlay)); IdentityFieldObservation *without = identity_field_observation_new( "given_names", "ALICE", 70, NULL, 1); - ocr_provenance_overlay_set_field(overlay, without, 3); + ocr_provenance_overlay_set_field(overlay, without, 4); g_assert_false(ocr_provenance_overlay_has_region(overlay)); ocr_provenance_overlay_set_field(overlay, field, 2); g_assert_false(ocr_provenance_overlay_has_region(overlay)); - ocr_provenance_overlay_clear(overlay, 4); + ocr_provenance_overlay_clear(overlay, 5); identity_field_observation_free(field); identity_field_observation_free(without); ocr_provenance_overlay_free(overlay); g_object_unref(widget); + g_bytes_unref(bytes); + g_remove(path); + g_free(path); return 0; }