From dae5f6393209f777653ccf1eb81704779cc12dac Mon Sep 17 00:00:00 2001 From: grayTerminal-sh Date: Wed, 29 Jul 2026 20:55:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(ocr):=20int=C3=A9grer=20la=20r=C3=A9vision?= =?UTF-8?q?=20contr=C3=B4l=C3=A9e=20aux=20preuves=20d=E2=80=99identit?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 116 +- README.md | 15 + database/schema_current.sql | 53 + database/schema_v15.sql | 78 ++ database/schema_v16.sql | 39 + database/schema_v17.sql | 11 + docs/ARCHITECTURE.md | 19 + docs/CONVENTIONS.md | 14 + docs/DEPENDENCE.md | 7 + docs/DEVELOPMENT.md | 11 + docs/ROADMAP.md | 13 + docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md | 35 - docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md | 73 -- include/core/evidence_identity_import_task.h | 45 + include/core/identity_field_extractor.h | 8 + include/core/identity_ocr_preprocessor.h | 18 + include/core/identity_ocr_workflow.h | 35 + include/core/mrz_parser.h | 12 + include/core/ocr_analysis.h | 6 + include/core/ocr_region_geometry.h | 23 + include/core/person_creation_coordinator.h | 57 + include/core/person_creation_task.h | 2 +- include/dao/identity_ocr_dao.h | 62 + include/database/schema.h | 3 + include/models/identity_ocr.h | 96 ++ include/views/create_person_dialog.h | 8 +- include/views/evidence_identity_ocr_dialog.h | 60 + include/views/evidence_metadata_dialog.h | 11 + include/views/main_window.h | 8 + include/views/manage_entity_evidence_dialog.h | 4 + include/widgets/ocr_provenance_overlay.h | 21 + include/widgets/workspace.h | 26 + opencode.json.backup-7b | 151 --- src/core/application.c | 701 ++++++++++- src/core/evidence_identity_import_task.c | 349 ++++++ src/core/evidence_preview.c | 44 + src/core/identity_field_extractor.c | 44 + src/core/identity_ocr_preprocessor.c | 124 ++ src/core/identity_ocr_workflow.c | 98 ++ src/core/mrz_parser.c | 31 + src/core/ocr_analysis.c | 77 +- src/core/ocr_region_geometry.c | 26 + src/core/person_creation_coordinator.c | 318 ++++- src/core/person_creation_task.c | 15 +- src/dao/identity_ocr_dao.c | 437 +++++++ src/database/database.c | 61 +- src/database/schema.c | 30 + src/models/identity_ocr.c | 242 ++++ src/models/person_evidence_selection.c | 7 +- src/views/create_person_dialog.c | 972 ++++++++++++++- src/views/evidence_identity_ocr_dialog.c | 1043 +++++++++++++++++ src/views/evidence_import_dialog.c | 70 +- src/views/evidence_metadata_dialog.c | 410 ++++++- src/views/main_window.c | 40 +- src/views/manage_entity_evidence_dialog.c | 122 +- src/views/metadata_analysis_dialog.c | 21 + src/widgets/ocr_provenance_overlay.c | 118 ++ src/widgets/workspace.c | 306 +++++ tests/fake_document_tool | Bin 0 -> 16888 bytes tests/fake_document_tool.c | 29 +- tests/test_create_person_dialog_gtk.c | 5 +- tests/test_create_person_dialog_ocr_gtk.c | 651 ++++++++++ tests/test_database.c | 6 +- tests/test_evidence_identity_import_gtk.c | 803 +++++++++++++ tests/test_evidence_metadata_dialog_gtk.c | 513 ++++++++ tests/test_evidence_preview_widget_gtk.c | 130 ++ tests/test_identity_ocr.c | 119 ++ tests/test_identity_ocr_preprocessor.c | 275 +++++ tests/test_ocr_provenance_overlay_gtk.c | 31 + tests/test_person_creation_coordinator.c | 484 +++++++- tests/test_person_evidence_selection.c | 34 + tests/test_workspace_identity_ocr_gtk.c | 288 +++++ 72 files changed, 9808 insertions(+), 406 deletions(-) create mode 100644 database/schema_v15.sql create mode 100644 database/schema_v16.sql create mode 100644 database/schema_v17.sql delete mode 100644 docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md delete mode 100644 docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md create mode 100644 include/core/evidence_identity_import_task.h create mode 100644 include/core/identity_field_extractor.h create mode 100644 include/core/identity_ocr_preprocessor.h create mode 100644 include/core/identity_ocr_workflow.h create mode 100644 include/core/mrz_parser.h create mode 100644 include/core/ocr_region_geometry.h create mode 100644 include/dao/identity_ocr_dao.h create mode 100644 include/models/identity_ocr.h create mode 100644 include/views/evidence_identity_ocr_dialog.h create mode 100644 include/widgets/ocr_provenance_overlay.h delete mode 100644 opencode.json.backup-7b create mode 100644 src/core/evidence_identity_import_task.c create mode 100644 src/core/identity_field_extractor.c create mode 100644 src/core/identity_ocr_preprocessor.c create mode 100644 src/core/identity_ocr_workflow.c create mode 100644 src/core/mrz_parser.c create mode 100644 src/core/ocr_region_geometry.c create mode 100644 src/dao/identity_ocr_dao.c create mode 100644 src/models/identity_ocr.c create mode 100644 src/views/evidence_identity_ocr_dialog.c create mode 100644 src/widgets/ocr_provenance_overlay.c create mode 100755 tests/fake_document_tool create mode 100644 tests/test_create_person_dialog_ocr_gtk.c create mode 100644 tests/test_evidence_identity_import_gtk.c create mode 100644 tests/test_evidence_metadata_dialog_gtk.c create mode 100644 tests/test_identity_ocr.c create mode 100644 tests/test_identity_ocr_preprocessor.c create mode 100644 tests/test_ocr_provenance_overlay_gtk.c create mode 100644 tests/test_workspace_identity_ocr_gtk.c diff --git a/Makefile b/Makefile index aabdce5..c873c61 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,7 @@ TEST_EVIDENCE_SELECTION_MODEL := tests/test_evidence_selection_model TEST_PERSON_CREATION_GUARD := tests/test_person_creation_guard TEST_PERSON_DIALOG_LIFECYCLE := tests/test_person_dialog_lifecycle TEST_CREATE_PERSON_DIALOG_GTK := tests/test_create_person_dialog_gtk +TEST_CREATE_PERSON_DIALOG_OCR_GTK := tests/test_create_person_dialog_ocr_gtk TEST_EVIDENCE_PREVIEW_WIDGET_GTK := tests/test_evidence_preview_widget_gtk TEST_PERSON_CONFIRMATION_SUMMARY := tests/test_person_confirmation_summary TEST_PERSON_ROLE_ASSIGNMENT_DAO := tests/test_person_role_assignment_dao @@ -157,6 +158,12 @@ TEST_EXIFTOOL_ANALYSIS := tests/test_exiftool_analysis TEST_OCR_ANALYSIS := tests/test_ocr_analysis TEST_PDF_ANALYSIS := tests/test_pdf_analysis TEST_DOCUMENT_TOOL_RUNNER := tests/test_document_tool_runner +TEST_IDENTITY_OCR := tests/test_identity_ocr +TEST_IDENTITY_OCR_PREPROCESSOR := tests/test_identity_ocr_preprocessor +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 FAKE_DOCUMENT_TOOL := tests/fake_document_tool DOCUMENT_ANALYSIS_TEST_SOURCES := \ @@ -173,6 +180,42 @@ $(TEST_DOCUMENT_TOOL_RUNNER): tests/test_document_tool_runner.c \ all: $(TARGET) +$(TEST_IDENTITY_OCR): tests/test_identity_ocr.c \ + src/models/identity_ocr.c src/core/identity_field_extractor.c \ + src/core/mrz_parser.c src/core/ocr_region_geometry.c \ + src/core/ocr_analysis.c $(DOCUMENT_ANALYSIS_TEST_SOURCES) + $(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS) + +$(TEST_IDENTITY_OCR_PREPROCESSOR): tests/test_identity_ocr_preprocessor.c \ + src/core/identity_ocr_preprocessor.c src/core/evidence_preview.c \ + src/core/eml_analyzer.c src/core/eml_mime_extractor.c \ + src/core/evidence_integrity_verifier.c src/core/file_hash.c \ + src/core/ocr_analysis.c src/core/document_analysis.c \ + src/core/document_tool_runner.c src/core/tool_process.c \ + src/core/tool_registry.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + +$(TEST_OCR_PROVENANCE_OVERLAY_GTK): \ + tests/test_ocr_provenance_overlay_gtk.c \ + src/widgets/ocr_provenance_overlay.c src/core/ocr_region_geometry.c \ + src/models/identity_ocr.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + +$(TEST_EVIDENCE_METADATA_DIALOG_GTK): \ + tests/test_evidence_metadata_dialog_gtk.c \ + $(filter-out src/main.c,$(SRC)) + $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ $(LDFLAGS) + +$(TEST_EVIDENCE_IDENTITY_IMPORT_GTK): \ + tests/test_evidence_identity_import_gtk.c \ + $(filter-out src/main.c,$(SRC)) $(FAKE_DOCUMENT_TOOL) + $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ $(LDFLAGS) + +$(TEST_WORKSPACE_IDENTITY_OCR_GTK): \ + tests/test_workspace_identity_ocr_gtk.c \ + $(filter-out src/main.c,$(SRC)) + $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ $(LDFLAGS) + $(TEST_BANK_PROPOSAL): \ tests/test_bank_proposal.c \ src/core/bank_proposal.c \ @@ -528,10 +571,16 @@ $(TEST_EVIDENCE_TYPE_DAO): \ $(TEST_EVIDENCE_IMPORT_DIALOG): \ tests/test_evidence_import_dialog.c \ src/views/evidence_import_dialog.c \ - src/models/evidence_type.c - $(CC) $(EVIDENCE_IMPORT_DIALOG_TEST_CFLAGS) $^ \ + src/models/evidence_type.c \ + src/widgets/evidence_preview_widget.c \ + src/core/evidence_preview_task.c src/core/evidence_preview.c \ + src/core/evidence_video_preview_controller.c \ + src/core/evidence_integrity_verifier.c src/core/file_hash.c \ + src/core/background_task.c src/core/task_manager.c \ + src/core/eml_analyzer.c src/core/eml_mime_extractor.c + $(CC) $(CFLAGS) $^ \ -o $@ \ - $(EVIDENCE_IMPORT_DIALOG_TEST_LDFLAGS) + $(LDFLAGS) $(TEST_EVIDENCE_CATEGORY_ITEM): \ tests/test_evidence_category_item.c \ @@ -840,6 +889,13 @@ $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ src/core/evidence_preview_task.c src/core/evidence_preview.c \ src/core/eml_analyzer.c src/core/eml_mime_extractor.c \ src/core/evidence_video_preview_controller.c \ + src/core/identity_ocr_preprocessor.c \ + src/core/identity_ocr_workflow.c \ + src/core/identity_field_extractor.c src/core/ocr_analysis.c \ + src/core/document_analysis.c src/core/document_tool_runner.c \ + src/core/tool_registry.c \ + src/models/identity_ocr.c \ + src/widgets/ocr_provenance_overlay.c src/core/ocr_region_geometry.c \ src/widgets/evidence_preview_widget.c \ src/core/evidence_integrity_verifier.c src/core/file_hash.c \ src/core/background_task.c src/core/task_manager.c \ @@ -848,6 +904,36 @@ $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ src/models/person_evidence_selection.c $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) +$(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/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 \ + src/core/eml_analyzer.c src/core/eml_mime_extractor.c \ + src/core/evidence_video_preview_controller.c \ + src/core/identity_ocr_preprocessor.c \ + src/core/identity_ocr_workflow.c \ + src/core/identity_field_extractor.c src/core/ocr_analysis.c \ + src/core/person_creation_coordinator.c \ + src/core/document_analysis.c src/core/document_tool_runner.c \ + src/core/tool_registry.c src/models/identity_ocr.c \ + src/dao/identity_ocr_dao.c src/dao/entity_dao.c \ + src/dao/evidence_dao.c src/dao/evidence_entity_dao.c \ + src/dao/person_role_assignment_dao.c \ + src/models/entity_record.c src/models/evidence_observation.c \ + src/widgets/ocr_provenance_overlay.c src/core/ocr_region_geometry.c \ + src/widgets/evidence_preview_widget.c \ + src/core/evidence_integrity_verifier.c src/core/file_hash.c \ + src/core/background_task.c src/core/task_manager.c \ + src/models/evidence_selection_model.c src/models/evidence_record.c \ + src/models/person_role_assignment.c \ + src/models/person_evidence_selection.c \ + src/database/database.c src/database/schema.c src/database/statement.c \ + src/database/transaction.c src/database/error.c \ + src/core/relation_type_normalizer.c $(FAKE_DOCUMENT_TOOL) + $(CC) $(CFLAGS) $(filter %.c,$^) -o $@ $(LDFLAGS) + $(TEST_EVIDENCE_PREVIEW_WIDGET_GTK): \ tests/test_evidence_preview_widget_gtk.c \ src/widgets/evidence_preview_widget.c \ @@ -886,6 +972,7 @@ $(TEST_PERSON_CREATION_COORDINATOR): \ tests/test_person_creation_coordinator.c \ src/core/person_creation_coordinator.c src/core/evidence_staging.c \ src/core/file_hash.c \ + src/models/identity_ocr.c src/dao/identity_ocr_dao.c \ src/models/person_evidence_selection.c src/models/evidence_record.c \ src/models/person_role_assignment.c src/models/entity_record.c \ src/models/evidence_observation.c \ @@ -1007,6 +1094,7 @@ test: \ $(TEST_PERSON_CREATION_GUARD) \ $(TEST_PERSON_DIALOG_LIFECYCLE) \ $(TEST_CREATE_PERSON_DIALOG_GTK) \ + $(TEST_CREATE_PERSON_DIALOG_OCR_GTK) \ $(TEST_EVIDENCE_PREVIEW_WIDGET_GTK) \ $(TEST_PERSON_CONFIRMATION_SUMMARY) \ $(TEST_PERSON_ROLE_ASSIGNMENT_DAO) \ @@ -1028,7 +1116,13 @@ test: \ $(TEST_EXIFTOOL_ANALYSIS) \ $(TEST_OCR_ANALYSIS) \ $(TEST_PDF_ANALYSIS) \ - $(TEST_DOCUMENT_TOOL_RUNNER) + $(TEST_DOCUMENT_TOOL_RUNNER) \ + $(TEST_IDENTITY_OCR) \ + $(TEST_IDENTITY_OCR_PREPROCESSOR) \ + $(TEST_OCR_PROVENANCE_OVERLAY_GTK) \ + $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \ + $(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) \ + $(TEST_WORKSPACE_IDENTITY_OCR_GTK) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -1093,6 +1187,7 @@ test: \ @$(TEST_PERSON_CREATION_GUARD) @$(TEST_PERSON_DIALOG_LIFECYCLE) @$(TEST_CREATE_PERSON_DIALOG_GTK) + @$(TEST_CREATE_PERSON_DIALOG_OCR_GTK) @$(TEST_EVIDENCE_PREVIEW_WIDGET_GTK) @$(TEST_PERSON_CONFIRMATION_SUMMARY) @$(TEST_PERSON_ROLE_ASSIGNMENT_DAO) @@ -1115,6 +1210,12 @@ test: \ @$(TEST_OCR_ANALYSIS) @$(TEST_PDF_ANALYSIS) @$(TEST_DOCUMENT_TOOL_RUNNER) + @$(TEST_IDENTITY_OCR) + @$(TEST_IDENTITY_OCR_PREPROCESSOR) + @$(TEST_OCR_PROVENANCE_OVERLAY_GTK) + @$(TEST_EVIDENCE_METADATA_DIALOG_GTK) + @$(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) + @$(TEST_WORKSPACE_IDENTITY_OCR_GTK) @echo "Tous les tests sont valides." %.o: %.c @@ -1186,6 +1287,7 @@ clean: $(TEST_PERSON_CREATION_GUARD) \ $(TEST_PERSON_DIALOG_LIFECYCLE) \ $(TEST_CREATE_PERSON_DIALOG_GTK) \ + $(TEST_CREATE_PERSON_DIALOG_OCR_GTK) \ $(TEST_EVIDENCE_PREVIEW_WIDGET_GTK) \ $(TEST_PERSON_CONFIRMATION_SUMMARY) \ $(TEST_PERSON_ROLE_ASSIGNMENT_DAO) \ @@ -1205,6 +1307,12 @@ clean: $(TEST_OCR_ANALYSIS) \ $(TEST_PDF_ANALYSIS) \ $(TEST_DOCUMENT_TOOL_RUNNER) \ + $(TEST_IDENTITY_OCR) \ + $(TEST_IDENTITY_OCR_PREPROCESSOR) \ + $(TEST_OCR_PROVENANCE_OVERLAY_GTK) \ + $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \ + $(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) \ + $(TEST_WORKSPACE_IDENTITY_OCR_GTK) \ $(FAKE_DOCUMENT_TOOL) diff --git a/README.md b/README.md index 9ac78d0..8f610fb 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,21 @@ 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. + +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. + Le socle actuel comprend notamment : - création et ouverture d’enquêtes ; diff --git a/database/schema_current.sql b/database/schema_current.sql index 08d2ea8..7ec46b2 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -265,3 +265,56 @@ CREATE INDEX IF NOT EXISTS idx_person_role_assignments_entity ON person_role_assignments(entity_id); CREATE INDEX IF NOT EXISTS idx_person_role_assignments_evidence ON person_role_assignments(evidence_id); + +CREATE TABLE IF NOT EXISTS identity_ocr_runs ( + id TEXT PRIMARY KEY, evidence_id TEXT NOT NULL, + expected_sha256 TEXT NOT NULL CHECK(length(expected_sha256)=64), + page_number INTEGER NOT NULL CHECK(page_number>0), + document_type TEXT NOT NULL, document_side TEXT NOT NULL, + engine TEXT NOT NULL, engine_version TEXT, requested_languages TEXT NOT NULL, + available_languages TEXT NOT NULL, parameters TEXT NOT NULL, + preprocessing_profile TEXT NOT NULL, executed_at TEXT NOT NULL, + status TEXT NOT NULL, error_message TEXT, + text_relative_path TEXT NOT NULL, text_sha256 TEXT NOT NULL, + tsv_relative_path TEXT NOT NULL, tsv_sha256 TEXT NOT NULL, + work_image_relative_path TEXT, work_image_sha256 TEXT, + corrected_transcription TEXT, + transcription_is_human INTEGER NOT NULL DEFAULT 0 + CHECK(transcription_is_human IN (0,1)), + transcription_corrected_at TEXT, + transcription_origin TEXT + CHECK(transcription_origin IS NULL OR transcription_origin='human'), + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE); +CREATE INDEX IF NOT EXISTS idx_identity_ocr_runs_evidence + ON identity_ocr_runs(evidence_id); +CREATE TABLE IF NOT EXISTS identity_document_observations ( + id TEXT PRIMARY KEY, person_id TEXT NOT NULL, evidence_id TEXT NOT NULL, + ocr_run_id TEXT NOT NULL UNIQUE, document_type TEXT NOT NULL, + issuing_country_declared TEXT, document_side TEXT NOT NULL, + page_number INTEGER NOT NULL, review_state TEXT NOT NULL, + observed_at TEXT NOT NULL, factual_notes TEXT, + FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE CASCADE, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS identity_field_observations ( + id TEXT PRIMARY KEY, observation_id TEXT NOT NULL, field_code TEXT NOT NULL, + raw_value TEXT, corrected_value TEXT, normalized_value TEXT, + confidence REAL, review_status TEXT NOT NULL, origin TEXT NOT NULL + CHECK(origin IN ('ocr','mrz','manual_override','manual_entry')), + evidence_id TEXT NOT NULL, ocr_run_id TEXT NOT NULL, + page_number INTEGER NOT NULL, source_x INTEGER, source_y INTEGER, + source_width INTEGER, source_height INTEGER, source_image_width INTEGER, + source_image_height INTEGER, display_order INTEGER NOT NULL, + reviewed_at TEXT NOT NULL, review_note TEXT, + CHECK ( + (origin = 'manual_entry' AND raw_value IS NULL + AND corrected_value IS NOT NULL AND confidence IS NULL) + OR + (origin <> 'manual_entry' AND raw_value IS NOT NULL) + ), + FOREIGN KEY(observation_id) REFERENCES identity_document_observations(id) + ON DELETE CASCADE, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE); +CREATE INDEX IF NOT EXISTS idx_identity_fields_observation + ON identity_field_observations(observation_id,display_order); diff --git a/database/schema_v15.sql b/database/schema_v15.sql new file mode 100644 index 0000000..0a6bd61 --- /dev/null +++ b/database/schema_v15.sql @@ -0,0 +1,78 @@ +/* Migration V15 — OCR contrôlé des documents d'identité. */ +CREATE TABLE IF NOT EXISTS identity_ocr_runs ( + id TEXT PRIMARY KEY, + evidence_id TEXT NOT NULL, + expected_sha256 TEXT NOT NULL CHECK(length(expected_sha256)=64), + page_number INTEGER NOT NULL CHECK(page_number > 0), + document_type TEXT NOT NULL CHECK(document_type IN + ('identity_card','passport','driving_licence','residence_permit','other')), + document_side TEXT NOT NULL CHECK(document_side IN + ('front','back','identity_page','other_page')), + engine TEXT NOT NULL, + engine_version TEXT, + requested_languages TEXT NOT NULL, + available_languages TEXT NOT NULL, + parameters TEXT NOT NULL, + preprocessing_profile TEXT NOT NULL CHECK(preprocessing_profile IN + ('none','orientation','grayscale','upscale')), + executed_at TEXT NOT NULL CHECK(length(executed_at)=20), + status TEXT NOT NULL CHECK(status IN ('success','partial','error','cancelled')), + error_message TEXT, + text_relative_path TEXT NOT NULL, + text_sha256 TEXT NOT NULL CHECK(length(text_sha256)=64), + tsv_relative_path TEXT NOT NULL, + tsv_sha256 TEXT NOT NULL CHECK(length(tsv_sha256)=64), + work_image_relative_path TEXT, + work_image_sha256 TEXT, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_identity_ocr_runs_evidence ON identity_ocr_runs(evidence_id); + +CREATE TABLE IF NOT EXISTS identity_document_observations ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + evidence_id TEXT NOT NULL, + ocr_run_id TEXT NOT NULL UNIQUE, + document_type TEXT NOT NULL, + issuing_country_declared TEXT, + document_side TEXT NOT NULL, + page_number INTEGER NOT NULL CHECK(page_number > 0), + review_state TEXT NOT NULL CHECK(review_state IN + ('proposed','accepted','modified','rejected','conflict')), + observed_at TEXT NOT NULL CHECK(length(observed_at)=20), + factual_notes TEXT, + FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE CASCADE, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS identity_field_observations ( + id TEXT PRIMARY KEY, + observation_id TEXT NOT NULL, + field_code TEXT NOT NULL, + raw_value TEXT NOT NULL, + corrected_value TEXT, + normalized_value TEXT, + confidence REAL CHECK(confidence IS NULL OR confidence BETWEEN 0 AND 100), + review_status TEXT NOT NULL CHECK(review_status IN + ('accepted','modified')), + origin TEXT NOT NULL CHECK(origin IN ('ocr','mrz','manual_override')), + evidence_id TEXT NOT NULL, + ocr_run_id TEXT NOT NULL, + page_number INTEGER NOT NULL CHECK(page_number > 0), + source_x INTEGER, + source_y INTEGER, + source_width INTEGER, + source_height INTEGER, + source_image_width INTEGER, + source_image_height INTEGER, + display_order INTEGER NOT NULL CHECK(display_order >= 0), + reviewed_at TEXT NOT NULL CHECK(length(reviewed_at)=20), + review_note TEXT, + FOREIGN KEY(observation_id) REFERENCES identity_document_observations(id) + ON DELETE CASCADE, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_identity_fields_observation + ON identity_field_observations(observation_id,display_order); diff --git a/database/schema_v16.sql b/database/schema_v16.sql new file mode 100644 index 0000000..1b94dc2 --- /dev/null +++ b/database/schema_v16.sql @@ -0,0 +1,39 @@ +/* Migration V16 — saisie manuelle contrôlée des champs d'identité omis. */ +CREATE TABLE identity_field_observations_v16 ( + id TEXT PRIMARY KEY, + observation_id TEXT NOT NULL, + field_code TEXT NOT NULL, + raw_value TEXT, + corrected_value TEXT, + normalized_value TEXT, + confidence REAL CHECK(confidence IS NULL OR confidence BETWEEN 0 AND 100), + review_status TEXT NOT NULL CHECK(review_status IN ('accepted','modified')), + origin TEXT NOT NULL CHECK(origin IN + ('ocr','mrz','manual_override','manual_entry')), + evidence_id TEXT NOT NULL, + ocr_run_id TEXT NOT NULL, + page_number INTEGER NOT NULL CHECK(page_number > 0), + source_x INTEGER, source_y INTEGER, source_width INTEGER, + source_height INTEGER, source_image_width INTEGER, + source_image_height INTEGER, + display_order INTEGER NOT NULL CHECK(display_order >= 0), + reviewed_at TEXT NOT NULL CHECK(length(reviewed_at)=20), + review_note TEXT, + CHECK ( + (origin = 'manual_entry' AND raw_value IS NULL + AND corrected_value IS NOT NULL AND confidence IS NULL) + OR + (origin <> 'manual_entry' AND raw_value IS NOT NULL) + ), + FOREIGN KEY(observation_id) REFERENCES identity_document_observations(id) + ON DELETE CASCADE, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE +); +INSERT INTO identity_field_observations_v16 +SELECT * FROM identity_field_observations; +DROP TABLE identity_field_observations; +ALTER TABLE identity_field_observations_v16 + RENAME TO identity_field_observations; +CREATE INDEX idx_identity_fields_observation + ON identity_field_observations(observation_id,display_order); diff --git a/database/schema_v17.sql b/database/schema_v17.sql new file mode 100644 index 0000000..47039b3 --- /dev/null +++ b/database/schema_v17.sql @@ -0,0 +1,11 @@ +/* Migration V17 — transcription humaine distincte du texte OCR brut. */ +ALTER TABLE identity_ocr_runs + ADD COLUMN corrected_transcription TEXT; +ALTER TABLE identity_ocr_runs + ADD COLUMN transcription_is_human INTEGER NOT NULL DEFAULT 0 + CHECK(transcription_is_human IN (0,1)); +ALTER TABLE identity_ocr_runs + ADD COLUMN transcription_corrected_at TEXT; +ALTER TABLE identity_ocr_runs + ADD COLUMN transcription_origin TEXT + CHECK(transcription_origin IS NULL OR transcription_origin='human'); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1098df2..9b16331 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,25 @@ 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. + +## OCR contrôlé d’identité — SQLite V15 + +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. + +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. > **Statut :** architecture courante --- diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index a03e3f3..47dab2f 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -437,6 +437,20 @@ interprétation ultérieure. Un résultat OCR ou OSINT est une proposition à vérifier, pas un fait confirmé. +Pour un document d’identité, l’OCR est exclusivement explicite et chaque champ +est révisé séparément. Il n’existe pas d’action « Tout accepter ». Une +extraction décrit seulement ce qui est présenté sur le document : elle ne +confirme pas son authenticité, ne fusionne aucune personne, ne change aucun +statut d’identification et n’effectue aucune reconnaissance faciale. + +Une zone OCR affichée est une surimpression éphémère calculée depuis les +coordonnées originales. Une zone absente est indiquée comme indisponible et +n’est jamais reconstruite arbitrairement. + +Les enregistrements lus par `IdentityOcrDao` possèdent toutes leurs chaînes. +Ils doivent être libérés avec la fonction `*_record_free()` correspondante ; +les listes retournées possèdent leurs éléments. + ### 7.4 Suppression La suppression logique est privilégiée pour les objets de traçabilité, mais la diff --git a/docs/DEPENDENCE.md b/docs/DEPENDENCE.md index 67418e8..ea5bc6c 100644 --- a/docs/DEPENDENCE.md +++ b/docs/DEPENDENCE.md @@ -94,6 +94,13 @@ sudo pacman -S python python-pip ## Vérification dans l’application +Tesseract est optionnel. L’étape OCR d’identité reste visible mais son lancement +est désactivé lorsque le registre d’outils ne fournit pas un exécutable vérifié. +Les langues réellement installées sont interrogées avec `--list-langs`; aucun +téléchargement n’est effectué par l’application. +Les choix sont triés et dédupliqués. `fra+eng` n’est proposé que lorsque les +deux packs sont réellement installés. + Au démarrage, le catalogue des outils indique le nombre d’exécutables disponibles et leurs versions. Une commande non détectée doit d’abord être vérifiée avec `command -v`, puis avec son option de version (`--version` ou diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3061149..e6ea4c3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,16 @@ # Guide de développement +Pour la tranche OCR 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 +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 `test_evidence_preview_widget_gtk`, `test_evidence_preview`, `test_evidence_video_preview_controller` et diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bf9e371..6a62297 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,18 @@ # 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ë. + 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. diff --git a/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md b/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md deleted file mode 100644 index 2beb1ee..0000000 --- a/docs/TEST_MANUEL_TICKET_109_TRANCHE_1.md +++ /dev/null @@ -1,35 +0,0 @@ -# Test manuel — ticket #109, tranche 1 - -> Le parcours « Preuve » est désormais nommé « Preuves » et accepte une -> sélection multiple ainsi qu’un import différé. Pour le scénario historique -> de tranche 1, sélectionner une seule preuve existante avec « Ajouter à la -> sélection ». Le protocole étendu se trouve dans -> `docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md`. - -Utiliser uniquement une enquête de démonstration et des fichiers synthétiques -marqués `SPECIMEN`. - -1. Cliquer sur Annuler à chacune des quatre étapes doit fermer uniquement - l’assistant et laisser la fenêtre principale utilisable. Refaire le contrôle - avec la croix, puis pendant le chargement d’un aperçu. -2. Ouvrir une enquête synthétique, puis demander l’ajout d’une personne. -3. Vérifier les quatre étapes `Personne`, `Rôles`, `Preuve`, `Confirmation`. -4. Revenir en arrière : les champs, rôles multiples et la preuve choisie - doivent être conservés. Aucun rôle sensible ne doit être précoché. -5. Rechercher par nom, description et type, combiner avec le filtre métier, - puis revenir à `Tous les types`. -6. Choisir des PNG et JPEG synthétiques : vérifier chargement, aperçu et - métadonnées complètes, puis les états invalide et absent. -7. Sélectionner rapidement plusieurs preuves : seul le dernier aperçu doit - apparaître, puis fermer la fenêtre pendant un chargement. -8. Choisir zéro puis une preuve existante. Aucun import, OCR, PDF, vidéo ou - analyse EML avancée ne doit être proposé. -9. Confirmer et vérifier que personne, rôles, rattachement et source `manual` - apparaissent ensemble après actualisation. -10. Changer d’enquête pendant un aperçu puis avant confirmation : le résultat - tardif et la création doivent être refusés sans écriture ni rafraîchissement. - -L’aperçu de tranche 1 accepte uniquement PNG/JPEG intègres. Il refuse les -fichiers absents, modifiés, corrompus ou supérieurs aux limites de 25 Mio, -12 000 pixels par côté et 40 millions de pixels. Le rendu mémoire est borné à -1 024 × 1 024 et aucun dérivé n’est persisté. diff --git a/docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md b/docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md deleted file mode 100644 index 13d7caa..0000000 --- a/docs/TEST_MANUEL_TICKET_109_TRANCHE_2.md +++ /dev/null @@ -1,73 +0,0 @@ -# Test manuel — ticket #109, tranche 2 - -Comparer le même EML synthétique dans la fiche directe et dans l’assistant : -From, To, Cc, Date, Subject, Message-ID, corps et inventaire détaillé des -pièces jointes doivent être identiques. Tester ensuite PNG/JPEG/HEIC/HEIF, -PDF, MP4/MOV et texte, les changements rapides, la fermeture pendant -chargement ou vidéo, la réouverture et l’ouverture d’une fiche personne. -Vérifier l’absence de critique GTK, d’écriture SQLite et d’OCR. - -Utiliser exclusivement une enquête temporaire et des fichiers synthétiques -dont le nom ou le contenu porte `SPECIMEN`. - -1. Créer une enquête temporaire et y importer préalablement deux preuves - synthétiques PNG/JPEG. -2. Ouvrir « Ajouter une personne », renseigner une désignation fictive et - sélectionner plusieurs rôles. -3. Dans « Preuves », rechercher puis ajouter les deux preuves existantes. - Vérifier qu’un second ajout est refusé sans modifier la preuve. -4. Choisir « Importer des fichiers » et sélectionner simultanément un PNG, un - JPEG et un PDF synthétiques. Vérifier que rien n’apparaît encore dans - `01_Preuves_Originales` ni dans SQLite. -5. Activer chaque élément retenu. Vérifier les aperçus PNG/JPEG et le message - explicite d’indisponibilité du PDF. -6. Modifier individuellement les types métier, naviguer en arrière puis en - avant et vérifier la conservation de l’état. -7. Retirer une preuve existante et un fichier nouveau. Vérifier que la preuve - existante reste dans l’enquête et que la copie de staging retirée disparaît. -8. Sélectionner deux fois le même fichier, puis deux fichiers de même contenu. - Vérifier le message de doublon et l’unicité de la sélection. -9. Ajouter un fichier identique à une preuve existante. Vérifier la - réutilisation explicite de cette preuve. -10. Atteindre Confirmation et contrôler la liste complète, les origines, - types, MIME, tailles, empreintes abrégées et compteurs. -11. Annuler puis vérifier l’absence de nouvelle personne, preuve, rôle, - rattachement et copie définitive, ainsi que le nettoyage du staging. -12. Recommencer, puis fermer la fenêtre pendant le staging. Vérifier la - fermeture propre, le nettoyage et le maintien de l’application. -13. Recommencer et changer d’enquête pendant une préparation ou une création. - Vérifier l’annulation, l’absence d’écriture dans la nouvelle enquête et - l’absence de rafraîchissement tardif. -14. Confirmer normalement. Vérifier que toutes les preuves sont rattachées à - la personne avec une source `manual`, puis rouvrir l’enquête. -15. Comparer les empreintes des fichiers sources avant et après le parcours. - Elles doivent être identiques. -16. Vérifier qu’aucun OCR ni outil documentaire avancé n’a été lancé. -17. Ouvrir la liste des preuves disponibles, choisir successivement deux - preuves, revenir à « Aucune preuve associée », activer une recherche et un - filtre, puis sélectionner de nouveau une preuve. Vérifier l’absence de - crash et de critique GTK. -18. Ajouter une image JPEG historique synthétique dont le MIME enregistré est - NULL et le type métier `document`. Vérifier que le contenu JPEG intègre est - détecté, que l’aperçu apparaît et que « MIME détecté : image/jpeg » est - affiché sans modification SQLite. -19. Renommer un fichier texte synthétique avec l’extension `.jpg`, puis - vérifier que l’aperçu est refusé malgré l’extension. Refaire avec un MIME - historique `image/jpeg` incohérent et vérifier le même refus. -20. Modifier le type individuel de l’image de `document` vers `email`. - Vérifier immédiatement la même valeur dans la ligne retenue, le sélecteur, - les métadonnées et le résumé après un aller-retour entre les étapes. - -21. Tester des fixtures PNG, JPEG, HEIC, HEIF, MP4, MOV, EML, TXT, CSV, LOG, - Markdown et PDF. Vérifier l’image, le lecteur sans lecture automatique, - le texte passif, les en-têtes EML et la première page PDF. -22. Tester format invalide, extension trompeuse et fichier trop volumineux. -23. Changer rapidement de preuve et vérifier le rejet tardif et l’arrêt vidéo. -24. Fermer puis changer d’enquête pendant un aperçu ; vérifier l’annulation. -25. Vérifier les empreintes inchangées et l’absence d’OCR, d’accès distant et - d’écriture SQLite pendant l’aperçu. -26. Avec un EML multipart synthétique, vérifier que le corps `text/plain` est - préféré au HTML et que nom, MIME, taille, disposition et Content-ID des - pièces jointes sont visibles sans fichier extrait persistant. - -Limites : aucun OCR ni analyse avancée. La vidéo dépend des codecs GStreamer. diff --git a/include/core/evidence_identity_import_task.h b/include/core/evidence_identity_import_task.h new file mode 100644 index 0000000..4f82153 --- /dev/null +++ b/include/core/evidence_identity_import_task.h @@ -0,0 +1,45 @@ +#ifndef LABFY_EVIDENCE_IDENTITY_IMPORT_TASK_H +#define LABFY_EVIDENCE_IDENTITY_IMPORT_TASK_H + +#include "core/background_task.h" +#include "core/task_manager.h" +#include "models/identity_ocr.h" +#include "core/person_creation_coordinator.h" + +G_BEGIN_DECLS + +typedef struct EvidenceIdentityImportTaskRequest + EvidenceIdentityImportTaskRequest; + +EvidenceIdentityImportTaskRequest * +evidence_identity_import_task_request_new( + const char *database_path, const char *investigation_root_path, + const char *source_path, const char *person_identifier, + const char *type_identifier, const char *collected_at, + const char *source, const char *description, + const IdentityOcrRun *run); +EvidenceIdentityImportTaskRequest * +evidence_identity_import_task_request_new_existing( + const char *database_path, const char *investigation_root_path, + const char *evidence_identifier, const char *person_identifier, + const IdentityOcrRun *run); +EvidenceIdentityImportTaskRequest * +evidence_identity_import_task_request_new_review( + const char *database_path, const char *investigation_root_path, + const char *evidence_identifier, const IdentityOcrRun *run); +void evidence_identity_import_task_request_free( + EvidenceIdentityImportTaskRequest *request); +void evidence_identity_import_task_request_set_session_check( + EvidenceIdentityImportTaskRequest *request, + PersonCreationSessionCheck session_check, + gpointer session_check_data); +BackgroundTask *evidence_identity_import_task_start( + TaskManager *manager, + const EvidenceIdentityImportTaskRequest *request, + BackgroundTaskCompletionCallback callback, + gpointer callback_data, GDestroyNotify callback_data_destroy, + GError **error); + +G_END_DECLS + +#endif diff --git a/include/core/identity_field_extractor.h b/include/core/identity_field_extractor.h new file mode 100644 index 0000000..7950c01 --- /dev/null +++ b/include/core/identity_field_extractor.h @@ -0,0 +1,8 @@ +#ifndef LABFY_IDENTITY_FIELD_EXTRACTOR_H +#define LABFY_IDENTITY_FIELD_EXTRACTOR_H +#include "models/identity_ocr.h" +G_BEGIN_DECLS +GPtrArray *identity_field_extractor_extract(const char *text, + const char *tsv, gint image_width, gint image_height, GError **error); +G_END_DECLS +#endif diff --git a/include/core/identity_ocr_preprocessor.h b/include/core/identity_ocr_preprocessor.h new file mode 100644 index 0000000..27967af --- /dev/null +++ b/include/core/identity_ocr_preprocessor.h @@ -0,0 +1,18 @@ +#ifndef LABFY_IDENTITY_OCR_PREPROCESSOR_H +#define LABFY_IDENTITY_OCR_PREPROCESSOR_H +#include "core/evidence_preview.h" +G_BEGIN_DECLS +typedef enum { IDENTITY_OCR_PREPROCESS_NONE, IDENTITY_OCR_PREPROCESS_ORIENTATION, + IDENTITY_OCR_PREPROCESS_GRAYSCALE, IDENTITY_OCR_PREPROCESS_UPSCALE +} IdentityOcrPreprocessProfile; +typedef struct { char *path; char *sha256; gint width; gint height; + guint page; } IdentityOcrWorkImage; +IdentityOcrWorkImage *identity_ocr_preprocessor_prepare( + const EvidencePreviewRequest *request, guint pdf_page, + IdentityOcrPreprocessProfile profile, GCancellable *cancellable, + GError **error); +void identity_ocr_work_image_free(IdentityOcrWorkImage *image); +gboolean identity_ocr_preprocessor_dimensions_are_safe(gint width, + gint height, IdentityOcrPreprocessProfile profile); +G_END_DECLS +#endif diff --git a/include/core/identity_ocr_workflow.h b/include/core/identity_ocr_workflow.h new file mode 100644 index 0000000..509011f --- /dev/null +++ b/include/core/identity_ocr_workflow.h @@ -0,0 +1,35 @@ +#ifndef LABFY_IDENTITY_OCR_WORKFLOW_H +#define LABFY_IDENTITY_OCR_WORKFLOW_H + +#include "core/identity_ocr_preprocessor.h" +#include "models/identity_ocr.h" + +G_BEGIN_DECLS + +typedef struct { + const char *root_path; + const char *evidence_identifier; + const char *relative_path; + const char *expected_sha256; + const char *mime_type; + const char *document_type; + const char *document_side; + const char *languages; + const char *preprocessing_profile; + const char *tesseract_executable; + const char *tesseract_version; + guint page_number; + IdentityOcrPreprocessProfile profile; + guint64 generation; +} IdentityOcrWorkflowRequest; + +gboolean identity_ocr_workflow_request_is_valid( + const IdentityOcrWorkflowRequest *request); +IdentityOcrRun *identity_ocr_workflow_execute( + const IdentityOcrWorkflowRequest *request, + GCancellable *cancellable, + GError **error); + +G_END_DECLS + +#endif diff --git a/include/core/mrz_parser.h b/include/core/mrz_parser.h new file mode 100644 index 0000000..d5d2f1b --- /dev/null +++ b/include/core/mrz_parser.h @@ -0,0 +1,12 @@ +#ifndef LABFY_MRZ_PARSER_H +#define LABFY_MRZ_PARSER_H +#include +G_BEGIN_DECLS +typedef struct { gboolean structure_valid; gboolean check_digits_valid; + char *document_number; char *nationality; char *surname; char *given_names; + GPtrArray *warnings; } MrzParseResult; +MrzParseResult *mrz_parser_parse(const char *text); +void mrz_parse_result_free(MrzParseResult *result); +gint mrz_parser_check_digit(const char *value); +G_END_DECLS +#endif diff --git a/include/core/ocr_analysis.h b/include/core/ocr_analysis.h index e068ac4..f635bb7 100644 --- a/include/core/ocr_analysis.h +++ b/include/core/ocr_analysis.h @@ -14,7 +14,9 @@ typedef struct { DocumentToolExecution *execution; char *requested_languages; + char *available_languages; char *text; + char *tsv; } OcrAnalysisResult; OcrAnalysisResult *ocr_analysis_run( @@ -34,6 +36,10 @@ OcrAnalysisResult *ocr_analysis_run_with_limits( ); void ocr_analysis_result_free(OcrAnalysisResult *result); gboolean ocr_analysis_mime_is_compatible(const char *mime_type); +char *ocr_analysis_list_languages(const char *executable, + GCancellable *cancellable, GError **error); +GPtrArray *ocr_analysis_parse_languages(const char *output); +GPtrArray *ocr_analysis_build_language_choices(const GPtrArray *languages); G_END_DECLS #endif diff --git a/include/core/ocr_region_geometry.h b/include/core/ocr_region_geometry.h new file mode 100644 index 0000000..e4fe078 --- /dev/null +++ b/include/core/ocr_region_geometry.h @@ -0,0 +1,23 @@ +#ifndef LABFY_OCR_REGION_GEOMETRY_H +#define LABFY_OCR_REGION_GEOMETRY_H + +#include + +typedef enum { + OCR_REGION_FIT_CONTAIN, + OCR_REGION_FIT_FILL +} OcrRegionFit; + +typedef struct { + double x; + double y; + double width; + double height; +} OcrDisplayRegion; + +gboolean ocr_region_geometry_transform(gint source_width, gint source_height, + gint region_x, gint region_y, gint region_width, gint region_height, + double display_width, double display_height, OcrRegionFit fit, + OcrDisplayRegion *out_region); + +#endif diff --git a/include/core/person_creation_coordinator.h b/include/core/person_creation_coordinator.h index d2cb919..6dd48a7 100644 --- a/include/core/person_creation_coordinator.h +++ b/include/core/person_creation_coordinator.h @@ -3,6 +3,7 @@ #include "core/person_entity_service.h" #include "models/person_evidence_selection.h" +#include "models/identity_ocr.h" #include G_BEGIN_DECLS @@ -12,10 +13,66 @@ typedef struct { GPtrArray *evidence_identifiers; } PersonCreationCoordinatorResult; +typedef enum { + PERSON_CREATION_FAILURE_NONE, + PERSON_CREATION_FAILURE_VALIDATE, + PERSON_CREATION_FAILURE_SESSION_BEFORE_START, + PERSON_CREATION_FAILURE_SOURCE_HASH, + PERSON_CREATION_FAILURE_CREATE_OCR_DIRECTORY, + PERSON_CREATION_FAILURE_COPY_OCR_TEXT, + PERSON_CREATION_FAILURE_HASH_OCR_TEXT, + PERSON_CREATION_FAILURE_COPY_OCR_TSV, + PERSON_CREATION_FAILURE_HASH_OCR_TSV, + PERSON_CREATION_FAILURE_CREATE_PERSON, + PERSON_CREATION_FAILURE_CREATE_ROLE, + PERSON_CREATION_FAILURE_IMPORT_EVIDENCE, + PERSON_CREATION_FAILURE_LINK_EVIDENCE, + PERSON_CREATION_FAILURE_INSERT_OCR_RUN, + PERSON_CREATION_FAILURE_INSERT_DOCUMENT_OBSERVATION, + PERSON_CREATION_FAILURE_INSERT_FIELD, + PERSON_CREATION_FAILURE_CREATE_SOURCE, + PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT, + PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED, + PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED, + PERSON_CREATION_FAILURE_COMMIT, + PERSON_CREATION_FAILURE_COMPENSATION +} PersonCreationFailurePoint; +typedef gboolean (*PersonCreationSessionCheck)(gpointer user_data); +typedef struct { + PersonCreationFailurePoint failure_point; + guint failure_occurrence; + gboolean inject_compensation_failure; + PersonCreationSessionCheck session_check; + gpointer session_check_data; +} PersonCreationCoordinatorOptions; +typedef struct { + const char *collected_at; + const char *source; + const char *description; + const char *type_identifier; +} PersonCreationCoordinatorEvidenceMetadata; + PersonCreationCoordinatorResult *person_creation_coordinator_execute( Database *database, const char *investigation_root_path, const PersonEntityInput *person, const PersonEvidenceSelection *selection, + const GPtrArray *ocr_runs, + GCancellable *cancellable, GError **error); +PersonCreationCoordinatorResult *person_creation_coordinator_execute_with_options( + Database *database, const char *investigation_root_path, + const PersonEntityInput *person, + const PersonEvidenceSelection *selection, + const GPtrArray *ocr_runs, + const PersonCreationCoordinatorOptions *options, + GCancellable *cancellable, GError **error); +PersonCreationCoordinatorResult * +person_creation_coordinator_attach_to_existing_person( + Database *database, const char *investigation_root_path, + const char *person_identifier, + const PersonEvidenceSelection *selection, + const GPtrArray *ocr_runs, + const PersonCreationCoordinatorEvidenceMetadata *metadata, + const PersonCreationCoordinatorOptions *options, GCancellable *cancellable, GError **error); void person_creation_coordinator_result_free( PersonCreationCoordinatorResult *result); diff --git a/include/core/person_creation_task.h b/include/core/person_creation_task.h index 80fa1d1..c93cca4 100644 --- a/include/core/person_creation_task.h +++ b/include/core/person_creation_task.h @@ -10,7 +10,7 @@ typedef struct PersonCreationTaskRequest PersonCreationTaskRequest; PersonCreationTaskRequest *person_creation_task_request_new( const char *database_path, const char *investigation_root_path, const PersonEntityInput *person, - const PersonEvidenceSelection *selection); + const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs); void person_creation_task_request_free(PersonCreationTaskRequest *request); BackgroundTask *person_creation_task_start(TaskManager *manager, const PersonCreationTaskRequest *request, diff --git a/include/dao/identity_ocr_dao.h b/include/dao/identity_ocr_dao.h new file mode 100644 index 0000000..998ff16 --- /dev/null +++ b/include/dao/identity_ocr_dao.h @@ -0,0 +1,62 @@ +#ifndef LABFY_IDENTITY_OCR_DAO_H +#define LABFY_IDENTITY_OCR_DAO_H +#include "database/database.h" +#include "models/identity_ocr.h" +G_BEGIN_DECLS +typedef struct IdentityOcrDao IdentityOcrDao; +typedef struct { + char *id,*evidence_id,*expected_sha256,*document_type,*document_side; + char *engine,*engine_version,*requested_languages,*available_languages; + char *parameters,*preprocessing_profile,*executed_at,*status,*error_message; + char *text_relative_path,*text_sha256,*tsv_relative_path,*tsv_sha256; + char *work_image_relative_path,*work_image_sha256; gint64 page_number; + char *corrected_transcription,*transcription_corrected_at; + char *transcription_origin; gboolean transcription_is_human; +} IdentityOcrRunRecord; +typedef struct { + char *id,*person_id,*evidence_id,*ocr_run_id,*document_type; + char *issuing_country_declared,*document_side,*review_state,*observed_at; + char *factual_notes; gint64 page_number; +} IdentityDocumentObservationRecord; +typedef struct { + char *id,*observation_id,*field_code,*raw_value,*corrected_value; + char *normalized_value,*review_status,*origin,*evidence_id,*ocr_run_id; + char *reviewed_at,*review_note; double confidence; gboolean has_confidence; + gint64 page_number,source_x,source_y,source_width,source_height; + gint64 source_image_width,source_image_height,display_order; + gboolean has_source_box; +} IdentityFieldObservationRecord; +IdentityOcrDao *identity_ocr_dao_new(Database *database); +void identity_ocr_dao_free(IdentityOcrDao *dao); +gboolean identity_ocr_dao_insert(IdentityOcrDao *dao, + const char *person_id,const char *evidence_id,const IdentityOcrRun *run, + const char *text_relative,const char *text_sha, + const char *tsv_relative,const char *tsv_sha,const char *timestamp, + GError **error); +IdentityOcrRunRecord *identity_ocr_dao_find_run(IdentityOcrDao *dao, + const char *identifier,GError **error); +GPtrArray *identity_ocr_dao_list_runs_by_evidence(IdentityOcrDao *dao, + const char *evidence_identifier,GError **error); +IdentityDocumentObservationRecord *identity_ocr_dao_find_document( + IdentityOcrDao *dao,const char *identifier,GError **error); +GPtrArray *identity_ocr_dao_list_documents_by_person(IdentityOcrDao *dao, + const char *person_identifier,GError **error); +GPtrArray *identity_ocr_dao_list_documents_by_evidence(IdentityOcrDao *dao, + const char *evidence_identifier,GError **error); +IdentityFieldObservationRecord *identity_ocr_dao_find_field( + IdentityOcrDao *dao,const char *identifier,GError **error); +GPtrArray *identity_ocr_dao_list_fields_by_document(IdentityOcrDao *dao, + const char *document_identifier,GError **error); +IdentityOcrRun *identity_ocr_dao_load_run( + IdentityOcrDao *dao,const char *investigation_root, + const char *run_identifier,char **person_identifier,GError **error); +gboolean identity_ocr_dao_update_review( + IdentityOcrDao *dao,const IdentityOcrRun *run, + const char *reviewed_at,GError **error); +void identity_ocr_run_record_free(IdentityOcrRunRecord *record); +void identity_document_observation_record_free( + IdentityDocumentObservationRecord *record); +void identity_field_observation_record_free( + IdentityFieldObservationRecord *record); +G_END_DECLS +#endif diff --git a/include/database/schema.h b/include/database/schema.h index 5024a7b..7335602 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -95,6 +95,9 @@ bool schema_install_v11(Database *database); bool schema_install_v12(Database *database); bool schema_install_v13(Database *database); bool schema_install_v14(Database *database); +bool schema_install_v15(Database *database); +bool schema_install_v16(Database *database); +bool schema_install_v17(Database *database); /** * @brief Garantit la présence des extensions du schéma courant V2. diff --git a/include/models/identity_ocr.h b/include/models/identity_ocr.h new file mode 100644 index 0000000..5c80c99 --- /dev/null +++ b/include/models/identity_ocr.h @@ -0,0 +1,96 @@ +#ifndef LABFY_IDENTITY_OCR_H +#define LABFY_IDENTITY_OCR_H +#include +G_BEGIN_DECLS +typedef enum { + IDENTITY_REVIEW_PROPOSED, IDENTITY_REVIEW_ACCEPTED, + IDENTITY_REVIEW_MODIFIED, IDENTITY_REVIEW_REJECTED, + IDENTITY_REVIEW_CONFLICT +} IdentityReviewStatus; +typedef struct { + gint page, x, y, width, height, image_width, image_height; + gboolean available; +} IdentitySourceBox; +typedef struct IdentityFieldObservation IdentityFieldObservation; +typedef struct IdentityOcrRun IdentityOcrRun; + +IdentityFieldObservation *identity_field_observation_new( + const char *code, const char *raw_value, double confidence, + const IdentitySourceBox *box, guint order); +IdentityFieldObservation *identity_field_observation_new_manual( + const char *code, const char *value, guint order); +IdentityFieldObservation *identity_field_observation_copy( + const IdentityFieldObservation *field); +void identity_field_observation_free(IdentityFieldObservation *field); +gboolean identity_field_observation_accept(IdentityFieldObservation *field); +gboolean identity_field_observation_modify(IdentityFieldObservation *field, + const char *corrected_value, const char *note); +gboolean identity_field_observation_restore_raw( + IdentityFieldObservation *field); +void identity_field_observation_reject(IdentityFieldObservation *field); +gboolean identity_field_observation_set_origin( + IdentityFieldObservation *field, const char *origin); +void identity_field_observation_mark_conflict(IdentityFieldObservation *field); +const char *identity_field_observation_get_code( + const IdentityFieldObservation *field); +const char *identity_field_observation_get_raw_value( + const IdentityFieldObservation *field); +const char *identity_field_observation_get_corrected_value( + const IdentityFieldObservation *field); +IdentityReviewStatus identity_field_observation_get_status( + const IdentityFieldObservation *field); +double identity_field_observation_get_confidence( + const IdentityFieldObservation *field); +const IdentitySourceBox *identity_field_observation_get_box( + const IdentityFieldObservation *field); + +IdentityOcrRun *identity_ocr_run_new(const char *evidence_id, + const char *expected_sha256, const char *document_type, + const char *document_side, guint page, const char *languages, + const char *profile); +IdentityOcrRun *identity_ocr_run_copy(const IdentityOcrRun *run); +void identity_ocr_run_free(IdentityOcrRun *run); +void identity_ocr_run_set_outputs(IdentityOcrRun *run, const char *version, + const char *available_languages, const char *parameters, + const char *raw_text, const char *tsv); +void identity_ocr_run_set_preview(IdentityOcrRun *run, GBytes *png_bytes); +GBytes *identity_ocr_run_get_preview(const IdentityOcrRun *run); +void identity_ocr_run_add_field(IdentityOcrRun *run, + IdentityFieldObservation *field); +gboolean identity_ocr_run_replace_evidence_id( + IdentityOcrRun *run, const char *evidence_identifier); +gboolean identity_ocr_run_replace_identifier( + IdentityOcrRun *run, const char *identifier); +void identity_ocr_run_set_factual_notes( + IdentityOcrRun *run, const char *notes); +gboolean identity_ocr_run_set_corrected_transcription( + IdentityOcrRun *run, const char *transcription, const char *corrected_at); +void identity_ocr_run_reset_corrected_transcription(IdentityOcrRun *run); +const char *identity_ocr_run_get_corrected_transcription( + const IdentityOcrRun *run); +const char *identity_ocr_run_get_transcription_corrected_at( + const IdentityOcrRun *run); +gboolean identity_ocr_run_has_human_transcription(const IdentityOcrRun *run); +const char *identity_ocr_run_get_factual_notes(const IdentityOcrRun *run); +const char *identity_ocr_run_get_identifier(const IdentityOcrRun *run); +const char *identity_ocr_run_get_evidence_id(const IdentityOcrRun *run); +const char *identity_ocr_run_get_raw_text(const IdentityOcrRun *run); +const char *identity_ocr_run_get_tsv(const IdentityOcrRun *run); +const char *identity_ocr_run_get_document_type(const IdentityOcrRun *run); +const char *identity_ocr_run_get_document_side(const IdentityOcrRun *run); +const char *identity_ocr_run_get_languages(const IdentityOcrRun *run); +const char *identity_ocr_run_get_expected_sha256(const IdentityOcrRun *run); +const char *identity_ocr_run_get_profile(const IdentityOcrRun *run); +const char *identity_ocr_run_get_version(const IdentityOcrRun *run); +const char *identity_ocr_run_get_available_languages(const IdentityOcrRun *run); +const char *identity_field_observation_get_origin( + const IdentityFieldObservation *field); +guint identity_field_observation_get_order( + const IdentityFieldObservation *field); +guint identity_ocr_run_get_page(const IdentityOcrRun *run); +const GPtrArray *identity_ocr_run_get_fields(const IdentityOcrRun *run); +gboolean identity_ocr_document_type_is_valid(const char *value); +gboolean identity_ocr_document_side_is_valid(const char *value); +gboolean identity_ocr_field_code_is_valid(const char *value); +G_END_DECLS +#endif diff --git a/include/views/create_person_dialog.h b/include/views/create_person_dialog.h index 6d7ca73..5e63912 100644 --- a/include/views/create_person_dialog.h +++ b/include/views/create_person_dialog.h @@ -9,6 +9,7 @@ #include "models/evidence_record.h" #include "models/person_evidence_selection.h" #include "core/task_manager.h" +#include "core/tool_registry.h" #include G_BEGIN_DECLS /** @brief Résultat opaque possédé par le callback. */ @@ -27,7 +28,8 @@ typedef gboolean (*CreatePersonDialogSessionCheck)(gpointer user_data); */ gboolean create_person_dialog_present(GtkWindow *parent, const GPtrArray *evidence_records, const char *investigation_root_path, - TaskManager *task_manager, CreatePersonDialogSessionCheck session_check, + TaskManager *task_manager, const ToolInfo *tesseract_tool, + CreatePersonDialogSessionCheck session_check, CreatePersonDialogCallback callback, gpointer user_data, GDestroyNotify user_data_destroy); /** @brief Libère un résultat. */ @@ -39,5 +41,9 @@ const PersonEvidenceSelection *create_person_dialog_result_get_evidence_selectio const CreatePersonDialogResult *result); EvidenceStaging *create_person_dialog_result_steal_staging( CreatePersonDialogResult *result); +const GPtrArray *create_person_dialog_result_get_ocr_runs( + const CreatePersonDialogResult *result); +gboolean create_person_dialog_test_overlay_has_region(GtkWindow *window); +guint64 create_person_dialog_test_ocr_generation(GtkWindow *window); G_END_DECLS #endif diff --git a/include/views/evidence_identity_ocr_dialog.h b/include/views/evidence_identity_ocr_dialog.h new file mode 100644 index 0000000..1cc0019 --- /dev/null +++ b/include/views/evidence_identity_ocr_dialog.h @@ -0,0 +1,60 @@ +#ifndef LABFY_EVIDENCE_IDENTITY_OCR_DIALOG_H +#define LABFY_EVIDENCE_IDENTITY_OCR_DIALOG_H + +#include "core/tool_registry.h" +#include "core/background_task.h" +#include "models/entity_record.h" +#include "models/identity_ocr.h" +#include + +G_BEGIN_DECLS + +typedef struct EvidenceIdentityOcrDialogResult + EvidenceIdentityOcrDialogResult; +typedef gboolean (*EvidenceIdentityOcrDialogSessionCheck)(gpointer user_data); +typedef void (*EvidenceIdentityOcrDialogCallback)( + EvidenceIdentityOcrDialogResult *result, gpointer user_data); + +gboolean evidence_identity_ocr_dialog_file_is_compatible( + const char *file_path); +gboolean evidence_identity_ocr_dialog_present( + GtkWindow *parent, const char *file_path, const GPtrArray *persons, + const char *preselected_person_identifier, + const ToolInfo *tesseract_tool, + EvidenceIdentityOcrDialogSessionCheck session_check, + EvidenceIdentityOcrDialogCallback callback, + gpointer user_data, GDestroyNotify user_data_destroy, + GError **error); +gboolean evidence_identity_ocr_dialog_present_review( + GtkWindow *parent,const char *file_path,const GPtrArray *persons, + const char *preselected_person_identifier, + const IdentityOcrRun *persisted_run, + EvidenceIdentityOcrDialogSessionCheck session_check, + EvidenceIdentityOcrDialogCallback callback, + gpointer user_data,GDestroyNotify user_data_destroy,GError **error); +void evidence_identity_ocr_dialog_result_free( + EvidenceIdentityOcrDialogResult *result); +gboolean evidence_identity_ocr_dialog_result_has_ocr( + const EvidenceIdentityOcrDialogResult *result); +const char *evidence_identity_ocr_dialog_result_get_person_identifier( + const EvidenceIdentityOcrDialogResult *result); +const char *evidence_identity_ocr_dialog_result_get_temporary_identifier( + const EvidenceIdentityOcrDialogResult *result); +const char *evidence_identity_ocr_dialog_result_get_sha256( + const EvidenceIdentityOcrDialogResult *result); +guint64 evidence_identity_ocr_dialog_result_get_size( + const EvidenceIdentityOcrDialogResult *result); +const char *evidence_identity_ocr_dialog_result_get_mime_type( + const EvidenceIdentityOcrDialogResult *result); +IdentityOcrRun *evidence_identity_ocr_dialog_result_steal_run( + EvidenceIdentityOcrDialogResult *result); +GtkWindow *evidence_identity_ocr_dialog_result_get_dialog( + const EvidenceIdentityOcrDialogResult *result); +void evidence_identity_ocr_dialog_finish_import( + GtkWindow *dialog, const GError *error); +void evidence_identity_ocr_dialog_set_submission_task( + GtkWindow *dialog, BackgroundTask *task); + +G_END_DECLS + +#endif diff --git a/include/views/evidence_metadata_dialog.h b/include/views/evidence_metadata_dialog.h index 1b7b814..af3b66d 100644 --- a/include/views/evidence_metadata_dialog.h +++ b/include/views/evidence_metadata_dialog.h @@ -7,6 +7,7 @@ #define LABFY_INVESTIGATION_EVIDENCE_METADATA_DIALOG_H #include "models/evidence_record.h" +#include "database/database.h" #include @@ -19,6 +20,10 @@ typedef struct EvidenceMetadataDialogResult EvidenceMetadataDialogResult; typedef void (*EvidenceMetadataDialogCallback)( EvidenceMetadataDialogResult *result, gpointer user_data ); +typedef void (*EvidenceMetadataDialogAnalyzeCallback)( + GtkWindow *dialog, const char *evidence_identifier, + gboolean revise_existing, const char *ocr_run_identifier, + gpointer user_data); /** * @brief Présente le dialogue prérempli de modification des métadonnées. @@ -36,6 +41,12 @@ gboolean evidence_metadata_dialog_present( EvidenceMetadataDialogCallback callback, gpointer user_data ); +gboolean evidence_metadata_dialog_present_with_ocr( + GtkWindow *parent, const EvidenceRecord *record, + const GPtrArray *evidence_types, Database *database, + const char *investigation_root, + EvidenceMetadataDialogAnalyzeCallback analyze_callback, + EvidenceMetadataDialogCallback callback, gpointer user_data); /** @brief Libère un résultat, ou accepte NULL. */ void evidence_metadata_dialog_result_free(EvidenceMetadataDialogResult *result); diff --git a/include/views/main_window.h b/include/views/main_window.h index 1cb3c3d..5be1f4b 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -104,6 +104,9 @@ typedef void (*MainWindowAnalyzeEmlCallback)(const char *evidence_identifier, /** @brief Callback appelé pour analyser un RIB par OCR. */ typedef void (*MainWindowAnalyzeRibCallback)(const char *evidence_identifier, gpointer user_data); +typedef void (*MainWindowIdentityOcrCallback)( + const char *evidence_identifier, gboolean revise_existing, + const char *ocr_run_identifier, gpointer user_data); /** @brief Callback appelé pour extraire les métadonnées d'une preuve. */ typedef void (*MainWindowExtractMetadataCallback)(const char *evidence_identifier, gpointer user_data); @@ -237,6 +240,9 @@ void main_window_set_extract_metadata_callback(MainWindow *main_window, /** @brief Définit le callback de récupération d'un mot de passe PDF. */ void main_window_set_recover_pdf_password_callback(MainWindow *main_window, MainWindowRecoverPdfPasswordCallback callback, gpointer user_data); +void main_window_set_identity_ocr_callback( + MainWindow *main_window, MainWindowIdentityOcrCallback callback, + gpointer user_data); /** * @brief Définit le callback de fin de déplacement d'un nœud. @@ -497,6 +503,8 @@ void main_window_set_selected_evidence( MainWindow *main_window, const EvidenceRecord *evidence_record ); +void main_window_set_identity_ocr_runs( + MainWindow *main_window, GPtrArray *owned_records); void main_window_set_evidence_observations(MainWindow *main_window, const GPtrArray *observations); void main_window_set_observation_remove_callback(MainWindow *main_window, diff --git a/include/views/manage_entity_evidence_dialog.h b/include/views/manage_entity_evidence_dialog.h index 7ad9eb4..0392ebc 100644 --- a/include/views/manage_entity_evidence_dialog.h +++ b/include/views/manage_entity_evidence_dialog.h @@ -5,10 +5,12 @@ #ifndef LABFY_INVESTIGATION_MANAGE_ENTITY_EVIDENCE_DIALOG_H #define LABFY_INVESTIGATION_MANAGE_ENTITY_EVIDENCE_DIALOG_H #include "models/evidence_record.h" +#include "core/task_manager.h" #include G_BEGIN_DECLS typedef void (*ManageEntityEvidenceDialogCallback)( GPtrArray *selected_identifiers, gpointer user_data); +typedef void (*ManageEntityEvidenceImportCallback)(gpointer user_data); /** * @brief Présente la sélection multiple de preuves avec aperçu. * @param parent Fenêtre parente. @@ -23,7 +25,9 @@ typedef void (*ManageEntityEvidenceDialogCallback)( gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, const GPtrArray *evidence_records, const GPtrArray *selected_identifiers, const char *investigation_root_path, + TaskManager *task_manager, ManageEntityEvidenceDialogCallback callback, gpointer user_data, + ManageEntityEvidenceImportCallback import_callback, GError **error); G_END_DECLS #endif diff --git a/include/widgets/ocr_provenance_overlay.h b/include/widgets/ocr_provenance_overlay.h new file mode 100644 index 0000000..acd801e --- /dev/null +++ b/include/widgets/ocr_provenance_overlay.h @@ -0,0 +1,21 @@ +#ifndef LABFY_OCR_PROVENANCE_OVERLAY_H +#define LABFY_OCR_PROVENANCE_OVERLAY_H + +#include "models/identity_ocr.h" +#include + +typedef struct OcrProvenanceOverlay OcrProvenanceOverlay; + +OcrProvenanceOverlay *ocr_provenance_overlay_new(void); +void ocr_provenance_overlay_free(OcrProvenanceOverlay *overlay); +GtkWidget *ocr_provenance_overlay_get_widget(OcrProvenanceOverlay *overlay); +void ocr_provenance_overlay_set_image(OcrProvenanceOverlay *overlay, + GBytes *png_bytes, guint64 generation); +void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay, + const IdentityFieldObservation *field, guint64 generation); +void ocr_provenance_overlay_clear(OcrProvenanceOverlay *overlay, + guint64 generation); +gboolean ocr_provenance_overlay_has_region( + const OcrProvenanceOverlay *overlay); + +#endif diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index ef1c844..83e875a 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -11,6 +11,7 @@ #include "models/evidence_observation.h" #include "models/entity_record.h" #include "models/osint_action_catalog.h" +#include "models/identity_ocr.h" #include "core/task_manager.h" #include @@ -157,6 +158,26 @@ typedef void (*WorkspaceRecoverPdfPasswordCallback)( const char *evidence_identifier, gpointer user_data); typedef void (*WorkspaceObservationRemoveCallback)( const char *observation_identifier, gpointer user_data); +typedef void (*WorkspaceIdentityOcrCallback)( + const char *evidence_identifier, gboolean revise_existing, + const char *ocr_run_identifier, gpointer user_data); + +typedef struct { + IdentityOcrRun *run; + char *person_identifier; + char *executed_at; + char *text_relative_path; + char *text_sha256; + char *tsv_relative_path; + char *tsv_sha256; +} WorkspaceIdentityOcrRecord; + +WorkspaceIdentityOcrRecord *workspace_identity_ocr_record_new( + IdentityOcrRun *owned_run, const char *person_identifier, + const char *executed_at, const char *text_relative_path, + const char *text_sha256, const char *tsv_relative_path, + const char *tsv_sha256); +void workspace_identity_ocr_record_free(WorkspaceIdentityOcrRecord *record); /** * @brief Crée une nouvelle zone de travail. @@ -207,6 +228,11 @@ void workspace_set_selected_evidence( Workspace *workspace, const EvidenceRecord *evidence_record ); +void workspace_set_identity_ocr_runs( + Workspace *workspace, GPtrArray *owned_records); +void workspace_set_identity_ocr_callback( + Workspace *workspace, WorkspaceIdentityOcrCallback callback, + gpointer user_data); void workspace_set_evidence_observations(Workspace *workspace, const GPtrArray *observations); void workspace_set_observation_remove_callback(Workspace *workspace, diff --git a/opencode.json.backup-7b b/opencode.json.backup-7b deleted file mode 100644 index 7216bc6..0000000 --- a/opencode.json.backup-7b +++ /dev/null @@ -1,151 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "enabled_providers": [ - "llama.cpp" - ], - "provider": { - "llama.cpp": { - "npm": "@ai-sdk/openai-compatible", - "name": "llama-server local", - "options": { - "baseURL": "http://127.0.0.1:8080/v1", - "timeout": 600000, - "chunkTimeout": 180000 - }, - "models": { - "qwen3-8b": { - "name": "Qwen3 8B — Architecte et reviewer", - "limit": { - "context": 32768, - "output": 2048 - } - }, - "qwen2.5-coder-7b": { - "name": "Qwen2.5-Coder 7B — Développeur C17", - "limit": { - "context": 32768, - "output": 2048 - } - } - } - } - }, - "model": "llama.cpp/qwen3-8b", - "small_model": "llama.cpp/qwen3-8b", - "default_agent": "orchestrateur", - "subagent_depth": 1, - "instructions": [ - ".opencode/labfy.md" - ], - "compaction": { - "auto": true, - "prune": true, - "reserved": 8000 - }, - "permission": { - "external_directory": "deny", - "edit": "ask", - "bash": "ask" - }, - "agent": { - "orchestrateur": { - "description": "Agent principal. Inspecte le dépôt, prépare le plan, consulte l’architecte, délègue la production de code C17, applique les changements autorisés, compile, teste et présente le résultat.", - "mode": "primary", - "model": "llama.cpp/qwen3-8b", - "temperature": 0.1, - "steps": 40, - "permission": { - "read": "allow", - "glob": "allow", - "grep": "allow", - "list": "allow", - "edit": "ask", - "bash": { - "*": "ask", - "pwd": "allow", - "ls *": "allow", - "grep *": "allow", - "rg *": "allow", - "head *": "allow", - "tail *": "allow", - "file *": "allow", - "git status*": "allow", - "git diff*": "allow", - "git log*": "allow", - "make -j8*": "allow", - "make test*": "allow", - "git commit*": "deny", - "git push*": "deny", - "git reset*": "deny", - "git clean*": "deny", - "git checkout*": "deny", - "git restore*": "deny", - "rm *": "deny", - "sudo *": "deny", - "doas *": "deny" - }, - "task": { - "*": "deny", - "architecte-reviewer": "allow", - "coder-c17": "allow" - }, - "external_directory": "deny", - "todowrite": "allow", - "lsp": "allow", - "question": "allow", - "skill": "deny", - "websearch": "allow", - "webfetch": "allow" - }, - "prompt": "Tu pilotes le travail dans Labfy Investigation. Pour une nouvelle API, une migration, une modification transversale ou un changement sensible, consulte architecte-reviewer avant l’implémentation. Transmets ensuite à coder-c17 tout le contexte nécessaire : objectif, fichiers pertinents, interfaces, extraits de code, contraintes et diagnostics. Le Coder ne dispose d’aucun outil : il renvoie uniquement du code ou un patch textuel. Tu restes seul responsable de l’application des changements, de l’exécution de make -j8, des tests, de git diff --check et de la validation finale. Ne crée jamais de commit ni de push sans autorisation explicite." - }, - "coder-c17": { - "description": "Spécialiste ISO C17 sans outils. Reçoit le contexte transmis par l’orchestrateur et produit du code ou un patch textuel précis.", - "mode": "all", - "model": "llama.cpp/qwen2.5-coder-7b", - "temperature": 0.1, - "permission": { - "read": "deny", - "glob": "deny", - "grep": "deny", - "list": "deny", - "edit": "deny", - "bash": "deny", - "task": "deny", - "external_directory": "deny", - "todowrite": "deny", - "lsp": "deny", - "skill": "deny", - "question": "deny", - "webfetch": "deny", - "websearch": "deny", - "doom_loop": "deny" - }, - "prompt": "Tu es le spécialiste C17 de Labfy Investigation. Tu ne disposes d’aucun outil et tu ne peux ni lire le dépôt, ni modifier un fichier, ni compiler, ni exécuter un test. Travaille uniquement à partir du contexte transmis. Produis du code ISO C17 compatible avec -std=c17 -Wall -Wextra -Wpedantic -Werror. Respecte les interfaces existantes, les noms snake_case, les préfixes de modules, l’ownership GLib, l’architecture GTK4/DAO/services et SQLite. Retourne de préférence un diff unifié applicable, ou des blocs de code complets avec les chemins concernés. Signale clairement les hypothèses. Ne prétends jamais avoir lu, modifié, compilé ou testé quoi que ce soit." - }, - "architecte-reviewer": { - "description": "Architecte et reviewer en lecture seule. Contrôle l’architecture, la cohérence des API, l’ownership, GTK4/GLib, SQLite, les risques de régression et les tests manquants.", - "mode": "subagent", - "model": "llama.cpp/qwen3-8b", - "temperature": 0.1, - "steps": 30, - "permission": { - "read": "allow", - "glob": "allow", - "grep": "allow", - "list": "allow", - "edit": "deny", - "bash": "deny", - "task": "deny", - "external_directory": "deny", - "todowrite": "deny", - "lsp": "allow", - "skill": "deny", - "question": "deny", - "webfetch": "deny", - "websearch": "deny" - }, - "prompt": "Tu es l’architecte et reviewer de Labfy Investigation. Tu travailles strictement en lecture seule. Examine le plan, les fichiers pertinents et le diff. Vérifie la séparation widgets/modèles/DAO/services, la cohérence entre .h et .c, l’ownership et les durées de vie GLib/GTK, les transactions SQLite, la gestion d’erreurs, la sécurité, les régressions et les tests manquants. Classe les remarques par gravité : bloquant, important, amélioration. Propose des corrections précises sans modifier le dépôt et sans créer de commit." - } - } -} diff --git a/src/core/application.c b/src/core/application.c index 32dd066..77200a9 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -20,6 +20,7 @@ #include "views/create_investigation_dialog.h" #include "views/folder_dialog.h" #include "views/main_window.h" +#include "widgets/workspace.h" #include "views/application_message_dialog.h" #include "core/task_manager.h" #include "core/background_task.h" @@ -30,6 +31,7 @@ #include "core/evidence_reclassification.h" #include "views/file_dialog.h" #include "core/evidence_import_task.h" +#include "core/evidence_identity_import_task.h" #include "models/evidence_record.h" #include "dao/evidence_type_dao.h" #include "dao/graph_node_position_dao.h" @@ -37,11 +39,13 @@ #include "models/relation_type.h" #include "core/relation_type_service.h" #include "views/evidence_import_dialog.h" +#include "views/evidence_identity_ocr_dialog.h" #include "views/evidence_metadata_dialog.h" #include "views/create_relation_dialog.h" #include "views/osint_dns_review_dialog.h" #include "views/osint_execution_history_dialog.h" #include "dao/evidence_dao.h" +#include "dao/identity_ocr_dao.h" #include "dao/evidence_entity_dao.h" #include "dao/entity_dao.h" #include "dao/relation_dao.h" @@ -146,12 +150,14 @@ struct Application InvestigationGraphLayout *graph_layout; guint64 graph_load_generation; guint64 session_generation; + gint identity_import_session_generation; gboolean graph_viewport_restored; guint graph_viewport_save_source_id; char *selected_evidence_identifier; char *pending_relation_selection_identifier; char *pending_entity_selection_identifier; + char *pending_evidence_import_person_identifier; }; typedef struct @@ -201,6 +207,13 @@ static void application_observation_removal_context_free( g_free(context); } +/** @brief Garde immuable d'une tâche d'import OCR. */ +typedef struct +{ + Application *application; + gint generation; +} ApplicationEvidenceIdentitySessionGuard; + /** * @brief Contexte ApplicationOpenErrorconservé jusqu’à la fin d’un import. */ @@ -209,8 +222,9 @@ typedef struct Application *application; char *investigation_root_path; gpointer batch_context; + GtkWindow *identity_dialog; + ApplicationEvidenceIdentitySessionGuard identity_session_guard; } ApplicationEvidenceImportContext; - /** @brief État d'un import groupé traité séquentiellement. */ typedef struct { @@ -242,8 +256,30 @@ typedef struct { Application *application; char *file_path; + char *type_identifier; + char *collected_at; + char *source; + char *description; + char *root_path; + char *preselected_person_identifier; } ApplicationEvidenceDialogContext; +static gboolean application_session_matches_root( + const Application *application, const char *expected_root_path); +static gboolean application_refresh_investigation_tree( + Application *application, const char *investigation_root_path); +static gboolean application_refresh_evidence_models( + Application *application, GError **error); +static void application_start_evidence_import( + Application *application, const char *file_path, + const char *type_identifier, const char *collected_at, + const char *source, const char *description, + ApplicationEvidenceBatchContext *batch_context); +static void application_evidence_import_context_free(gpointer user_data); +static gboolean application_evidence_identity_session_check(gpointer data); +static void application_on_edit_evidence_requested( + const char *evidence_identifier, gpointer user_data); + /** @brief Contexte possédé pendant l'édition d'une preuve. */ typedef struct { @@ -251,6 +287,17 @@ typedef struct char *evidence_identifier; } ApplicationEvidenceMetadataContext; +typedef struct +{ + Application *application; + char *evidence_identifier; + char *root_path; + char *database_path; + GtkWindow *dialog; + char *ocr_run_identifier; + gboolean revision; +} ApplicationExistingEvidenceOcrContext; + /** @brief Données d'actualisation différée après reclassement. */ typedef struct { @@ -855,12 +902,175 @@ static void application_evidence_dialog_context_free( g_free( context->file_path ); + g_free(context->type_identifier); + g_free(context->collected_at); + g_free(context->source); + g_free(context->description); + g_free(context->root_path); + g_free(context->preselected_person_identifier); g_free( context ); } +/** @brief Vérifie que le dialogue OCR appartient toujours à la session active. */ +static gboolean application_evidence_ocr_session_is_current(gpointer user_data) +{ + ApplicationEvidenceDialogContext *context = user_data; + return context != NULL && + application_session_matches_root(context->application, + context->root_path); +} + +/** @brief Actualise l'interface après l'import atomique d'une preuve OCR. */ +static void application_on_evidence_identity_import_completed( + BackgroundTask *task, gpointer user_data) +{ + ApplicationEvidenceImportContext *context = user_data; + Application *application = + context != NULL ? context->application : NULL; + GError *refresh_error = NULL; + if (task == NULL || application == NULL || + application->main_window == NULL) return; + if (background_task_get_state(task) == BACKGROUND_TASK_STATE_COMPLETED) + { + if (application_session_matches_root( + application, context->investigation_root_path)) + { + application_refresh_investigation_tree( + application, context->investigation_root_path); + application_refresh_evidence_models(application, &refresh_error); + PersonCreationCoordinatorResult *result = + background_task_get_result(task); + if (result != NULL && + result->evidence_identifiers != NULL && + result->evidence_identifiers->len > 0) + application_on_evidence_selected( + g_ptr_array_index(result->evidence_identifiers, 0), + application); + main_window_set_status(application->main_window, + "Preuve et observations OCR importées."); + evidence_identity_ocr_dialog_finish_import( + context->identity_dialog, NULL); + } + else + { + GError *session_error = g_error_new_literal( + G_IO_ERROR, G_IO_ERROR_CANCELLED, + "La session d’enquête a changé avant la fin de l’import."); + evidence_identity_ocr_dialog_finish_import( + context->identity_dialog, session_error); + g_error_free(session_error); + } + } + else + { + GError *task_error = background_task_dup_error(task); + application_present_error(application, "Import OCR impossible", + task_error != NULL ? task_error->message : + "L’import atomique de la preuve a échoué."); + GError *display_error = task_error != NULL + ? g_error_copy(task_error) + : g_error_new_literal(G_IO_ERROR, G_IO_ERROR_FAILED, + "L’import atomique de la preuve a échoué."); + evidence_identity_ocr_dialog_finish_import( + context->identity_dialog, display_error); + g_error_free(display_error); + g_clear_error(&task_error); + } + g_clear_error(&refresh_error); +} + +/** @brief Traite le choix facultatif effectué dans l'étape OCR. */ +static void application_on_evidence_identity_ocr_completed( + EvidenceIdentityOcrDialogResult *result, gpointer user_data) +{ + ApplicationEvidenceDialogContext *context = user_data; + Application *application = + context != NULL ? context->application : NULL; + if (result == NULL || application == NULL) return; + if (!evidence_identity_ocr_dialog_result_has_ocr(result)) + { + application_start_evidence_import(application, context->file_path, + context->type_identifier, context->collected_at, + context->source, context->description, NULL); + } + else + { + const InvestigationProject *project = + investigation_session_get_project(application->session); + GtkWindow *identity_dialog = + evidence_identity_ocr_dialog_result_get_dialog(result); + IdentityOcrRun *run = + evidence_identity_ocr_dialog_result_steal_run(result); + EvidenceIdentityImportTaskRequest *request = NULL; + ApplicationEvidenceImportContext *task_context = NULL; + BackgroundTask *task = NULL; + GError *error = NULL; + if (project == NULL) { + GError *session_error = g_error_new_literal( + G_IO_ERROR, G_IO_ERROR_CANCELLED, + "La session d’enquête n’est plus active."); + evidence_identity_ocr_dialog_finish_import( + identity_dialog, session_error); + g_error_free(session_error); + identity_ocr_run_free(run); + evidence_identity_ocr_dialog_result_free(result); + return; + } + request = evidence_identity_import_task_request_new( + investigation_project_get_database_path(project), + investigation_project_get_root_path(project), context->file_path, + evidence_identity_ocr_dialog_result_get_person_identifier(result), + context->type_identifier, context->collected_at, + context->source, context->description, run); + task_context = g_new0(ApplicationEvidenceImportContext, 1); + task_context->application = application; + task_context->investigation_root_path = + g_strdup(context->root_path); + task_context->identity_dialog = identity_dialog != NULL + ? g_object_ref(identity_dialog) : NULL; + task_context->identity_session_guard.application = application; + task_context->identity_session_guard.generation = g_atomic_int_get( + &application->identity_import_session_generation); + if (request != NULL) { + evidence_identity_import_task_request_set_session_check( + request, application_evidence_identity_session_check, + &task_context->identity_session_guard); + task = evidence_identity_import_task_start( + application->task_manager, request, + application_on_evidence_identity_import_completed, + task_context, application_evidence_import_context_free, + &error); + } + if (task == NULL) + { + GError *display_error = error != NULL + ? g_error_copy(error) + : g_error_new_literal(G_IO_ERROR, G_IO_ERROR_FAILED, + "La tâche d’import OCR n’a pas pu démarrer."); + evidence_identity_ocr_dialog_finish_import( + identity_dialog, display_error); + g_error_free(display_error); + application_evidence_import_context_free(task_context); + application_present_error(application, "Import OCR impossible", + error != NULL ? error->message : + "La tâche d’import OCR n’a pas pu démarrer."); + } + else + { + main_window_set_status(application->main_window, + "Import atomique de la preuve et des observations OCR…"); + background_task_unref(task); + } + evidence_identity_import_task_request_free(request); + identity_ocr_run_free(run); + g_clear_error(&error); + } + evidence_identity_ocr_dialog_result_free(result); +} + /** * @brief Libère le contexte d’un import asynchrone. */ @@ -879,12 +1089,22 @@ static void application_evidence_import_context_free( g_free( context->investigation_root_path ); + g_clear_object(&context->identity_dialog); g_free( context ); } +static gboolean application_evidence_identity_session_check(gpointer data) +{ + ApplicationEvidenceIdentitySessionGuard *guard = data; + return guard != NULL && guard->application != NULL && + g_atomic_int_get( + &guard->application->identity_import_session_generation) == + guard->generation; +} + /** @brief Libère l'état possédé d'un import groupé. */ static void application_evidence_batch_context_free( ApplicationEvidenceBatchContext *context @@ -2763,6 +2983,7 @@ static gboolean application_install_session( application->tree_model = new_tree_model; application->session = new_session; application->session_generation++; + g_atomic_int_inc(&application->identity_import_session_generation); application->graph_viewport_restored = FALSE; main_window_set_tree_model( @@ -3683,6 +3904,309 @@ static void application_evidence_metadata_context_free( g_free(context->evidence_identifier); g_free(context); } +static void application_existing_evidence_ocr_context_free(gpointer data) +{ + ApplicationExistingEvidenceOcrContext *context = data; + if (context == NULL) return; + g_free(context->evidence_identifier); + g_free(context->root_path); + g_free(context->database_path); + g_clear_object(&context->dialog); + g_free(context->ocr_run_identifier); + g_free(context); +} + +static gboolean application_existing_evidence_ocr_session_is_current( + gpointer data) +{ + ApplicationExistingEvidenceOcrContext *context = data; + return context != NULL && + application_session_matches_root( + context->application, context->root_path); +} + +static void application_on_existing_evidence_ocr_persisted( + BackgroundTask *task, gpointer data) +{ + ApplicationExistingEvidenceOcrContext *context = data; + Application *application = + context != NULL ? context->application : NULL; + if (application == NULL || application->main_window == NULL) return; + if (background_task_get_state(task) == BACKGROUND_TASK_STATE_COMPLETED && + application_session_matches_root(application, context->root_path)) { + main_window_set_status(application->main_window, + "Analyse OCR enregistrée sur la preuve existante."); + application_on_evidence_selected( + context->evidence_identifier, application); + } else { + GError *error = background_task_dup_error(task); + application_present_error(application, + "Analyse OCR impossible", + error != NULL ? error->message : + "L’analyse n’a pas pu être liée à la preuve existante."); + g_clear_error(&error); + } +} + +static void application_on_existing_evidence_ocr_reviewed( + BackgroundTask *task, gpointer data) +{ + ApplicationExistingEvidenceOcrContext *context = data; + Application *application = + context != NULL ? context->application : NULL; + GError *error = NULL; + gboolean success = FALSE; + if (application == NULL || application->main_window == NULL || + context->dialog == NULL) return; + if (background_task_get_state(task) == BACKGROUND_TASK_STATE_COMPLETED && + application_session_matches_root(application, context->root_path)) { + IdentityOcrRun *verified = background_task_get_result(task); + success = verified != NULL && + g_strcmp0(identity_ocr_run_get_identifier(verified), + context->ocr_run_identifier) == 0; + if (success) { + application_on_evidence_selected( + context->evidence_identifier, application); + main_window_set_status(application->main_window, + "Révision OCR enregistrée sans relancer Tesseract."); + evidence_identity_ocr_dialog_finish_import( + context->dialog, NULL); + gtk_window_present( + main_window_get_window(application->main_window)); + } + } + if (!success) { + error = background_task_dup_error(task); + if (error == NULL) + error = g_error_new_literal(G_IO_ERROR, G_IO_ERROR_FAILED, + "La révision OCR n’a pas pu être validée."); + evidence_identity_ocr_dialog_finish_import( + context->dialog, error); + application_present_error(application, + "Révision OCR impossible", error->message); + } + g_clear_error(&error); +} + +static void application_on_existing_evidence_ocr_completed( + EvidenceIdentityOcrDialogResult *result, gpointer data) +{ + ApplicationExistingEvidenceOcrContext *context = data; + Application *application = + context != NULL ? context->application : NULL; + EvidenceIdentityImportTaskRequest *request = NULL; + ApplicationExistingEvidenceOcrContext *task_context = NULL; + BackgroundTask *task = NULL; + IdentityOcrRun *run = NULL; + GError *error = NULL; + if (result == NULL || application == NULL || + !evidence_identity_ocr_dialog_result_has_ocr(result)) + goto cleanup; + run = evidence_identity_ocr_dialog_result_steal_run(result); + if (context->revision) { + GtkWindow *dialog = + evidence_identity_ocr_dialog_result_get_dialog(result); + task_context = g_new0(ApplicationExistingEvidenceOcrContext, 1); + task_context->application = application; + task_context->evidence_identifier = + g_strdup(context->evidence_identifier); + task_context->root_path = g_strdup(context->root_path); + task_context->database_path = g_strdup(context->database_path); + task_context->dialog = dialog != NULL + ? g_object_ref(dialog) : NULL; + task_context->ocr_run_identifier = g_strdup( + identity_ocr_run_get_identifier(run)); + task_context->revision = TRUE; + request = evidence_identity_import_task_request_new_review( + context->database_path, context->root_path, + context->evidence_identifier, run); + if (request != NULL) + evidence_identity_import_task_request_set_session_check( + request, + application_existing_evidence_ocr_session_is_current, + task_context); + if (request != NULL) + task = evidence_identity_import_task_start( + application->task_manager, request, + application_on_existing_evidence_ocr_reviewed, + task_context, application_existing_evidence_ocr_context_free, + &error); + if (task == NULL) { + GError *display_error = error != NULL + ? g_error_copy(error) + : g_error_new_literal(G_IO_ERROR, G_IO_ERROR_FAILED, + "La tâche de révision OCR n’a pas pu démarrer."); + evidence_identity_ocr_dialog_finish_import( + dialog, display_error); + application_present_error(application, + "Révision OCR impossible", display_error->message); + g_error_free(display_error); + } else { + evidence_identity_ocr_dialog_set_submission_task(dialog, task); + task_context = NULL; + background_task_unref(task); + main_window_set_status(application->main_window, + "Enregistrement de la révision OCR…"); + } + goto cleanup; + } + task_context = g_new0(ApplicationExistingEvidenceOcrContext, 1); + task_context->application = application; + task_context->evidence_identifier = + g_strdup(context->evidence_identifier); + task_context->root_path = g_strdup(context->root_path); + task_context->database_path = g_strdup(context->database_path); + request = evidence_identity_import_task_request_new_existing( + context->database_path, context->root_path, + context->evidence_identifier, + evidence_identity_ocr_dialog_result_get_person_identifier(result), + run); + if (request != NULL) + evidence_identity_import_task_request_set_session_check( + request, application_existing_evidence_ocr_session_is_current, + task_context); + if (request != NULL) + task = evidence_identity_import_task_start( + application->task_manager, request, + application_on_existing_evidence_ocr_persisted, + task_context, application_existing_evidence_ocr_context_free, + &error); + if (task == NULL) { + application_present_error(application, "Analyse OCR impossible", + error != NULL ? error->message : + "La conservation de l’analyse n’a pas pu démarrer."); + goto cleanup; + } + task_context = NULL; + main_window_set_status(application->main_window, + "Conservation de l’analyse OCR sur la preuve existante…"); +cleanup: + evidence_identity_import_task_request_free(request); + identity_ocr_run_free(run); + evidence_identity_ocr_dialog_result_free(result); + application_existing_evidence_ocr_context_free(task_context); + g_clear_error(&error); +} + +static void application_on_analyze_existing_evidence( + GtkWindow *parent, const char *evidence_identifier, + gboolean revise_existing, const char *ocr_run_identifier, + gpointer data) +{ + ApplicationEvidenceMetadataContext *metadata_context = data; + Application *application = metadata_context != NULL + ? metadata_context->application : NULL; + Database *database = NULL; + EvidenceDao *evidence_dao = NULL; + EntityDao *entity_dao = NULL; + EvidenceRecord *record = NULL; + GPtrArray *persons = NULL; + ApplicationExistingEvidenceOcrContext *context = NULL; + const InvestigationProject *project = NULL; + const ToolInfo *tesseract = NULL; + ToolRegistry *registry = NULL; + char *path = NULL; + IdentityOcrRun *persisted_run = NULL; + char *persisted_person = NULL; + GError *error = NULL; + if (application == NULL || application->session == NULL || + evidence_identifier == NULL) return; + database = investigation_session_get_database(application->session); + project = investigation_session_get_project(application->session); + evidence_dao = evidence_dao_new(database, &error); + entity_dao = entity_dao_new(database, &error); + if (evidence_dao != NULL) + record = evidence_dao_find_by_identifier( + evidence_dao, evidence_identifier, &error); + if (entity_dao != NULL) + persons = entity_dao_list_all(entity_dao, &error); + if (record != NULL && project != NULL) + path = g_build_filename( + investigation_project_get_root_path(project), + evidence_record_get_relative_path(record), NULL); + registry = tool_initializer_get_registry(application->tool_initializer); + if (registry != NULL) + tesseract = tool_registry_find(registry, "ocr.tesseract"); + context = g_new0(ApplicationExistingEvidenceOcrContext, 1); + context->application = application; + context->evidence_identifier = g_strdup(evidence_identifier); + context->root_path = project != NULL + ? g_strdup(investigation_project_get_root_path(project)) : NULL; + context->database_path = project != NULL + ? g_strdup(investigation_project_get_database_path(project)) : NULL; + context->revision = revise_existing; + if (revise_existing && record != NULL && project != NULL) { + IdentityOcrDao *ocr_dao = identity_ocr_dao_new(database); + GPtrArray *runs = identity_ocr_dao_list_runs_by_evidence( + ocr_dao, evidence_identifier, &error); + if (runs != NULL && ocr_run_identifier != NULL) { + IdentityOcrRunRecord *selected = NULL; + for (guint index = 0; index < runs->len; index++) { + IdentityOcrRunRecord *candidate = + g_ptr_array_index(runs, index); + if (g_strcmp0(candidate->id, + ocr_run_identifier) == 0) { + selected = candidate; + break; + } + } + if (selected != NULL) + persisted_run = identity_ocr_dao_load_run( + ocr_dao, investigation_project_get_root_path(project), + selected->id, &persisted_person, &error); + } + g_clear_pointer(&runs, g_ptr_array_unref); + identity_ocr_dao_free(ocr_dao); + } + if (record == NULL || persons == NULL || path == NULL || + (revise_existing ? persisted_run == NULL || + g_strcmp0(identity_ocr_run_get_evidence_id(persisted_run), + evidence_identifier) != 0 || + g_strcmp0(identity_ocr_run_get_expected_sha256(persisted_run), + evidence_record_get_sha256(record)) != 0 || + !evidence_identity_ocr_dialog_present_review( + parent, path, persons, persisted_person, persisted_run, + application_existing_evidence_ocr_session_is_current, + application_on_existing_evidence_ocr_completed, + context, application_existing_evidence_ocr_context_free, + &error) + : !evidence_identity_ocr_dialog_present( + parent, path, persons, NULL, tesseract, + application_existing_evidence_ocr_session_is_current, + application_on_existing_evidence_ocr_completed, + context, application_existing_evidence_ocr_context_free, + &error))) { + application_present_error(application, "Étape OCR indisponible", + error != NULL ? error->message : + "La preuve ou la liste des personnes est indisponible."); + application_existing_evidence_ocr_context_free(context); + } + g_free(path); + g_free(persisted_person); + identity_ocr_run_free(persisted_run); + g_clear_pointer(&persons, g_ptr_array_unref); + evidence_record_free(record); + entity_dao_free(entity_dao); + evidence_dao_free(evidence_dao); + g_clear_error(&error); +} + +static void application_on_workspace_identity_ocr( + const char *evidence_identifier, gboolean revise_existing, + const char *ocr_run_identifier, gpointer data) +{ + Application *application = data; + if (application == NULL || application->main_window == NULL) return; + ApplicationEvidenceMetadataContext context = { + .application = application, + .evidence_identifier = (char *) evidence_identifier + }; + application_on_analyze_existing_evidence( + main_window_get_window(application->main_window), + evidence_identifier, revise_existing, ocr_run_identifier, + &context); +} + /** @brief Actualise les vues après la destruction du dialogue d'édition. */ static gboolean application_refresh_after_evidence_reclassification( gpointer user_data @@ -3784,8 +4308,12 @@ static void application_on_edit_evidence_requested( context = g_new0(ApplicationEvidenceMetadataContext, 1); context->application = application; context->evidence_identifier = g_strdup(evidence_identifier); - if (!evidence_metadata_dialog_present( + const InvestigationProject *project = + investigation_session_get_project(application->session); + if (!evidence_metadata_dialog_present_with_ocr( main_window_get_window(application->main_window), record, types, + database, investigation_project_get_root_path(project), + application_on_analyze_existing_evidence, application_on_evidence_metadata_edited, context)) { application_evidence_metadata_context_free(context); @@ -4874,6 +5402,12 @@ static void application_on_evidence_metadata_completed( user_data; Application *application = NULL; + const InvestigationProject *project = NULL; + EntityDao *entity_dao = NULL; + GPtrArray *persons = NULL; + ToolRegistry *registry = NULL; + const ToolInfo *tesseract = NULL; + GError *error = NULL; if (context == NULL) { @@ -4918,31 +5452,53 @@ static void application_on_evidence_metadata_completed( return; } - application_start_evidence_import( - application, - context->file_path, - evidence_import_dialog_result_get_type_identifier( - result - ), - evidence_import_dialog_result_get_collected_at( - result - ), - evidence_import_dialog_result_get_source( - result - ), - evidence_import_dialog_result_get_description( - result - ), - NULL - ); + context->type_identifier = g_strdup( + evidence_import_dialog_result_get_type_identifier(result)); + context->collected_at = g_strdup( + evidence_import_dialog_result_get_collected_at(result)); + context->source = g_strdup( + evidence_import_dialog_result_get_source(result)); + context->description = g_strdup( + evidence_import_dialog_result_get_description(result)); + project = investigation_session_get_project(application->session); + context->root_path = project != NULL ? g_strdup( + investigation_project_get_root_path(project)) : NULL; evidence_import_dialog_result_free( result ); - application_evidence_dialog_context_free( - context - ); + if (!evidence_identity_ocr_dialog_file_is_compatible(context->file_path)) + { + application_start_evidence_import(application, context->file_path, + context->type_identifier, context->collected_at, + context->source, context->description, NULL); + application_evidence_dialog_context_free(context); + return; + } + entity_dao = entity_dao_new( + investigation_session_get_database(application->session), &error); + if (entity_dao != NULL) persons = entity_dao_list_all(entity_dao, &error); + registry = tool_initializer_get_registry(application->tool_initializer); + if (registry != NULL) tesseract = + tool_registry_find(registry, "ocr.tesseract"); + if (persons == NULL || + !evidence_identity_ocr_dialog_present( + main_window_get_window(application->main_window), + context->file_path, persons, + context->preselected_person_identifier, tesseract, + application_evidence_ocr_session_is_current, + application_on_evidence_identity_ocr_completed, + context, application_evidence_dialog_context_free, &error)) + { + application_present_error(application, "Étape OCR indisponible", + error != NULL ? error->message : + "La liste des personnes ou le dialogue OCR est indisponible."); + application_evidence_dialog_context_free(context); + } + g_clear_pointer(&persons, g_ptr_array_unref); + entity_dao_free(entity_dao); + g_clear_error(&error); } /** @@ -5127,6 +5683,10 @@ static void application_on_evidence_file_selected( g_strdup( file_path ); + dialog_context->preselected_person_identifier = g_strdup( + application->pending_evidence_import_person_identifier); + g_clear_pointer( + &application->pending_evidence_import_person_identifier, g_free); if (dialog_context->file_path == NULL) { @@ -5290,7 +5850,10 @@ static void application_on_evidence_files_selected( { char *message = g_strdup_printf( "%u fichiers ont été sélectionnés. Chaque fichier sera révisé " - "puis importé séparément avec sa propre empreinte SHA-256.", + "puis importé séparément avec sa propre empreinte SHA-256.\n\n" + "L’analyse OCR contrôlée traite actuellement une preuve à la " + "fois. Importez les fichiers puis analysez-les séparément.\n\n" + "L’import groupé reste disponible sans OCR.", paths->len); application_message_dialog_present_confirmation( main_window_get_window(application->main_window), @@ -5319,6 +5882,8 @@ static void application_on_import_evidence_requested( { return; } + g_clear_pointer( + &application->pending_evidence_import_person_identifier, g_free); if (application->session == NULL) { @@ -5437,7 +6002,20 @@ static void application_on_person_creation_task_completed( return; } main_window_set_status(application->main_window, - "Personne et preuves ajoutées. Actualisation du graphe…"); + "Personne et preuves ajoutées. Actualisation des vues…"); + { + GError *refresh_error = NULL; + application_refresh_investigation_tree( + application, context->root_path); + if (!application_refresh_evidence_models( + application, &refresh_error)) { + application_present_error(application, + "Actualisation incomplète", + refresh_error != NULL ? refresh_error->message : + "La liste des preuves n’a pas pu être actualisée."); + } + g_clear_error(&refresh_error); + } application_start_graph_loading(application, context->database_path); } static void application_on_person_completed( @@ -5465,7 +6043,8 @@ static void application_on_person_completed( application->session_generation, active_root, active_database)) goto changed_session; request = person_creation_task_request_new(active_database, active_root, - input, create_person_dialog_result_get_evidence_selection(result)); + input, create_person_dialog_result_get_evidence_selection(result), + create_person_dialog_result_get_ocr_runs(result)); task_context = g_new0(ApplicationPersonCreationTaskContext, 1); task_context->application = application; task_context->generation = application->session_generation; @@ -5532,9 +6111,13 @@ static void application_on_add_person_requested(gpointer user_data) application->session_generation, investigation_project_get_root_path(project), investigation_project_get_database_path(project)); + const ToolRegistry *registry = + tool_initializer_get_registry(application->tool_initializer); + const ToolInfo *tesseract = + tool_registry_find(registry, "ocr.tesseract"); create_person_dialog_present(main_window_get_window(application->main_window), records, investigation_project_get_root_path(project), - application->task_manager, + application->task_manager, tesseract, application_person_dialog_session_matches, application_on_person_completed, context, (GDestroyNotify) application_person_dialog_context_free); @@ -5828,6 +6411,45 @@ static void application_on_evidence_selected( project = investigation_session_get_project(application->session); investigation_root = project != NULL ? investigation_project_get_root_path(project) : NULL; + if (investigation_root != NULL) { + IdentityOcrDao *ocr_dao = identity_ocr_dao_new(database); + GPtrArray *run_records = + identity_ocr_dao_list_runs_by_evidence( + ocr_dao, evidence_identifier, &error); + GPtrArray *workspace_records = g_ptr_array_new_with_free_func( + (GDestroyNotify) workspace_identity_ocr_record_free); + for (guint index = 0; + error == NULL && run_records != NULL && + index < run_records->len; index++) { + IdentityOcrRunRecord *record = + g_ptr_array_index(run_records, index); + char *person_identifier = NULL; + IdentityOcrRun *run = identity_ocr_dao_load_run( + ocr_dao, investigation_root, record->id, + &person_identifier, &error); + if (run != NULL) { + WorkspaceIdentityOcrRecord *workspace_record = + workspace_identity_ocr_record_new( + run, person_identifier, record->executed_at, + record->text_relative_path, record->text_sha256, + record->tsv_relative_path, record->tsv_sha256); + if (workspace_record != NULL) + g_ptr_array_add(workspace_records, workspace_record); + else + identity_ocr_run_free(run); + } + g_free(person_identifier); + } + if (error != NULL) { + g_warning("Analyse OCR de la preuve indisponible : %s", + error->message); + g_clear_error(&error); + } + main_window_set_identity_ocr_runs(application->main_window, + workspace_records); + g_clear_pointer(&run_records, g_ptr_array_unref); + identity_ocr_dao_free(ocr_dao); + } gboolean eml_analysis_available = FALSE; if (investigation_root != NULL) { const char *selected_name = @@ -6527,6 +7149,25 @@ cleanup: evidence_entity_dao_free(dao); application_person_evidence_context_free(context); } +/** @brief Lance l'import normal avec la personne de la fiche présélectionnée. */ +static void application_on_person_evidence_import_requested(gpointer user_data) +{ + ApplicationPersonEvidenceContext *context = user_data; + Application *application = + context != NULL ? context->application : NULL; + if (application == NULL || application->main_window == NULL || + application->session == NULL) { + application_person_evidence_context_free(context); + return; + } + g_free(application->pending_evidence_import_person_identifier); + application->pending_evidence_import_person_identifier = + g_strdup(context->entity_identifier); + application_person_evidence_context_free(context); + file_dialog_select_files( + main_window_get_window(application->main_window), + application_on_evidence_files_selected, application); +} /** @brief Ouvre le gestionnaire des preuves de la personne sélectionnée. */ static void application_on_person_evidence_requested( const char *entity_identifier, gpointer user_data) @@ -6553,7 +7194,9 @@ static void application_on_person_evidence_requested( !manage_entity_evidence_dialog_present(main_window_get_window( application->main_window), records, selected, investigation_project_get_root_path(project), - application_on_person_evidences_selected, context, &error)) + application->task_manager, + application_on_person_evidences_selected, context, + application_on_person_evidence_import_requested, &error)) goto failure; context = NULL; goto cleanup; failure: @@ -8306,6 +8949,8 @@ static void application_on_activate( application_on_extract_metadata_requested, application); main_window_set_recover_pdf_password_callback(application->main_window, application_on_recover_pdf_password_requested, application); + main_window_set_identity_ocr_callback(application->main_window, + application_on_workspace_identity_ocr, application); main_window_set_graph_node_moved_callback( application->main_window, @@ -8670,6 +9315,8 @@ void application_free( g_clear_pointer(&application->pending_relation_selection_identifier, g_free); g_clear_pointer(&application->pending_entity_selection_identifier, g_free); + g_clear_pointer( + &application->pending_evidence_import_person_identifier, g_free); g_free( application diff --git a/src/core/evidence_identity_import_task.c b/src/core/evidence_identity_import_task.c new file mode 100644 index 0000000..af5d5f2 --- /dev/null +++ b/src/core/evidence_identity_import_task.c @@ -0,0 +1,349 @@ +#include "core/evidence_identity_import_task.h" + +#include "core/evidence_staging.h" +#include "core/person_creation_coordinator.h" +#include "database/database.h" +#include "dao/evidence_dao.h" +#include "dao/identity_ocr_dao.h" +#include "models/person_evidence_selection.h" + +struct EvidenceIdentityImportTaskRequest { + char *database_path; + char *root; + char *source_path; + char *person_identifier; + char *type_identifier; + char *collected_at; + char *source; + char *description; + IdentityOcrRun *run; + char *evidence_identifier; + gboolean review; + PersonCreationSessionCheck session_check; + gpointer session_check_data; +}; + +EvidenceIdentityImportTaskRequest * +evidence_identity_import_task_request_new( + const char *database_path, const char *root, + const char *source_path, const char *person_identifier, + const char *type_identifier, const char *collected_at, + const char *source, const char *description, + const IdentityOcrRun *run) +{ + if (database_path == NULL || root == NULL || source_path == NULL || + person_identifier == NULL || type_identifier == NULL || + collected_at == NULL || run == NULL) return NULL; + EvidenceIdentityImportTaskRequest *request = + g_new0(EvidenceIdentityImportTaskRequest, 1); + request->database_path = g_strdup(database_path); + request->root = g_strdup(root); + request->source_path = g_strdup(source_path); + request->person_identifier = g_strdup(person_identifier); + request->type_identifier = g_strdup(type_identifier); + request->collected_at = g_strdup(collected_at); + request->source = g_strdup(source); + request->description = g_strdup(description); + request->run = identity_ocr_run_copy(run); + return request; +} + +EvidenceIdentityImportTaskRequest * +evidence_identity_import_task_request_new_review( + const char *database_path, const char *root, + const char *evidence_identifier, const IdentityOcrRun *run) +{ + if (database_path == NULL || root == NULL || + evidence_identifier == NULL || run == NULL) return NULL; + EvidenceIdentityImportTaskRequest *request = + g_new0(EvidenceIdentityImportTaskRequest, 1); + request->database_path = g_strdup(database_path); + request->root = g_strdup(root); + request->evidence_identifier = g_strdup(evidence_identifier); + request->run = identity_ocr_run_copy(run); + request->review = TRUE; + return request; +} + +EvidenceIdentityImportTaskRequest * +evidence_identity_import_task_request_new_existing( + const char *database_path, const char *root, + const char *evidence_identifier, const char *person_identifier, + const IdentityOcrRun *run) +{ + if (database_path == NULL || root == NULL || + evidence_identifier == NULL || person_identifier == NULL || + run == NULL) return NULL; + EvidenceIdentityImportTaskRequest *request = + g_new0(EvidenceIdentityImportTaskRequest, 1); + request->database_path = g_strdup(database_path); + request->root = g_strdup(root); + request->evidence_identifier = g_strdup(evidence_identifier); + request->person_identifier = g_strdup(person_identifier); + request->run = identity_ocr_run_copy(run); + return request; +} + +void evidence_identity_import_task_request_set_session_check( + EvidenceIdentityImportTaskRequest *request, + PersonCreationSessionCheck session_check, + gpointer session_check_data) +{ + if (request == NULL) return; + request->session_check = session_check; + request->session_check_data = session_check_data; +} + +void evidence_identity_import_task_request_free( + EvidenceIdentityImportTaskRequest *request) +{ + if (request == NULL) return; + g_free(request->database_path); g_free(request->root); + g_free(request->source_path); g_free(request->person_identifier); + g_free(request->type_identifier); g_free(request->collected_at); + g_free(request->source); g_free(request->description); + g_free(request->evidence_identifier); + identity_ocr_run_free(request->run); + g_free(request); +} + +static gboolean evidence_identity_import_worker( + BackgroundTask *task, GCancellable *cancellable, + gpointer data, gpointer *result, GError **error) +{ + EvidenceIdentityImportTaskRequest *request = data; + EvidenceStaging *staging = NULL; + EvidenceStagingResult *prepared = NULL; + PersonEvidenceSelection *selection = NULL; + GPtrArray *runs = NULL; + Database *database = NULL; + PersonCreationCoordinatorResult *coordinator_result = NULL; + PersonCreationCoordinatorEvidenceMetadata metadata = { + .collected_at = request->collected_at, + .source = request->source, + .description = request->description, + .type_identifier = request->type_identifier + }; + PersonCreationCoordinatorOptions options = { + .session_check = request->session_check, + .session_check_data = request->session_check_data + }; + (void) task; + *result = NULL; + if (request->review) { + IdentityOcrDao *dao = NULL; + IdentityOcrRun *verified = NULL; + GDateTime *now = NULL; + char *timestamp = NULL; + if (g_cancellable_set_error_if_cancelled(cancellable, error)) + goto cleanup; + if (request->session_check != NULL && + !request->session_check(request->session_check_data)) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_CANCELLED, + "La session d’enquête a changé avant la révision."); + goto cleanup; + } + database = database_open(request->database_path); + if (database == NULL) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Impossible d’ouvrir la base de l’enquête."); + goto cleanup; + } + dao = identity_ocr_dao_new(database); + now = g_date_time_new_now_utc(); + timestamp = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ"); + if (dao == NULL || timestamp == NULL || + !identity_ocr_dao_update_review( + dao, request->run, timestamp, error)) + goto review_cleanup; + database_close(database); + database = database_open(request->database_path); + identity_ocr_dao_free(dao); + dao = identity_ocr_dao_new(database); + if (database == NULL || dao == NULL || + (verified = identity_ocr_dao_load_run( + dao, request->root, + identity_ocr_run_get_identifier(request->run), + NULL, error)) == NULL || + g_strcmp0(identity_ocr_run_get_identifier(verified), + identity_ocr_run_get_identifier(request->run)) != 0 || + g_strcmp0(identity_ocr_run_get_raw_text(verified), + identity_ocr_run_get_raw_text(request->run)) != 0) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "La révision OCR n’est pas relisible après validation."); + identity_ocr_run_free(verified); + verified = NULL; + } +review_cleanup: + g_free(timestamp); + if (now != NULL) g_date_time_unref(now); + identity_ocr_dao_free(dao); + if (verified != NULL) { + *result = verified; + database_close(database); + database = NULL; + return TRUE; + } + goto cleanup; + } + if (request->evidence_identifier != NULL) { + EvidenceDao *evidence_dao = NULL; + EvidenceRecord *record = NULL; + database = database_open(request->database_path); + if (database == NULL) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Impossible d’ouvrir la base temporaire de l’enquête."); + goto cleanup; + } + evidence_dao = evidence_dao_new(database, error); + if (evidence_dao != NULL) + record = evidence_dao_find_by_identifier(evidence_dao, + request->evidence_identifier, error); + if (record == NULL || + !person_evidence_selection_add_existing(selection = + person_evidence_selection_new(), record, error) || + !identity_ocr_run_replace_evidence_id(request->run, + request->evidence_identifier)) + { + if (error != NULL && *error == NULL) + g_set_error_literal(error, G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "La preuve existante ne peut pas recevoir l’analyse OCR."); + evidence_record_free(record); + evidence_dao_free(evidence_dao); + goto cleanup; + } + runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + g_ptr_array_add(runs, identity_ocr_run_copy(request->run)); + coordinator_result = + person_creation_coordinator_attach_to_existing_person( + database, request->root, request->person_identifier, + selection, runs, NULL, &options, cancellable, error); + evidence_record_free(record); + evidence_dao_free(evidence_dao); + if (coordinator_result == NULL) goto cleanup; + database_close(database); + database = database_open(request->database_path); + IdentityOcrDao *verification_dao = + identity_ocr_dao_new(database); + IdentityOcrRunRecord *persisted = + verification_dao != NULL + ? identity_ocr_dao_find_run(verification_dao, + identity_ocr_run_get_identifier(request->run), error) + : NULL; + if (persisted == NULL || + g_strcmp0(persisted->evidence_id, + request->evidence_identifier) != 0 || + g_strcmp0(persisted->expected_sha256, + identity_ocr_run_get_expected_sha256( + request->run)) != 0) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "L’analyse OCR n’est pas relisible après réouverture."); + identity_ocr_run_record_free(persisted); + identity_ocr_dao_free(verification_dao); + goto cleanup; + } + identity_ocr_run_record_free(persisted); + identity_ocr_dao_free(verification_dao); + *result = coordinator_result; + coordinator_result = NULL; + goto cleanup; + } + staging = evidence_staging_new(error); + if (staging == NULL) goto cleanup; + prepared = evidence_staging_prepare( + staging, request->source_path, cancellable, error); + if (prepared == NULL) goto cleanup; + selection = person_evidence_selection_new(); + if (!person_evidence_selection_add_staged(selection, + prepared->source_path, prepared->staging_path, + prepared->original_name, prepared->mime_type, + request->type_identifier, prepared->size_bytes, + prepared->sha256, request->description, + prepared->prepared_at, error)) + goto cleanup; + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(selection, 0); + if (!identity_ocr_run_replace_evidence_id(request->run, + person_evidence_selection_item_get_identifier(item))) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA, + "L’identifiant temporaire OCR ne peut pas être remplacé."); + goto cleanup; + } + runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + g_ptr_array_add(runs, identity_ocr_run_copy(request->run)); + database = database_open(request->database_path); + if (database == NULL) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Impossible d’ouvrir la base temporaire de l’enquête."); + goto cleanup; + } + coordinator_result = + person_creation_coordinator_attach_to_existing_person( + database, request->root, request->person_identifier, + selection, runs, &metadata, &options, cancellable, error); + if (coordinator_result == NULL) goto cleanup; + *result = coordinator_result; + coordinator_result = NULL; + +cleanup: + person_creation_coordinator_result_free(coordinator_result); + database_close(database); + g_clear_pointer(&runs, g_ptr_array_unref); + person_evidence_selection_free(selection); + evidence_staging_result_free(prepared); + if (*result == NULL) evidence_staging_cleanup(staging, NULL); + evidence_staging_free(staging); + return *result != NULL; +} + +BackgroundTask *evidence_identity_import_task_start( + TaskManager *manager, + const EvidenceIdentityImportTaskRequest *request, + BackgroundTaskCompletionCallback callback, + gpointer callback_data, GDestroyNotify callback_data_destroy, + GError **error) +{ + if (manager == NULL || request == NULL) return NULL; + EvidenceIdentityImportTaskRequest *copy = + request->review + ? evidence_identity_import_task_request_new_review( + request->database_path, request->root, + request->evidence_identifier, request->run) + : request->evidence_identifier != NULL + ? evidence_identity_import_task_request_new_existing( + request->database_path, request->root, + request->evidence_identifier, request->person_identifier, + request->run) + : evidence_identity_import_task_request_new( + request->database_path, request->root, request->source_path, + request->person_identifier, request->type_identifier, + request->collected_at, request->source, + request->description, request->run); + if (copy != NULL) + evidence_identity_import_task_request_set_session_check( + copy, request->session_check, request->session_check_data); + BackgroundTask *task = background_task_new(request->review + ? "Enregistrement de la révision OCR" + : "Import de la preuve et conservation OCR"); + if (copy == NULL || task == NULL || + !task_manager_add(manager, task, error) || + !background_task_start(task, evidence_identity_import_worker, + copy, + (GDestroyNotify) evidence_identity_import_task_request_free, + request->review + ? (GDestroyNotify) identity_ocr_run_free + : (GDestroyNotify) person_creation_coordinator_result_free, + callback, callback_data, callback_data_destroy, error)) { + evidence_identity_import_task_request_free(copy); + background_task_unref(task); + return NULL; + } + return task; +} diff --git a/src/core/evidence_preview.c b/src/core/evidence_preview.c index 2775df8..26ce5fd 100644 --- a/src/core/evidence_preview.c +++ b/src/core/evidence_preview.c @@ -142,6 +142,48 @@ static gboolean valid_dimensions(gint width, gint height, GError **error) } return TRUE; } +static guint16 read_u16(const guint8*p,gboolean little) +{return little?(guint16)(p[0]|p[1]<<8):(guint16)(p[0]<<8|p[1]);} +static guint32 read_u32(const guint8*p,gboolean little) +{return little?((guint32)p[0]|(guint32)p[1]<<8|(guint32)p[2]<<16|(guint32)p[3]<<24): + ((guint32)p[0]<<24|(guint32)p[1]<<16|(guint32)p[2]<<8|p[3]);} +static gint jpeg_exif_orientation(const char*path) +{ + char*raw=NULL;gsize size=0;if(!g_file_get_contents(path,&raw,&size,NULL))return 1; + const guint8*d=(const guint8*)raw;gint orientation=1; + for(gsize p=2;p+4size)break; + if(marker==0xe1&&length>=16&&memcmp(d+p+4,"Exif\0\0",6)==0){ + const guint8*t=d+p+10;gsize available=p+2+length-(p+10); + gboolean little=available>=8&&t[0]=='I'&&t[1]=='I'; + if((little||(t[0]=='M'&&t[1]=='M'))&&read_u16(t+2,little)==42){ + guint32 offset=read_u32(t+4,little); + if(offset+2<=available){guint16 count=read_u16(t+offset,little); + for(guint i=0;i 0 && height > 0 && @@ -204,6 +246,8 @@ static gboolean load_jpeg(const char *path, EvidencePreviewResult *result, if (cancelled(cancellable, error)) { cairo_surface_destroy(surface); return FALSE; } + surface=orient_surface(surface,jpeg_exif_orientation(path),width,height, + &width,&height); gboolean ok = surface_to_result(surface, width, height, result, error); cairo_surface_destroy(surface); return ok; invalid: diff --git a/src/core/identity_field_extractor.c b/src/core/identity_field_extractor.c new file mode 100644 index 0000000..9025327 --- /dev/null +++ b/src/core/identity_field_extractor.c @@ -0,0 +1,44 @@ +#include "core/identity_field_extractor.h" +typedef struct { char *text; gint x,y,w,h; double confidence; } Word; +static void word_free(gpointer p){Word*w=p;if(w){g_free(w->text);g_free(w);}} +static GPtrArray *parse_tsv(const char *tsv) +{ + GPtrArray*a=g_ptr_array_new_with_free_func(word_free); + char **lines=g_strsplit(tsv!=NULL?tsv:"","\n",-1); + for(guint i=1;lines[i]!=NULL;i++){char **c=g_strsplit(lines[i],"\t",12); + if(g_strv_length(c)>=12&&c[11][0]!='\0'){Word*w=g_new0(Word,1); + w->x=(gint)g_ascii_strtoll(c[6],NULL,10);w->y=(gint)g_ascii_strtoll(c[7],NULL,10); + w->w=(gint)g_ascii_strtoll(c[8],NULL,10);w->h=(gint)g_ascii_strtoll(c[9],NULL,10); + w->confidence=g_ascii_strtod(c[10],NULL);w->text=g_strdup(c[11]); + g_ptr_array_add(a,w);}g_strfreev(c);}g_strfreev(lines);return a; +} +typedef struct {const char*label;const char*code;} Rule; +static const Rule rules[]={ + {"NOM :","surname"},{"NOM DE NAISSANCE :","birth_name"}, + {"PRÉNOMS :","given_names"},{"PRENOMS :","given_names"}, + {"DATE DE NAISSANCE :","birth_date"},{"LIEU DE NAISSANCE :","birth_place"}, + {"NATIONALITÉ :","nationality"},{"N° DU DOCUMENT :","document_number"}, + {"NUMÉRO DU DOCUMENT :","document_number"},{"DÉLIVRÉ LE :","issue_date"}, + {"EXPIRE LE :","expiry_date"},{"AUTORITÉ :","issuing_authority"}, + {"ADRESSE :","address_as_printed"}}; +GPtrArray *identity_field_extractor_extract(const char *text,const char *tsv, + gint iw,gint ih,GError **error) +{ + (void)error;GPtrArray*out=g_ptr_array_new_with_free_func( + (GDestroyNotify)identity_field_observation_free); + GPtrArray*words=parse_tsv(tsv);char**lines=g_strsplit(text!=NULL?text:"","\n",-1); + guint order=0; + for(guint i=0;lines[i]!=NULL;i++){char*line=g_strstrip(lines[i]); + for(guint r=0;rlen;w++){Word*word=g_ptr_array_index(words,w); + if(g_strstr_len(value,-1,word->text)!=NULL){if(!box.available){box.x=word->x;box.y=word->y; + box.width=word->w;box.height=word->h;box.available=TRUE;}else{ + gint right=MAX(box.x+box.width,word->x+word->w);gint bottom=MAX(box.y+box.height,word->y+word->h); + box.x=MIN(box.x,word->x);box.y=MIN(box.y,word->y);box.width=right-box.x;box.height=bottom-box.y;} + confidence=MAX(confidence,word->confidence);}} + IdentityFieldObservation*f=identity_field_observation_new(rules[r].code, + value,confidence,&box,order++);if(f!=NULL)g_ptr_array_add(out,f);}g_free(value);break;}} + g_strfreev(lines);g_ptr_array_unref(words);return out; +} diff --git a/src/core/identity_ocr_preprocessor.c b/src/core/identity_ocr_preprocessor.c new file mode 100644 index 0000000..05440d1 --- /dev/null +++ b/src/core/identity_ocr_preprocessor.c @@ -0,0 +1,124 @@ +#include "core/identity_ocr_preprocessor.h" +#include "core/file_hash.h" +#include +#include +#include + +gboolean identity_ocr_preprocessor_dimensions_are_safe(gint width,gint height, + IdentityOcrPreprocessProfile profile) +{ + if(width<=0||height<=0||width>12000||height>12000|| + (guint64)width*(guint64)height>40000000U|| + profile>IDENTITY_OCR_PREPROCESS_UPSCALE)return FALSE; + if(profile==IDENTITY_OCR_PREPROCESS_UPSCALE&&(width>6000||height>6000|| + (guint64)width*(guint64)height>10000000U))return FALSE; + return TRUE; +} + +static gboolean apply_profile(const char *path, + IdentityOcrPreprocessProfile profile, GError **error) +{ + if (profile == IDENTITY_OCR_PREPROCESS_NONE || + profile == IDENTITY_OCR_PREPROCESS_ORIENTATION) return TRUE; + cairo_surface_t *source = cairo_image_surface_create_from_png(path); + if (cairo_surface_status(source) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(source); + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA, + "L’image de travail OCR est illisible."); + return FALSE; + } + gint width = cairo_image_surface_get_width(source); + gint height = cairo_image_surface_get_height(source); + cairo_surface_t *output = source; + if (profile == IDENTITY_OCR_PREPROCESS_GRAYSCALE) { + cairo_surface_flush(source); + guint8 *pixels = cairo_image_surface_get_data(source); + gint stride = cairo_image_surface_get_stride(source); + for (gint y = 0; y < height; y++) + for (gint x = 0; x < width; x++) { + guint8 *pixel = pixels + y * stride + x * 4; + guint8 gray = (guint8) ((pixel[0] * 11U + + pixel[1] * 59U + pixel[2] * 30U) / 100U); + pixel[0] = gray; pixel[1] = gray; pixel[2] = gray; + } + cairo_surface_mark_dirty(source); + } else if (profile == IDENTITY_OCR_PREPROCESS_UPSCALE && + identity_ocr_preprocessor_dimensions_are_safe(width,height, + profile)) { + output = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, width * 2, height * 2); + cairo_t *cr = cairo_create(output); + cairo_scale(cr, 2.0, 2.0); + cairo_set_source_surface(cr, source, 0, 0); + cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR); + cairo_paint(cr); cairo_destroy(cr); + } + cairo_status_t status = cairo_surface_write_to_png(output, path); + if (output != source) cairo_surface_destroy(output); + cairo_surface_destroy(source); + if (status != CAIRO_STATUS_SUCCESS) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Le prétraitement OCR a échoué."); + return FALSE; + } + return TRUE; +} +IdentityOcrWorkImage *identity_ocr_preprocessor_prepare( + const EvidencePreviewRequest *request,guint pdf_page, + IdentityOcrPreprocessProfile profile,GCancellable*cancellable,GError**error) +{ + if(request==NULL||pdf_page==0||profile>IDENTITY_OCR_PREPROCESS_UPSCALE){ + g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT, + "Paramètres de prétraitement OCR invalides.");return NULL;} + EvidencePreviewResult*r=evidence_preview_load(request,cancellable,error); + if(r==NULL)return NULL; + if((r->kind!=EVIDENCE_PREVIEW_KIND_IMAGE&&r->kind!=EVIDENCE_PREVIEW_KIND_PDF)|| + r->png_bytes==NULL){evidence_preview_result_free(r); + g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NOT_SUPPORTED, + "Cette preuve ne peut pas être préparée pour l’OCR.");return NULL;} + if(!identity_ocr_preprocessor_dimensions_are_safe(r->width,r->height,profile)){ + evidence_preview_result_free(r);g_set_error_literal(error,G_IO_ERROR, + G_IO_ERROR_NO_SPACE,"Dimensions OCR hors limites.");return NULL;} + gchar*path=NULL;gint fd=g_file_open_tmp("labfy-identity-ocr-XXXXXX.png",&path,error); + if(fd<0){evidence_preview_result_free(r);return NULL;}close(fd); + if(r->kind==EVIDENCE_PREVIEW_KIND_PDF){ + char*uri=g_filename_to_uri(r->controlled_path,NULL,error); + PopplerDocument*document=uri!=NULL? + poppler_document_new_from_file(uri,NULL,error):NULL;g_free(uri); + gint pages=document!=NULL?poppler_document_get_n_pages(document):0; + if(document==NULL||pdf_page>(guint)pages){g_clear_object(&document); + g_unlink(path);g_free(path);evidence_preview_result_free(r); + if(error!=NULL&&*error==NULL)g_set_error_literal(error,G_IO_ERROR, + G_IO_ERROR_INVALID_ARGUMENT,"La page PDF choisie n’existe pas.");return NULL;} + PopplerPage*page=poppler_document_get_page(document,(gint)pdf_page-1); + double pw=0,ph=0;poppler_page_get_size(page,&pw,&ph); + if(pw<=0||ph<=0||pw>12000||ph>12000||pw*ph>40000000){ + g_object_unref(page);g_object_unref(document);g_unlink(path);g_free(path); + evidence_preview_result_free(r);g_set_error_literal(error,G_IO_ERROR, + G_IO_ERROR_NO_SPACE,"Dimensions PDF hors limites.");return NULL;} + double scale=MIN(2.0,MIN(2000.0/pw,2000.0/ph)); + gint width=MAX(1,(gint)(pw*scale)),height=MAX(1,(gint)(ph*scale)); + cairo_surface_t*surface=cairo_image_surface_create(CAIRO_FORMAT_ARGB32,width,height); + 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); + cairo_status_t status=cairo_surface_write_to_png(surface,path); + cairo_surface_destroy(surface);g_object_unref(page);g_object_unref(document); + if(status!=CAIRO_STATUS_SUCCESS){g_unlink(path);g_free(path); + evidence_preview_result_free(r);g_set_error_literal(error,G_IO_ERROR, + G_IO_ERROR_FAILED,"Rendu de la page PDF impossible.");return NULL;} + r->width=width;r->height=height; + }else{ + gsize length=0;const guint8*data=g_bytes_get_data(r->png_bytes,&length); + if(!g_file_set_contents(path,(const char*)data,(gssize)length,error)){ + g_unlink(path);g_free(path);evidence_preview_result_free(r);return NULL;} + } + if(!apply_profile(path,profile,error)){g_unlink(path);g_free(path); + evidence_preview_result_free(r);return NULL;} + IdentityOcrWorkImage*out=g_new0(IdentityOcrWorkImage,1);out->path=path; + out->width=r->width;out->height=r->height;out->page=pdf_page; + guint64 size=0;if(!file_hash_compute_sha256(path,cancellable,&out->sha256, + &size,error)){identity_ocr_work_image_free(out);out=NULL;} + evidence_preview_result_free(r);return out; +} +void identity_ocr_work_image_free(IdentityOcrWorkImage*i) +{if(!i)return;if(i->path)g_unlink(i->path);g_free(i->path);g_free(i->sha256);g_free(i);} diff --git a/src/core/identity_ocr_workflow.c b/src/core/identity_ocr_workflow.c new file mode 100644 index 0000000..f799d2f --- /dev/null +++ b/src/core/identity_ocr_workflow.c @@ -0,0 +1,98 @@ +#include "core/identity_ocr_workflow.h" + +#include "core/evidence_preview.h" +#include "core/identity_field_extractor.h" +#include "core/ocr_analysis.h" + +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; +} + +IdentityOcrRun *identity_ocr_workflow_execute( + const IdentityOcrWorkflowRequest *request, + GCancellable *cancellable, + GError **error) +{ + EvidencePreviewRequest *preview_request = NULL; + IdentityOcrWorkImage *image = NULL; + OcrAnalysisResult *ocr = NULL; + IdentityOcrRun *run = NULL; + GPtrArray *fields = NULL; + char *available = NULL; + char *preview_data = NULL; + 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."); + 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); + if (preview_request != NULL) + image = identity_ocr_preprocessor_prepare(preview_request, + request->page_number, request->profile, cancellable, error); + evidence_preview_request_free(preview_request); + if (image == NULL) goto cleanup; + available = ocr_analysis_list_languages( + request->tesseract_executable, cancellable, error); + if (available == NULL) goto cleanup; + ocr = ocr_analysis_run(request->tesseract_executable, image->path, + request->languages, cancellable, error); + if (ocr == NULL) goto cleanup; + run = identity_ocr_run_new(request->evidence_identifier, + request->expected_sha256, request->document_type, + request->document_side, request->page_number, request->languages, + request->preprocessing_profile); + if (run == NULL) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA, + "Le résultat OCR ne peut pas être représenté."); + goto cleanup; + } + identity_ocr_run_set_outputs(run, + ocr->execution != NULL ? ocr->execution->version : + request->tesseract_version, + available, "tesseract stdout -l LANG ; tesseract stdout -l LANG tsv", + ocr->text, ocr->tsv); + if (g_file_get_contents(image->path, &preview_data, &preview_length, + NULL)) { + GBytes *preview = g_bytes_new_take(preview_data, preview_length); + preview_data = NULL; + identity_ocr_run_set_preview(run, preview); + g_bytes_unref(preview); + } + fields = identity_field_extractor_extract(ocr->text, ocr->tsv, + image->width, image->height, error); + if (fields == NULL && error != NULL && *error != NULL) { + identity_ocr_run_free(run); + run = NULL; + goto cleanup; + } + while (fields != NULL && fields->len > 0) + identity_ocr_run_add_field(run, + g_ptr_array_steal_index(fields, 0)); + +cleanup: + g_free(preview_data); + g_clear_pointer(&fields, g_ptr_array_unref); + identity_ocr_work_image_free(image); + ocr_analysis_result_free(ocr); + g_free(available); + return run; +} diff --git a/src/core/mrz_parser.c b/src/core/mrz_parser.c new file mode 100644 index 0000000..8218589 --- /dev/null +++ b/src/core/mrz_parser.c @@ -0,0 +1,31 @@ +#include "core/mrz_parser.h" +static gint value(gchar c) +{ + if(c>='0'&&c<='9')return c-'0'; + if(c>='A'&&c<='Z')return c-'A'+10; + if(c=='<')return 0; + return -1; +} +gint mrz_parser_check_digit(const char*s){static const gint weights[]={7,3,1}; + gint sum=0;if(s==NULL)return -1;for(gsize i=0;s[i];i++){gint v=value(s[i]); + if(v<0)return -1; + sum+=v*weights[i%3];}return sum%10;} +MrzParseResult *mrz_parser_parse(const char*text) +{ + MrzParseResult*r=g_new0(MrzParseResult,1);r->warnings=g_ptr_array_new_with_free_func(g_free); + char**all=g_strsplit(text!=NULL?text:"","\n",-1);GPtrArray*l=g_ptr_array_new(); + for(guint i=0;all[i]!=NULL;i++){g_strstrip(all[i]);if(strlen(all[i])==44)g_ptr_array_add(l,all[i]);} + if(l->len>=2){const char*a=g_ptr_array_index(l,0),*b=g_ptr_array_index(l,1); + r->structure_valid=TRUE;char*num=g_strndup(b,9);r->document_number=g_strdup(num); + gint expected=value(b[9]);r->check_digits_valid=expected>=0&&mrz_parser_check_digit(num)==expected; + r->nationality=g_strndup(b+10,3);const char*names=a+5;char**parts=g_strsplit(names,"<<",2); + r->surname=g_strdup(parts[0]);r->given_names=g_strdup(parts[1]!=NULL?parts[1]:""); + g_strdelimit(r->surname,"<",' ');g_strdelimit(r->given_names,"<",' '); + g_strfreev(parts);g_free(num);if(!r->check_digits_valid)g_ptr_array_add(r->warnings, + g_strdup("Chiffre de contrôle MRZ incohérent ; aucun verdict d’authenticité."));} + else g_ptr_array_add(r->warnings,g_strdup("Structure MRZ non reconnue.")); + g_ptr_array_unref(l);g_strfreev(all);return r; +} +void mrz_parse_result_free(MrzParseResult*r){if(!r)return;g_free(r->document_number); + g_free(r->nationality);g_free(r->surname);g_free(r->given_names); + g_ptr_array_unref(r->warnings);g_free(r);} diff --git a/src/core/ocr_analysis.c b/src/core/ocr_analysis.c index e6e5d18..ff8e5d5 100644 --- a/src/core/ocr_analysis.c +++ b/src/core/ocr_analysis.c @@ -5,6 +5,12 @@ #include "core/ocr_analysis.h" #include "core/document_tool_runner.h" +static gint compare_language_codes(gconstpointer left, gconstpointer right) +{ + return g_strcmp0(*(const char *const *) left, + *(const char *const *) right); +} + gboolean ocr_analysis_mime_is_compatible(const char *mime_type) { return g_strcmp0(mime_type, "image/png") == 0 || @@ -18,7 +24,9 @@ void ocr_analysis_result_free(OcrAnalysisResult *result) return; document_tool_execution_free(result->execution); g_free(result->requested_languages); + g_free(result->available_languages); g_free(result->text); + g_free(result->tsv); g_free(result); } @@ -49,9 +57,7 @@ OcrAnalysisResult *ocr_analysis_run_with_limits( { if (executable == NULL || image_path == NULL || languages == NULL || languages[0] == '\0' || - (!g_str_equal(languages, "fra") && - !g_str_equal(languages, "eng") && - !g_str_equal(languages, "fra+eng"))) + strpbrk(languages, " \t\r\n/\\") != NULL) { g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, "Les paramètres OCR sont invalides."); @@ -60,6 +66,9 @@ OcrAnalysisResult *ocr_analysis_run_with_limits( const char *arguments[] = { image_path, "stdout", "-l", languages, NULL }; + const char *tsv_arguments[] = { + image_path, "stdout", "-l", languages, "tsv", NULL + }; const char *version_arguments[] = { "--version", NULL }; DocumentToolExecution *execution = NULL; if (!document_tool_runner_run_with_limits("tesseract", executable, @@ -94,5 +103,67 @@ OcrAnalysisResult *ocr_analysis_run_with_limits( g_ptr_array_add(execution->warnings, g_strdup("Tesseract n'a produit aucun texte.")); } + DocumentToolExecution *tsv_execution = NULL; + if (!document_tool_runner_run_with_limits("tesseract", executable, + tsv_arguments, image_path, limits, cancellable, + &tsv_execution, error)) { + ocr_analysis_result_free(result); + return NULL; + } + result->tsv = g_strdup(tsv_execution->raw_stdout); + document_tool_execution_free(tsv_execution); return result; } + +GPtrArray *ocr_analysis_parse_languages(const char *output) +{ + GPtrArray *items = g_ptr_array_new_with_free_func(g_free); + if (output == NULL) return items; + char **lines = g_strsplit(output, "\n", -1); + for (guint i = 0; lines[i] != NULL; i++) { + char *code = g_strstrip(lines[i]); + if (*code == '\0' || g_str_has_prefix(code, + "List of available languages")) continue; + gboolean valid = TRUE; + for (const char *p = code; *p != '\0'; p++) + if (!g_ascii_isalnum(*p) && *p != '_' && *p != '-') valid = FALSE; + gboolean duplicate = FALSE; + for (guint j = 0; valid && j < items->len; j++) + if (g_str_equal(code, g_ptr_array_index(items, j))) + duplicate = TRUE; + if (valid && !duplicate) g_ptr_array_add(items, g_strdup(code)); + } + g_strfreev(lines); + g_ptr_array_sort(items, compare_language_codes); + return items; +} + +GPtrArray *ocr_analysis_build_language_choices(const GPtrArray *languages) +{ + GPtrArray *choices = g_ptr_array_new_with_free_func(g_free); + gboolean fra = FALSE, eng = FALSE; + for (guint i = 0; languages != NULL && i < languages->len; i++) { + const char *code = g_ptr_array_index((GPtrArray *) languages, i); + g_ptr_array_add(choices, g_strdup(code)); + fra |= g_str_equal(code, "fra"); + eng |= g_str_equal(code, "eng"); + } + if (fra && eng) g_ptr_array_add(choices, g_strdup("fra+eng")); + return choices; +} + +char *ocr_analysis_list_languages(const char *executable, + GCancellable *cancellable, GError **error) +{ + const char *arguments[] = {"--list-langs", NULL}; + const DocumentToolRunnerLimits limits = {65536U, 16384U}; + DocumentToolExecution *execution = NULL; + char *languages = NULL; + if (executable == NULL || + !document_tool_runner_run_with_limits("tesseract", executable, + arguments, executable, &limits, cancellable, &execution, error)) + return NULL; + languages = g_strdup(execution->raw_stdout); + document_tool_execution_free(execution); + return languages; +} diff --git a/src/core/ocr_region_geometry.c b/src/core/ocr_region_geometry.c new file mode 100644 index 0000000..429c282 --- /dev/null +++ b/src/core/ocr_region_geometry.c @@ -0,0 +1,26 @@ +#include "core/ocr_region_geometry.h" + +gboolean ocr_region_geometry_transform(gint sw, gint sh, gint x, gint y, + gint width, gint height, double dw, double dh, OcrRegionFit fit, + OcrDisplayRegion *out) +{ + if (out == NULL || sw <= 0 || sh <= 0 || width <= 0 || height <= 0 || + dw <= 0 || dh <= 0 || fit > OCR_REGION_FIT_FILL) + return FALSE; + gint x1 = CLAMP(x, 0, sw); + gint y1 = CLAMP(y, 0, sh); + gint x2 = (gint) CLAMP((gint64) x + width, 0, (gint64) sw); + gint y2 = (gint) CLAMP((gint64) y + height, 0, (gint64) sh); + if (x2 <= x1 || y2 <= y1) return FALSE; + double sx = dw / sw, sy = dh / sh, ox = 0, oy = 0; + if (fit == OCR_REGION_FIT_CONTAIN) { + sx = sy = MIN(sx, sy); + ox = (dw - sw * sx) / 2.0; + oy = (dh - sh * sy) / 2.0; + } + out->x = ox + x1 * sx; + out->y = oy + y1 * sy; + out->width = (x2 - x1) * sx; + out->height = (y2 - y1) * sy; + return TRUE; +} diff --git a/src/core/person_creation_coordinator.c b/src/core/person_creation_coordinator.c index 6f29104..20ca075 100644 --- a/src/core/person_creation_coordinator.c +++ b/src/core/person_creation_coordinator.c @@ -4,6 +4,7 @@ #include "dao/evidence_dao.h" #include "dao/evidence_entity_dao.h" #include "dao/person_role_assignment_dao.h" +#include "dao/identity_ocr_dao.h" #include "database/transaction.h" #include #include @@ -13,6 +14,29 @@ static GQuark coordinator_error(void) { return g_quark_from_static_string("person-creation-coordinator-error"); } +typedef struct { + const PersonCreationCoordinatorOptions *options; + guint occurrences[PERSON_CREATION_FAILURE_COMPENSATION + 1]; +} FailureControl; +static gboolean fail_at(FailureControl *control, + PersonCreationFailurePoint point, GError **error) +{ + guint occurrence; + if (control == NULL || control->options == NULL || + control->options->failure_point != point) return FALSE; + occurrence = control->occurrences[point]++; + if (occurrence != control->options->failure_occurrence) return FALSE; + if (error != NULL && *error == NULL) + g_set_error(error, coordinator_error(), 100 + point, + "Échec injecté au point %u, occurrence %u.", + (guint) point, occurrence); + return TRUE; +} +static gboolean session_valid(const PersonCreationCoordinatorOptions *options) +{ + return options == NULL || options->session_check == NULL || + options->session_check(options->session_check_data); +} static gboolean cancelled(GCancellable *cancellable, GError **error) { return cancellable != NULL && @@ -140,9 +164,110 @@ static gboolean validate_selection_files(const char *root, } return TRUE; } -PersonCreationCoordinatorResult *person_creation_coordinator_execute( + +static gboolean persist_ocr_runs(IdentityOcrDao *dao, const char *root, + const char *person_identifier, const PersonEvidenceSelection *selection, + const GPtrArray *evidence_identifiers, const GPtrArray *runs, + const char *timestamp, GPtrArray *created_paths, + GPtrArray *created_directories, + FailureControl *failure, + GCancellable *cancellable, GError **error) +{ + for (guint i = 0; runs != NULL && i < runs->len; i++) { + IdentityOcrRun *run = g_ptr_array_index((GPtrArray *) runs, i); + const char *evidence_identifier = NULL; + for (guint j = 0; j < person_evidence_selection_get_count(selection); + j++) { + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(selection, j); + if (g_strcmp0(person_evidence_selection_item_get_identifier(item), + identity_ocr_run_get_evidence_id(run)) == 0) { + evidence_identifier = g_ptr_array_index( + (GPtrArray *) evidence_identifiers, j); + break; + } + } + if (evidence_identifier == NULL) { + g_set_error_literal(error, coordinator_error(), 4, + "La preuve source de l’OCR n’est plus retenue."); + return FALSE; + } + char *parent = g_build_filename(root, "02_Preuves_Traitees", + "OCR", NULL); + char *directory = g_build_filename(parent, + identity_ocr_run_get_identifier(run), NULL); + gboolean parent_existed = g_file_test(parent, G_FILE_TEST_IS_DIR); + gboolean directory_existed = + g_file_test(directory, G_FILE_TEST_IS_DIR); + char *text_absolute = g_build_filename(directory, "ocr.txt", NULL); + char *tsv_absolute = g_build_filename(directory, "ocr.tsv", NULL); + char *text_relative = g_build_filename("02_Preuves_Traitees", "OCR", + identity_ocr_run_get_identifier(run), "ocr.txt", NULL); + char *tsv_relative = g_build_filename("02_Preuves_Traitees", "OCR", + identity_ocr_run_get_identifier(run), "ocr.tsv", NULL); + char *text_sha = NULL, *tsv_sha = NULL; + guint64 artifact_size = 0; + gboolean ok = + !fail_at(failure, + PERSON_CREATION_FAILURE_CREATE_OCR_DIRECTORY, error) && + g_mkdir_with_parents(directory, 0700) == 0 && + !fail_at(failure, PERSON_CREATION_FAILURE_COPY_OCR_TEXT, error) && + g_file_set_contents(text_absolute, + identity_ocr_run_get_raw_text(run), -1, error) && + !fail_at(failure, PERSON_CREATION_FAILURE_COPY_OCR_TSV, error) && + g_file_set_contents(tsv_absolute, + identity_ocr_run_get_tsv(run), -1, error) && + !fail_at(failure, PERSON_CREATION_FAILURE_HASH_OCR_TEXT, error) && + file_hash_compute_sha256(text_absolute, cancellable, + &text_sha, &artifact_size, error) && + !fail_at(failure, PERSON_CREATION_FAILURE_HASH_OCR_TSV, error) && + file_hash_compute_sha256(tsv_absolute, cancellable, + &tsv_sha, &artifact_size, error); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + ok = ok && !fail_at(failure, + PERSON_CREATION_FAILURE_INSERT_OCR_RUN, error) && + !fail_at(failure, + PERSON_CREATION_FAILURE_INSERT_DOCUMENT_OBSERVATION, error); + for (guint field_index = 0; ok && fields != NULL && + field_index < fields->len; field_index++) { + IdentityFieldObservation *field = + g_ptr_array_index((GPtrArray *) fields, field_index); + IdentityReviewStatus status = + identity_field_observation_get_status(field); + if (status != IDENTITY_REVIEW_ACCEPTED && + status != IDENTITY_REVIEW_MODIFIED) continue; + ok = !fail_at(failure, + PERSON_CREATION_FAILURE_INSERT_FIELD, error) && + !fail_at(failure, + PERSON_CREATION_FAILURE_CREATE_SOURCE, error); + } + ok = ok && + identity_ocr_dao_insert(dao, person_identifier, + evidence_identifier, run, text_relative, text_sha, + tsv_relative, tsv_sha, timestamp, error); + if (g_file_test(text_absolute, G_FILE_TEST_IS_REGULAR)) + g_ptr_array_add(created_paths, g_strdup(text_absolute)); + if (g_file_test(tsv_absolute, G_FILE_TEST_IS_REGULAR)) + g_ptr_array_add(created_paths, g_strdup(tsv_absolute)); + if (!parent_existed && g_file_test(parent, G_FILE_TEST_IS_DIR)) + g_ptr_array_add(created_directories, g_strdup(parent)); + if (!directory_existed && g_file_test(directory, G_FILE_TEST_IS_DIR)) + g_ptr_array_add(created_directories, g_strdup(directory)); + g_free(parent); g_free(directory); g_free(text_absolute); g_free(tsv_absolute); + g_free(text_relative); g_free(tsv_relative); + g_free(text_sha); g_free(tsv_sha); + if (!ok) return FALSE; + } + return TRUE; +} + +static PersonCreationCoordinatorResult *person_creation_coordinator_execute_internal( Database *database, const char *root, const PersonEntityInput *person, - const PersonEvidenceSelection *selection, GCancellable *cancellable, + const char *existing_person_identifier, + const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs, + const PersonCreationCoordinatorEvidenceMetadata *metadata, + const PersonCreationCoordinatorOptions *options, + GCancellable *cancellable, GError **error) { PersonCreationCoordinatorResult *result = NULL; @@ -150,39 +275,72 @@ PersonCreationCoordinatorResult *person_creation_coordinator_execute( EvidenceDao *evidence_dao = NULL; EvidenceEntityDao *link_dao = NULL; PersonRoleAssignmentDao *role_dao = NULL; + IdentityOcrDao *ocr_dao = NULL; EntityRecord *person_record = NULL; GPtrArray *created_paths = g_ptr_array_new_with_free_func(g_free); + GPtrArray *created_directories = g_ptr_array_new_with_free_func(g_free); char *timestamp = NULL; gboolean transaction_active = FALSE, success = FALSE; - if (database == NULL || root == NULL || person == NULL || - person->designation == NULL || person->designation[0] == '\0' || + gboolean create_person = existing_person_identifier == NULL; + FailureControl failure = {.options = options}; + if (fail_at(&failure, PERSON_CREATION_FAILURE_VALIDATE, error)) + goto cleanup; + if (database == NULL || root == NULL || + (create_person && (person == NULL || + person->designation == NULL || person->designation[0] == '\0')) || + (!create_person && (existing_person_identifier[0] == '\0' || + !g_uuid_string_is_valid(existing_person_identifier))) || !person_evidence_selection_is_confirmable(selection)) { g_set_error_literal(error, coordinator_error(), 1, "La création de personne ou la sélection est invalide."); goto cleanup; } + if (!session_valid(options) || + fail_at(&failure, PERSON_CREATION_FAILURE_SESSION_BEFORE_START, + error)) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, coordinator_error(), 6, + "La session d’enquête a changé avant la création."); + goto cleanup; + } if (cancelled(cancellable, error)) goto cleanup; + if (fail_at(&failure, PERSON_CREATION_FAILURE_SOURCE_HASH, error)) + goto cleanup; if (!validate_selection_files(root, selection, cancellable, error)) goto cleanup; timestamp = timestamp_now(); result = g_new0(PersonCreationCoordinatorResult, 1); - result->person_identifier = g_uuid_string_random(); + result->person_identifier = create_person + ? g_uuid_string_random() : g_strdup(existing_person_identifier); result->evidence_identifiers = g_ptr_array_new_with_free_func(g_free); entity_dao = entity_dao_new(database, error); evidence_dao = evidence_dao_new(database, error); link_dao = evidence_entity_dao_new(database, error); role_dao = person_role_assignment_dao_new(database, error); - person_record = build_person(person, result->person_identifier, - timestamp, error); + ocr_dao = identity_ocr_dao_new(database); + person_record = entity_dao == NULL ? NULL : create_person + ? build_person(person, result->person_identifier, timestamp, error) + : entity_dao_find_by_identifier( + entity_dao, result->person_identifier, error); if (timestamp == NULL || entity_dao == NULL || evidence_dao == NULL || - link_dao == NULL || role_dao == NULL || person_record == NULL || + link_dao == NULL || role_dao == NULL || ocr_dao == NULL || + person_record == NULL || + g_strcmp0(entity_record_get_type_identifier(person_record), + "person") != 0 || !database_transaction_begin(database)) goto cleanup; transaction_active = TRUE; - if (!entity_dao_insert(entity_dao, person_record, error) || - !person_role_assignment_dao_insert_all(role_dao, - result->person_identifier, person->role_assignments, error)) + if (create_person && + (fail_at(&failure, PERSON_CREATION_FAILURE_CREATE_PERSON, error) || + !entity_dao_insert(entity_dao, person_record, error))) goto cleanup; + for (guint i = 0; create_person && person->role_assignments != NULL && + i < person->role_assignments->len; i++) + if (fail_at(&failure, PERSON_CREATION_FAILURE_CREATE_ROLE, error) || + !person_role_assignment_dao_insert(role_dao, + result->person_identifier, + g_ptr_array_index(person->role_assignments, i), error)) + goto cleanup; for (guint i = 0; i < person_evidence_selection_get_count(selection); i++) { const PersonEvidenceSelectionItem *item = @@ -193,6 +351,8 @@ PersonCreationCoordinatorResult *person_creation_coordinator_execute( char *absolute = NULL, *duplicate_identifier = NULL; EvidenceRecord *record = NULL; if (cancelled(cancellable, error)) goto cleanup; + if (fail_at(&failure, PERSON_CREATION_FAILURE_IMPORT_EVIDENCE, + error)) goto cleanup; if (person_evidence_selection_item_get_origin(item) == PERSON_EVIDENCE_ORIGIN_STAGED) { duplicate_identifier = evidence_dao_find_identifier_by_sha256( @@ -215,11 +375,19 @@ PersonCreationCoordinatorResult *person_creation_coordinator_execute( record = evidence_record_new(identifier, person_evidence_selection_item_get_original_name(item), internal, relative, - person_evidence_selection_item_get_type_identifier(item), + metadata != NULL && metadata->type_identifier != NULL + ? metadata->type_identifier + : person_evidence_selection_item_get_type_identifier(item), person_evidence_selection_item_get_size_bytes(item), - person_evidence_selection_item_get_sha256(item), timestamp, - NULL, "Import depuis l’assistant de création d’une personne", - person_evidence_selection_item_get_description(item), + person_evidence_selection_item_get_sha256(item), + metadata != NULL && metadata->collected_at != NULL + ? metadata->collected_at : timestamp, + metadata != NULL ? metadata->source : NULL, create_person + ? "Import depuis l’assistant de création d’une personne" + : "Import OCR rattaché à une personne existante", + metadata != NULL && metadata->description != NULL + ? metadata->description + : person_evidence_selection_item_get_description(item), EVIDENCE_INTEGRITY_STATUS_VALID, error); if (record == NULL || !evidence_dao_insert(evidence_dao, record, error)) { @@ -230,7 +398,9 @@ PersonCreationCoordinatorResult *person_creation_coordinator_execute( evidence_identifier = identifier; } } - if (!evidence_entity_dao_link(link_dao, evidence_identifier, + if (fail_at(&failure, PERSON_CREATION_FAILURE_LINK_EVIDENCE, + error) || + !evidence_entity_dao_link(link_dao, evidence_identifier, result->person_identifier, error)) { evidence_record_free(record); g_free(identifier); g_free(duplicate_identifier); @@ -244,7 +414,61 @@ PersonCreationCoordinatorResult *person_creation_coordinator_execute( g_free(identifier); g_free(relative); g_free(internal); g_free(absolute); } - if (!database_transaction_commit(database)) goto cleanup; + if (!persist_ocr_runs(ocr_dao, root, result->person_identifier, + selection, result->evidence_identifiers, ocr_runs, timestamp, + created_paths, created_directories, &failure, + cancellable, error)) + goto cleanup; + for (guint i = 0; ocr_runs != NULL && i < ocr_runs->len; i++) { + IdentityOcrRun *run = g_ptr_array_index((GPtrArray *) ocr_runs, i); + char *directory = g_build_filename(root, "02_Preuves_Traitees", + "OCR", identity_ocr_run_get_identifier(run), NULL); + char *text_path = g_build_filename(directory, "ocr.txt", NULL); + char *tsv_path = g_build_filename(directory, "ocr.tsv", NULL); + gboolean alter_text = fail_at(&failure, + PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED, error); + gboolean alter_tsv = fail_at(&failure, + PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED, error); + if (alter_text || alter_tsv) { + g_clear_error(error); + if (!g_file_set_contents(alter_text ? text_path : tsv_path, + "SPECIMEN MODIFIÉ APRÈS COPIE", -1, error)) { + g_free(directory); g_free(text_path); g_free(tsv_path); + goto cleanup; + } + } + char *text_sha = NULL, *tsv_sha = NULL; + guint64 size = 0; + char *expected_text = g_compute_checksum_for_string( + G_CHECKSUM_SHA256, identity_ocr_run_get_raw_text(run), -1); + char *expected_tsv = g_compute_checksum_for_string( + G_CHECKSUM_SHA256, identity_ocr_run_get_tsv(run), -1); + gboolean verified = file_hash_compute_sha256(text_path, cancellable, + &text_sha, &size, error) && + file_hash_compute_sha256(tsv_path, cancellable, + &tsv_sha, &size, error) && + g_strcmp0(text_sha, expected_text) == 0 && + g_strcmp0(tsv_sha, expected_tsv) == 0; + g_free(directory); g_free(text_path); g_free(tsv_path); + g_free(text_sha); g_free(tsv_sha); + g_free(expected_text); g_free(expected_tsv); + if (!verified) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, coordinator_error(), 7, + "Un artefact OCR a changé avant la validation."); + goto cleanup; + } + } + if (!session_valid(options) || + fail_at(&failure, PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT, + error)) { + if (error != NULL && *error == NULL) + g_set_error_literal(error, coordinator_error(), 8, + "La session d’enquête a changé avant la validation."); + goto cleanup; + } + if (fail_at(&failure, PERSON_CREATION_FAILURE_COMMIT, error) || + !database_transaction_commit(database)) goto cleanup; transaction_active = FALSE; success = TRUE; cleanup: @@ -253,12 +477,42 @@ cleanup: if (!success) for (guint i = 0; i < created_paths->len; i++) g_unlink(g_ptr_array_index(created_paths, i)); + if (!success) + for (guint i = created_directories->len; i > 0; i--) { + const char *directory = + g_ptr_array_index(created_directories, i - 1); + gboolean compensation_failure = + failure.options != NULL && + failure.options->inject_compensation_failure && + failure.occurrences[PERSON_CREATION_FAILURE_COMPENSATION]++ == 0; + if (compensation_failure || + fail_at(&failure, PERSON_CREATION_FAILURE_COMPENSATION, + error)) { + if (error != NULL && *error != NULL) { + char *message = g_strdup_printf( + "%s Compensation incomplète : suppression injectée " + "du dossier « %s ».", (*error)->message, directory); + g_clear_error(error); + g_set_error_literal(error, coordinator_error(), 9, + message); + g_free(message); + } + continue; + } + if (g_rmdir(directory) != 0 && errno != ENOENT && + errno != ENOTEMPTY && error != NULL && *error == NULL) + g_set_error(error, coordinator_error(), 5, + "Impossible de nettoyer le dossier OCR : %s.", + g_strerror(errno)); + } g_ptr_array_unref(created_paths); + g_ptr_array_unref(created_directories); entity_record_free(person_record); entity_dao_free(entity_dao); evidence_dao_free(evidence_dao); evidence_entity_dao_free(link_dao); person_role_assignment_dao_free(role_dao); + identity_ocr_dao_free(ocr_dao); g_free(timestamp); if (!success) { person_creation_coordinator_result_free(result); @@ -266,6 +520,36 @@ cleanup: } return result; } +PersonCreationCoordinatorResult *person_creation_coordinator_execute_with_options( + Database *database, const char *root, const PersonEntityInput *person, + const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs, + const PersonCreationCoordinatorOptions *options, + GCancellable *cancellable, GError **error) +{ + return person_creation_coordinator_execute_internal(database, root, + person, NULL, selection, ocr_runs, NULL, options, + cancellable, error); +} +PersonCreationCoordinatorResult * +person_creation_coordinator_attach_to_existing_person( + Database *database, const char *root, const char *person_identifier, + const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs, + const PersonCreationCoordinatorEvidenceMetadata *metadata, + const PersonCreationCoordinatorOptions *options, + GCancellable *cancellable, GError **error) +{ + return person_creation_coordinator_execute_internal(database, root, + NULL, person_identifier, selection, ocr_runs, metadata, options, + cancellable, error); +} +PersonCreationCoordinatorResult *person_creation_coordinator_execute( + Database *database, const char *root, const PersonEntityInput *person, + const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs, + GCancellable *cancellable, GError **error) +{ + return person_creation_coordinator_execute_with_options(database, root, + person, selection, ocr_runs, NULL, cancellable, error); +} void person_creation_coordinator_result_free( PersonCreationCoordinatorResult *result) { diff --git a/src/core/person_creation_task.c b/src/core/person_creation_task.c index 002c84c..0081c05 100644 --- a/src/core/person_creation_task.c +++ b/src/core/person_creation_task.c @@ -11,11 +11,12 @@ struct PersonCreationTaskRequest { char *notes; GPtrArray *roles; PersonEvidenceSelection *selection; + GPtrArray *ocr_runs; }; PersonCreationTaskRequest *person_creation_task_request_new( const char *database_path, const char *root, const PersonEntityInput *person, - const PersonEvidenceSelection *selection) + const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs) { PersonCreationTaskRequest *request; if (database_path == NULL || root == NULL || person == NULL) return NULL; @@ -43,6 +44,11 @@ PersonCreationTaskRequest *person_creation_task_request_new( request->person.evidence_identifier = NULL; request->person.role_assignments = request->roles; request->selection = person_evidence_selection_copy(selection); + request->ocr_runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + for (guint i = 0; ocr_runs != NULL && i < ocr_runs->len; i++) + g_ptr_array_add(request->ocr_runs, + identity_ocr_run_copy(g_ptr_array_index((GPtrArray *) ocr_runs,i))); return request; } void person_creation_task_request_free(PersonCreationTaskRequest *request) @@ -53,6 +59,7 @@ void person_creation_task_request_free(PersonCreationTaskRequest *request) g_free(request->pseudonym); g_free(request->status); g_free(request->notes); g_ptr_array_unref(request->roles); person_evidence_selection_free(request->selection); + g_ptr_array_unref(request->ocr_runs); g_free(request); } static gboolean worker(BackgroundTask *task, GCancellable *cancellable, @@ -69,7 +76,8 @@ static gboolean worker(BackgroundTask *task, GCancellable *cancellable, return FALSE; } *result = person_creation_coordinator_execute(database, request->root, - &request->person, request->selection, cancellable, error); + &request->person, request->selection, request->ocr_runs, + cancellable, error); database_close(database); return *result != NULL; } @@ -83,7 +91,8 @@ BackgroundTask *person_creation_task_start(TaskManager *manager, if (manager == NULL || request == NULL) return NULL; task = background_task_new("Création de la personne et import des preuves"); copy = person_creation_task_request_new(request->database_path, - request->root, &request->person, request->selection); + request->root, &request->person, request->selection, + request->ocr_runs); if (task == NULL || copy == NULL || !task_manager_add(manager, task, error) || !background_task_start(task, worker, copy, diff --git a/src/dao/identity_ocr_dao.c b/src/dao/identity_ocr_dao.c new file mode 100644 index 0000000..06a6996 --- /dev/null +++ b/src/dao/identity_ocr_dao.c @@ -0,0 +1,437 @@ +#include "dao/identity_ocr_dao.h" +#include "database/statement.h" +#include "database/transaction.h" +struct IdentityOcrDao{Database*database;}; +static const char *review_status_text(IdentityReviewStatus status) +{ + if(status==IDENTITY_REVIEW_ACCEPTED)return "accepted"; + if(status==IDENTITY_REVIEW_MODIFIED)return "modified"; + if(status==IDENTITY_REVIEW_REJECTED)return "rejected"; + if(status==IDENTITY_REVIEW_CONFLICT)return "conflict"; + return "proposed"; +} +static gboolean bind_text(DatabaseStatement*s,int i,const char*v) +{return v!=NULL?database_statement_bind_text(s,i,v): + database_statement_bind_null(s,i);} +IdentityOcrDao *identity_ocr_dao_new(Database*d) +{if(!d)return NULL;IdentityOcrDao*dao=g_new0(IdentityOcrDao,1);dao->database=d;return dao;} +void identity_ocr_dao_free(IdentityOcrDao*d){g_free(d);} +gboolean identity_ocr_dao_insert(IdentityOcrDao*d,const char*person, + const char*evidence,const IdentityOcrRun*r,const char*text_path, + const char*text_sha,const char*tsv_path,const char*tsv_sha, + const char*timestamp,GError**error) +{ + const char*sql="INSERT INTO identity_ocr_runs(id,evidence_id,expected_sha256," + "page_number,document_type,document_side,engine,engine_version," + "requested_languages,available_languages,parameters,preprocessing_profile," + "executed_at,status,text_relative_path,text_sha256,tsv_relative_path," + "tsv_sha256,corrected_transcription,transcription_is_human," + "transcription_corrected_at,transcription_origin)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,'success',?,?,?,?,?,?,?,?);"; + DatabaseStatement*s=database_statement_prepare(d->database,sql); + gboolean ok=s&&bind_text(s,1,identity_ocr_run_get_identifier(r))&& + bind_text(s,2,evidence)&&bind_text(s,3,identity_ocr_run_get_expected_sha256(r))&& + database_statement_bind_int64(s,4,identity_ocr_run_get_page(r))&& + bind_text(s,5,identity_ocr_run_get_document_type(r))&& + bind_text(s,6,identity_ocr_run_get_document_side(r))&&bind_text(s,7,"tesseract")&& + bind_text(s,8,identity_ocr_run_get_version(r))&& + bind_text(s,9,identity_ocr_run_get_languages(r))&& + bind_text(s,10,identity_ocr_run_get_available_languages(r))&& + bind_text(s,11,"GSubprocess sans shell; sorties texte et TSV")&& + bind_text(s,12,identity_ocr_run_get_profile(r))&&bind_text(s,13,timestamp)&& + bind_text(s,14,text_path)&&bind_text(s,15,text_sha)&&bind_text(s,16,tsv_path)&& + bind_text(s,17,tsv_sha)&& + bind_text(s,18,identity_ocr_run_get_corrected_transcription(r))&& + database_statement_bind_int64(s,19, + identity_ocr_run_has_human_transcription(r)?1:0)&& + bind_text(s,20,identity_ocr_run_get_transcription_corrected_at(r))&& + bind_text(s,21,identity_ocr_run_has_human_transcription(r)?"human":NULL)&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);if(!ok)goto fail; + char*obs=g_uuid_string_random();s=database_statement_prepare(d->database, + "INSERT INTO identity_document_observations(id,person_id,evidence_id," + "ocr_run_id,document_type,document_side,page_number,review_state,observed_at," + "factual_notes) VALUES(?,?,?,?,?,?,?,'accepted',?,?);"); + ok=s&&bind_text(s,1,obs)&&bind_text(s,2,person)&&bind_text(s,3,evidence)&& + bind_text(s,4,identity_ocr_run_get_identifier(r))&& + bind_text(s,5,identity_ocr_run_get_document_type(r))&& + bind_text(s,6,identity_ocr_run_get_document_side(r))&& + database_statement_bind_int64(s,7,identity_ocr_run_get_page(r))&& + bind_text(s,8,timestamp)&& + bind_text(s,9,identity_ocr_run_get_factual_notes(r))&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);if(!ok){g_free(obs);goto fail;} + const GPtrArray*fields=identity_ocr_run_get_fields(r); + for(guint i=0;fields&&ilen;i++){IdentityFieldObservation*f=g_ptr_array_index((GPtrArray*)fields,i); + IdentityReviewStatus status=identity_field_observation_get_status(f); + const IdentitySourceBox*b=identity_field_observation_get_box(f);char*id=g_uuid_string_random(); + s=database_statement_prepare(d->database,"INSERT INTO identity_field_observations(" + "id,observation_id,field_code,raw_value,corrected_value,confidence,review_status," + "origin,evidence_id,ocr_run_id,page_number,source_x,source_y,source_width," + "source_height,source_image_width,source_image_height,display_order,reviewed_at)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); + ok=s&&bind_text(s,1,id)&&bind_text(s,2,obs)&&bind_text(s,3,identity_field_observation_get_code(f))&& + bind_text(s,4,identity_field_observation_get_raw_value(f))&& + bind_text(s,5,identity_field_observation_get_corrected_value(f))&& + (identity_field_observation_get_confidence(f)>=0? + database_statement_bind_double(s,6,identity_field_observation_get_confidence(f)): + database_statement_bind_null(s,6))&&bind_text(s,7,review_status_text(status))&& + bind_text(s,8,identity_field_observation_get_origin(f))&&bind_text(s,9,evidence)&& + bind_text(s,10,identity_ocr_run_get_identifier(r))&&database_statement_bind_int64(s,11,identity_ocr_run_get_page(r)); + for(int column=12;ok&&column<=17;column++){gint value=0;if(b&&b->available){ + const gint values[]={b->x,b->y,b->width,b->height,b->image_width,b->image_height}; + value=values[column-12];ok=database_statement_bind_int64(s,column,value); + }else ok=database_statement_bind_null(s,column);} + ok=ok&&database_statement_bind_int64(s,18,identity_field_observation_get_order(f))&& + bind_text(s,19,timestamp)&&database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);g_free(id);if(!ok){g_free(obs);goto fail;}} + g_free(obs);return TRUE; +fail:g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),1, + "Impossible de conserver l’OCR d’identité.");return FALSE; +} + +#define FREE_FIELD(record,field) g_free((record)->field) +void identity_ocr_run_record_free(IdentityOcrRunRecord*r) +{if(!r)return;FREE_FIELD(r,id);FREE_FIELD(r,evidence_id);FREE_FIELD(r,expected_sha256); + FREE_FIELD(r,document_type);FREE_FIELD(r,document_side);FREE_FIELD(r,engine); + FREE_FIELD(r,engine_version);FREE_FIELD(r,requested_languages); + FREE_FIELD(r,available_languages);FREE_FIELD(r,parameters); + FREE_FIELD(r,preprocessing_profile);FREE_FIELD(r,executed_at);FREE_FIELD(r,status); + FREE_FIELD(r,error_message);FREE_FIELD(r,text_relative_path);FREE_FIELD(r,text_sha256); + FREE_FIELD(r,tsv_relative_path);FREE_FIELD(r,tsv_sha256); + FREE_FIELD(r,work_image_relative_path);FREE_FIELD(r,work_image_sha256); + FREE_FIELD(r,corrected_transcription); + FREE_FIELD(r,transcription_corrected_at); + FREE_FIELD(r,transcription_origin);g_free(r);} +void identity_document_observation_record_free(IdentityDocumentObservationRecord*r) +{if(!r)return;FREE_FIELD(r,id);FREE_FIELD(r,person_id);FREE_FIELD(r,evidence_id); + FREE_FIELD(r,ocr_run_id);FREE_FIELD(r,document_type); + FREE_FIELD(r,issuing_country_declared);FREE_FIELD(r,document_side); + FREE_FIELD(r,review_state);FREE_FIELD(r,observed_at);FREE_FIELD(r,factual_notes);g_free(r);} +void identity_field_observation_record_free(IdentityFieldObservationRecord*r) +{if(!r)return;FREE_FIELD(r,id);FREE_FIELD(r,observation_id);FREE_FIELD(r,field_code); + FREE_FIELD(r,raw_value);FREE_FIELD(r,corrected_value);FREE_FIELD(r,normalized_value); + FREE_FIELD(r,review_status);FREE_FIELD(r,origin);FREE_FIELD(r,evidence_id); + FREE_FIELD(r,ocr_run_id);FREE_FIELD(r,reviewed_at);FREE_FIELD(r,review_note);g_free(r);} + +static IdentityOcrRunRecord *read_run(DatabaseStatement*s) +{ + gint64 human=0; + IdentityOcrRunRecord*r=g_new0(IdentityOcrRunRecord,1);gboolean ok= + database_statement_column_text(s,0,&r->id)&& + database_statement_column_text(s,1,&r->evidence_id)&& + database_statement_column_text(s,2,&r->expected_sha256)&& + database_statement_column_int64(s,3,&r->page_number)&& + database_statement_column_text(s,4,&r->document_type)&& + database_statement_column_text(s,5,&r->document_side)&& + database_statement_column_text(s,6,&r->engine)&& + database_statement_column_text(s,7,&r->engine_version)&& + database_statement_column_text(s,8,&r->requested_languages)&& + database_statement_column_text(s,9,&r->available_languages)&& + database_statement_column_text(s,10,&r->parameters)&& + database_statement_column_text(s,11,&r->preprocessing_profile)&& + database_statement_column_text(s,12,&r->executed_at)&& + database_statement_column_text(s,13,&r->status)&& + database_statement_column_text(s,14,&r->error_message)&& + database_statement_column_text(s,15,&r->text_relative_path)&& + database_statement_column_text(s,16,&r->text_sha256)&& + database_statement_column_text(s,17,&r->tsv_relative_path)&& + database_statement_column_text(s,18,&r->tsv_sha256)&& + database_statement_column_text(s,19,&r->work_image_relative_path)&& + database_statement_column_text(s,20,&r->work_image_sha256)&& + database_statement_column_text(s,21,&r->corrected_transcription)&& + database_statement_column_int64(s,22,&human)&& + database_statement_column_text(s,23,&r->transcription_corrected_at)&& + database_statement_column_text(s,24,&r->transcription_origin); + if(!ok){identity_ocr_run_record_free(r);return NULL;} + r->transcription_is_human=human!=0;return r; +} +static IdentityDocumentObservationRecord *read_document(DatabaseStatement*s) +{ + IdentityDocumentObservationRecord*r=g_new0(IdentityDocumentObservationRecord,1); + gboolean ok=database_statement_column_text(s,0,&r->id)&& + database_statement_column_text(s,1,&r->person_id)&& + database_statement_column_text(s,2,&r->evidence_id)&& + database_statement_column_text(s,3,&r->ocr_run_id)&& + database_statement_column_text(s,4,&r->document_type)&& + database_statement_column_text(s,5,&r->issuing_country_declared)&& + database_statement_column_text(s,6,&r->document_side)&& + database_statement_column_int64(s,7,&r->page_number)&& + database_statement_column_text(s,8,&r->review_state)&& + database_statement_column_text(s,9,&r->observed_at)&& + database_statement_column_text(s,10,&r->factual_notes); + if(!ok){identity_document_observation_record_free(r);return NULL;}return r; +} +static IdentityFieldObservationRecord *read_field(DatabaseStatement*s) +{ + IdentityFieldObservationRecord*r=g_new0(IdentityFieldObservationRecord,1); + gboolean ok=database_statement_column_text(s,0,&r->id)&& + database_statement_column_text(s,1,&r->observation_id)&& + database_statement_column_text(s,2,&r->field_code)&& + database_statement_column_text(s,3,&r->raw_value)&& + database_statement_column_text(s,4,&r->corrected_value)&& + database_statement_column_text(s,5,&r->normalized_value); + bool is_null=FALSE; + ok=ok&&database_statement_column_is_null(s,6,&is_null); + r->has_confidence=!is_null; + ok=ok&&(!r->has_confidence||database_statement_column_double(s,6,&r->confidence))&& + database_statement_column_text(s,7,&r->review_status)&& + database_statement_column_text(s,8,&r->origin)&& + database_statement_column_text(s,9,&r->evidence_id)&& + database_statement_column_text(s,10,&r->ocr_run_id)&& + database_statement_column_int64(s,11,&r->page_number); + ok=ok&&database_statement_column_is_null(s,12,&is_null); + r->has_source_box=!is_null; + gint64*values[]={&r->source_x,&r->source_y,&r->source_width,&r->source_height, + &r->source_image_width,&r->source_image_height}; + for(guint i=0;ok&&r->has_source_box&&idisplay_order)&& + database_statement_column_text(s,19,&r->reviewed_at)&& + database_statement_column_text(s,20,&r->review_note); + if(!ok){identity_field_observation_record_free(r);return NULL;}return r; +} +static const char run_columns[]="id,evidence_id,expected_sha256,page_number," + "document_type,document_side,engine,engine_version,requested_languages," + "available_languages,parameters,preprocessing_profile,executed_at,status," + "error_message,text_relative_path,text_sha256,tsv_relative_path,tsv_sha256," + "work_image_relative_path,work_image_sha256,corrected_transcription," + "transcription_is_human,transcription_corrected_at,transcription_origin"; +static const char document_columns[]="id,person_id,evidence_id,ocr_run_id," + "document_type,issuing_country_declared,document_side,page_number," + "review_state,observed_at,factual_notes"; +static const char field_columns[]="id,observation_id,field_code,raw_value," + "corrected_value,normalized_value,confidence,review_status,origin,evidence_id," + "ocr_run_id,page_number,source_x,source_y,source_width,source_height," + "source_image_width,source_image_height,display_order,reviewed_at,review_note"; + +typedef gpointer(*ReadRecord)(DatabaseStatement*); +static gpointer read_run_record(DatabaseStatement*s){return read_run(s);} +static gpointer read_document_record(DatabaseStatement*s){return read_document(s);} +static gpointer read_field_record(DatabaseStatement*s){return read_field(s);} +static gpointer find_one(IdentityOcrDao*d,const char*table,const char*columns, + const char*id,ReadRecord read,GError**error) +{ + if(!d||!id){g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),2, + "Lecture OCR invalide.");return NULL;} + char*sql=g_strdup_printf("SELECT %s FROM %s WHERE id=?;",columns,table); + DatabaseStatement*s=database_statement_prepare(d->database,sql);g_free(sql); + if(!s||!database_statement_bind_text(s,1,id)){database_statement_finalize(s); + g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),3, + "Impossible de préparer la lecture OCR.");return NULL;} + DatabaseStatementStepResult step=database_statement_step(s); + gpointer result=step==DATABASE_STATEMENT_STEP_ROW?read(s):NULL; + if(step==DATABASE_STATEMENT_STEP_ERROR||(step==DATABASE_STATEMENT_STEP_ROW&&!result)) + g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),4, + "Impossible de lire l’enregistrement OCR."); + database_statement_finalize(s);return result; +} +static GPtrArray *list_records(IdentityOcrDao*d,const char*table, + const char*columns,const char*foreign_column,const char*id,ReadRecord read, + GDestroyNotify destroy,GError**error) +{ + if(!d||!id){g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),2, + "Lecture OCR invalide.");return NULL;} + char*sql=g_strdup_printf("SELECT %s FROM %s WHERE %s=? ORDER BY rowid;", + columns,table,foreign_column); + DatabaseStatement*s=database_statement_prepare(d->database,sql);g_free(sql); + GPtrArray*a=g_ptr_array_new_with_free_func(destroy); + if(!s||!database_statement_bind_text(s,1,id))goto failed; + for(;;){DatabaseStatementStepResult step=database_statement_step(s); + if(step==DATABASE_STATEMENT_STEP_DONE)break; + if(step!=DATABASE_STATEMENT_STEP_ROW)goto failed; + gpointer record=read(s);if(!record)goto failed;g_ptr_array_add(a,record);} + database_statement_finalize(s);return a; +failed:database_statement_finalize(s);g_ptr_array_unref(a); + g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),4, + "Impossible de lire les enregistrements OCR.");return NULL; +} +IdentityOcrRunRecord *identity_ocr_dao_find_run(IdentityOcrDao*d,const char*i,GError**e) +{return find_one(d,"identity_ocr_runs",run_columns,i,read_run_record,e);} +GPtrArray *identity_ocr_dao_list_runs_by_evidence(IdentityOcrDao*d,const char*i,GError**e) +{return list_records(d,"identity_ocr_runs",run_columns,"evidence_id",i, + read_run_record,(GDestroyNotify)identity_ocr_run_record_free,e);} +IdentityDocumentObservationRecord *identity_ocr_dao_find_document( + IdentityOcrDao*d,const char*i,GError**e) +{return find_one(d,"identity_document_observations",document_columns,i, + read_document_record,e);} +GPtrArray *identity_ocr_dao_list_documents_by_person(IdentityOcrDao*d,const char*i,GError**e) +{return list_records(d,"identity_document_observations",document_columns, + "person_id",i,read_document_record, + (GDestroyNotify)identity_document_observation_record_free,e);} +GPtrArray *identity_ocr_dao_list_documents_by_evidence(IdentityOcrDao*d, + const char*i,GError**e) +{return list_records(d,"identity_document_observations",document_columns, + "evidence_id",i,read_document_record, + (GDestroyNotify)identity_document_observation_record_free,e);} +IdentityFieldObservationRecord *identity_ocr_dao_find_field(IdentityOcrDao*d, + const char*i,GError**e) +{return find_one(d,"identity_field_observations",field_columns,i, + read_field_record,e);} +GPtrArray *identity_ocr_dao_list_fields_by_document(IdentityOcrDao*d,const char*i,GError**e) +{return list_records(d,"identity_field_observations",field_columns, + "observation_id",i,read_field_record, + (GDestroyNotify)identity_field_observation_record_free,e);} + +IdentityOcrRun *identity_ocr_dao_load_run( + IdentityOcrDao*d,const char*root,const char*identifier, + char**person_identifier,GError**error) +{ + IdentityOcrRunRecord*record=identity_ocr_dao_find_run(d,identifier,error); + IdentityOcrRun*run=NULL;GPtrArray*documents=NULL;char*raw=NULL;char*tsv=NULL; + if(person_identifier!=NULL)*person_identifier=NULL; + if(record==NULL||root==NULL)goto cleanup; + char*raw_path=g_build_filename(root,record->text_relative_path,NULL); + char*tsv_path=g_build_filename(root,record->tsv_relative_path,NULL); + if(!g_file_get_contents(raw_path,&raw,NULL,error)|| + !g_file_get_contents(tsv_path,&tsv,NULL,error)){ + g_free(raw_path);g_free(tsv_path);goto cleanup;} + g_free(raw_path);g_free(tsv_path); + run=identity_ocr_run_new(record->evidence_id,record->expected_sha256, + record->document_type,record->document_side,(guint)record->page_number, + record->requested_languages,record->preprocessing_profile); + if(run==NULL||!identity_ocr_run_replace_identifier(run,record->id)) + goto cleanup; + identity_ocr_run_set_outputs(run,record->engine_version, + record->available_languages,record->parameters,raw,tsv); + if(record->transcription_is_human) + identity_ocr_run_set_corrected_transcription(run, + record->corrected_transcription,record->transcription_corrected_at); + documents=identity_ocr_dao_list_documents_by_evidence( + d,record->evidence_id,error); + for(guint i=0;documents!=NULL&&ilen;i++){ + IdentityDocumentObservationRecord*document=g_ptr_array_index(documents,i); + if(g_strcmp0(document->ocr_run_id,identifier)!=0)continue; + identity_ocr_run_set_factual_notes(run,document->factual_notes); + if(person_identifier!=NULL)*person_identifier=g_strdup(document->person_id); + GPtrArray*fields=identity_ocr_dao_list_fields_by_document( + d,document->id,error); + for(guint j=0;fields!=NULL&&jlen;j++){ + IdentityFieldObservationRecord*f=g_ptr_array_index(fields,j); + IdentitySourceBox box={.page=(gint)f->page_number, + .x=(gint)f->source_x,.y=(gint)f->source_y, + .width=(gint)f->source_width,.height=(gint)f->source_height, + .image_width=(gint)f->source_image_width, + .image_height=(gint)f->source_image_height, + .available=f->has_source_box}; + IdentityFieldObservation*field=f->raw_value!=NULL + ?identity_field_observation_new(f->field_code,f->raw_value, + f->has_confidence?f->confidence:-1.0,&box,(guint)f->display_order) + :identity_field_observation_new_manual(f->field_code, + f->corrected_value,(guint)f->display_order); + if(field==NULL)continue; + if(f->corrected_value!=NULL) + identity_field_observation_modify(field,f->corrected_value,f->review_note); + if(g_strcmp0(f->review_status,"accepted")==0) + identity_field_observation_accept(field); + else if(g_strcmp0(f->review_status,"rejected")==0) + identity_field_observation_reject(field); + else if(g_strcmp0(f->review_status,"conflict")==0) + identity_field_observation_mark_conflict(field); + identity_field_observation_set_origin(field,f->origin); + identity_ocr_run_add_field(run,field); + } + g_clear_pointer(&fields,g_ptr_array_unref); + break; + } +cleanup: + if(record==NULL||run==NULL||documents==NULL){ + identity_ocr_run_free(run);run=NULL; + if(error!=NULL&&*error==NULL)g_set_error_literal(error, + g_quark_from_static_string("identity-ocr-dao"),5, + "Impossible de recharger l’analyse OCR persistée.");} + g_free(raw);g_free(tsv);g_clear_pointer(&documents,g_ptr_array_unref); + identity_ocr_run_record_free(record);return run; +} + +static gboolean update_review_fields(IdentityOcrDao*d,const char*observation, + const IdentityOcrRun*run,const char*timestamp) +{ + const GPtrArray*fields=identity_ocr_run_get_fields(run); + for(guint i=0;fields!=NULL&&ilen;i++){ + IdentityFieldObservation*f=g_ptr_array_index((GPtrArray*)fields,i); + const IdentitySourceBox*b=identity_field_observation_get_box(f); + char*id=g_uuid_string_random(); + DatabaseStatement*s=database_statement_prepare(d->database, + "INSERT INTO identity_field_observations(id,observation_id,field_code," + "raw_value,corrected_value,confidence,review_status,origin,evidence_id," + "ocr_run_id,page_number,source_x,source_y,source_width,source_height," + "source_image_width,source_image_height,display_order,reviewed_at)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); + gboolean ok=s&&bind_text(s,1,id)&&bind_text(s,2,observation)&& + bind_text(s,3,identity_field_observation_get_code(f))&& + bind_text(s,4,identity_field_observation_get_raw_value(f))&& + bind_text(s,5,identity_field_observation_get_corrected_value(f))&& + (identity_field_observation_get_confidence(f)>=0 + ?database_statement_bind_double(s,6, + identity_field_observation_get_confidence(f)) + :database_statement_bind_null(s,6))&& + bind_text(s,7,review_status_text( + identity_field_observation_get_status(f)))&& + bind_text(s,8,identity_field_observation_get_origin(f))&& + bind_text(s,9,identity_ocr_run_get_evidence_id(run))&& + bind_text(s,10,identity_ocr_run_get_identifier(run))&& + database_statement_bind_int64(s,11,identity_ocr_run_get_page(run)); + const gint values[]={b!=NULL?b->x:0,b!=NULL?b->y:0, + b!=NULL?b->width:0,b!=NULL?b->height:0, + b!=NULL?b->image_width:0,b!=NULL?b->image_height:0}; + for(gint column=12;ok&&column<=17;column++) + ok=b!=NULL&&b->available + ?database_statement_bind_int64(s,column,values[column-12]) + :database_statement_bind_null(s,column); + ok=ok&&database_statement_bind_int64(s,18, + identity_field_observation_get_order(f))&& + bind_text(s,19,timestamp)&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);g_free(id);if(!ok)return FALSE; + } + return TRUE; +} + +gboolean identity_ocr_dao_update_review(IdentityOcrDao*d, + const IdentityOcrRun*run,const char*timestamp,GError**error) +{ + if(d==NULL||run==NULL||timestamp==NULL|| + !database_transaction_begin(d->database))goto fail; + DatabaseStatement*s=database_statement_prepare(d->database, + "UPDATE identity_ocr_runs SET corrected_transcription=?," + "transcription_is_human=?,transcription_corrected_at=?," + "transcription_origin=? WHERE id=? AND evidence_id=?;"); + gboolean ok=s&&bind_text(s,1, + identity_ocr_run_get_corrected_transcription(run))&& + database_statement_bind_int64(s,2, + identity_ocr_run_has_human_transcription(run)?1:0)&& + bind_text(s,3,identity_ocr_run_get_transcription_corrected_at(run))&& + bind_text(s,4,identity_ocr_run_has_human_transcription(run)?"human":NULL)&& + bind_text(s,5,identity_ocr_run_get_identifier(run))&& + bind_text(s,6,identity_ocr_run_get_evidence_id(run))&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s); + IdentityDocumentObservationRecord*document=NULL; + GPtrArray*documents=ok?identity_ocr_dao_list_documents_by_evidence( + d,identity_ocr_run_get_evidence_id(run),error):NULL; + for(guint i=0;documents!=NULL&&ilen;i++){ + IdentityDocumentObservationRecord*candidate=g_ptr_array_index(documents,i); + if(g_strcmp0(candidate->ocr_run_id, + identity_ocr_run_get_identifier(run))==0){document=candidate;break;}} + if(document==NULL)ok=FALSE; + if(ok){s=database_statement_prepare(d->database, + "UPDATE identity_document_observations SET factual_notes=?," + "review_state='accepted',observed_at=? WHERE id=?;"); + ok=s&&bind_text(s,1,identity_ocr_run_get_factual_notes(run))&& + bind_text(s,2,timestamp)&&bind_text(s,3,document->id)&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);} + if(ok){s=database_statement_prepare(d->database, + "DELETE FROM identity_field_observations WHERE observation_id=?;"); + ok=s&&bind_text(s,1,document->id)&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);} + if(ok)ok=update_review_fields(d,document->id,run,timestamp); + g_clear_pointer(&documents,g_ptr_array_unref); + if(ok&&database_transaction_commit(d->database))return TRUE; + database_transaction_rollback(d->database); +fail: + g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),6, + "Impossible d’enregistrer la révision OCR.");return FALSE; +} diff --git a/src/database/database.c b/src/database/database.c index d96d2fc..c9a8f4a 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -16,12 +16,12 @@ /** * @brief Version actuelle du schéma SQLite. */ -#define DATABASE_SCHEMA_VERSION_CURRENT 14 +#define DATABASE_SCHEMA_VERSION_CURRENT 17 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "14" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "17" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -872,6 +872,51 @@ rollback: return false; } +static bool database_migrate_v14_to_v15(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v15(database) || + !database_update_schema_version(database, "15") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V14 vers V15."); + return false; +} + +static bool database_migrate_v15_to_v16(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v16(database) || + !database_update_schema_version(database, "16") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V15 vers V16."); + return false; +} + +static bool database_migrate_v16_to_v17(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v17(database) || + !database_update_schema_version(database, "17") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V16 vers V17."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -1117,6 +1162,18 @@ bool database_migrate_to_latest( if (!database_migrate_v13_to_v14(database)) return false; schema_version = 14; break; + case 14: + if (!database_migrate_v14_to_v15(database)) return false; + schema_version = 15; + break; + case 15: + if (!database_migrate_v15_to_v16(database)) return false; + schema_version = 16; + break; + case 16: + if (!database_migrate_v16_to_v17(database)) return false; + schema_version = 17; + break; default: database_set_error( diff --git a/src/database/schema.c b/src/database/schema.c index 1c31acb..3385928 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -377,6 +377,36 @@ bool schema_install_v14(Database *database) "la migration SQLite V14"); } +bool schema_install_v15(Database *database) +{ + return schema_execute_file(database, "database/schema_v15.sql", + "la migration SQLite V15"); +} + +bool schema_install_v16(Database *database) +{ + return schema_execute_file(database, "database/schema_v16.sql", + "la migration SQLite V16"); +} + +bool schema_install_v17(Database *database) +{ + sqlite3_stmt *statement = NULL; + sqlite3 *handle = database_get_handle(database); + if (handle != NULL && + sqlite3_prepare_v2(handle, + "SELECT corrected_transcription, transcription_is_human, " + "transcription_corrected_at, transcription_origin " + "FROM identity_ocr_runs LIMIT 0", + -1, &statement, NULL) == SQLITE_OK) { + sqlite3_finalize(statement); + return true; + } + sqlite3_finalize(statement); + return schema_execute_file(database, "database/schema_v17.sql", + "la migration SQLite V17"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/models/identity_ocr.c b/src/models/identity_ocr.c new file mode 100644 index 0000000..3f978ac --- /dev/null +++ b/src/models/identity_ocr.c @@ -0,0 +1,242 @@ +#include "models/identity_ocr.h" +struct IdentityFieldObservation { + char *code, *raw_value, *corrected_value, *normalized_value, *note; + char *origin; + double confidence; + IdentityReviewStatus status; + IdentitySourceBox box; + guint order; +}; +struct IdentityOcrRun { + char *id, *evidence_id, *expected_sha256, *document_type, *document_side; + char *languages, *profile, *version, *available_languages, *parameters; + char *raw_text, *tsv, *factual_notes; + char *corrected_transcription, *transcription_corrected_at; + gboolean human_transcription; + guint page; + GPtrArray *fields; + GBytes *preview; +}; +static const char *const document_types[] = { + "identity_card","passport","driving_licence","residence_permit","other"}; +static const char *const document_sides[] = { + "front","back","identity_page","other_page"}; +static const char *const field_codes[] = { + "document_type","issuing_country","issuing_authority","document_number", + "surname","birth_name","given_names","sex_as_printed","nationality", + "birth_date","birth_place","issue_date","expiry_date", + "address_as_printed","mrz_line_1","mrz_line_2","mrz_line_3"}; +static gboolean in_vocab(const char *value, const char *const *values, + guint count) +{ + for (guint i=0; value != NULL && i 100.0) return NULL; + IdentityFieldObservation *f=g_new0(IdentityFieldObservation,1); + f->code=g_strdup(code); f->raw_value=g_strdup(raw); + f->confidence=confidence; f->status=IDENTITY_REVIEW_PROPOSED; + f->origin=g_strdup("ocr"); f->order=order; + if (box != NULL) f->box=*box; + return f; +} +IdentityFieldObservation *identity_field_observation_new_manual( + const char *code, const char *value, guint order) +{ + if (!identity_ocr_field_code_is_valid(code) || value == NULL || + value[0] == '\0') return NULL; + IdentityFieldObservation *field = + g_new0(IdentityFieldObservation, 1); + field->code = g_strdup(code); + field->corrected_value = g_strdup(value); + field->confidence = -1.0; + field->status = IDENTITY_REVIEW_PROPOSED; + field->origin = g_strdup("manual_entry"); + field->order = order; + return field; +} +IdentityFieldObservation *identity_field_observation_copy( + const IdentityFieldObservation *f) +{ + if (f==NULL) return NULL; + IdentityFieldObservation *c=f->raw_value != NULL + ? identity_field_observation_new( + f->code,f->raw_value,f->confidence,&f->box,f->order) + : identity_field_observation_new_manual( + f->code,f->corrected_value,f->order); + c->corrected_value=g_strdup(f->corrected_value); + c->normalized_value=g_strdup(f->normalized_value); + c->note=g_strdup(f->note); c->status=f->status; + g_free(c->origin); c->origin=g_strdup(f->origin); return c; +} +void identity_field_observation_free(IdentityFieldObservation *f) +{ + if(f==NULL)return; + g_free(f->code);g_free(f->raw_value); + g_free(f->corrected_value);g_free(f->normalized_value);g_free(f->note); + g_free(f->origin);g_free(f); +} +gboolean identity_field_observation_accept(IdentityFieldObservation *f) +{ if(f==NULL)return FALSE;f->status=IDENTITY_REVIEW_ACCEPTED;return TRUE; } +gboolean identity_field_observation_modify(IdentityFieldObservation *f, + const char *value,const char *note) +{ + if(f==NULL||value==NULL||value[0]=='\0')return FALSE; + g_free(f->corrected_value);f->corrected_value=g_strdup(value); + g_free(f->note);f->note=g_strdup(note);g_free(f->origin); + f->origin=g_strdup(f->raw_value == NULL + ? "manual_entry" : "manual_override"); + f->status=IDENTITY_REVIEW_MODIFIED; + return TRUE; +} +gboolean identity_field_observation_restore_raw(IdentityFieldObservation *f) +{ + if (f == NULL || f->raw_value == NULL) return FALSE; + g_clear_pointer(&f->corrected_value, g_free); + g_clear_pointer(&f->note, g_free); + g_free(f->origin); + f->origin = g_strdup("ocr"); + f->status = IDENTITY_REVIEW_PROPOSED; + return TRUE; +} +void identity_field_observation_reject(IdentityFieldObservation *f) +{if(f!=NULL)f->status=IDENTITY_REVIEW_REJECTED;} +gboolean identity_field_observation_set_origin(IdentityFieldObservation*f, + const char*origin) +{if(f==NULL||(!g_str_equal(origin,"ocr")&&!g_str_equal(origin,"mrz")&& + !g_str_equal(origin,"manual_override")&&!g_str_equal(origin,"manual_entry"))) + return FALSE; + g_free(f->origin);f->origin=g_strdup(origin);return TRUE;} +void identity_field_observation_mark_conflict(IdentityFieldObservation*f) +{if(f!=NULL)f->status=IDENTITY_REVIEW_CONFLICT;} +#define FG(name,type,field,zero) type identity_field_observation_get_##name(\ + const IdentityFieldObservation*f){return f!=NULL?f->field:zero;} +FG(code,const char*,code,NULL) FG(raw_value,const char*,raw_value,NULL) +FG(corrected_value,const char*,corrected_value,NULL) +FG(origin,const char*,origin,NULL) +FG(status,IdentityReviewStatus,status,IDENTITY_REVIEW_PROPOSED) +FG(confidence,double,confidence,-1.0) +FG(order,guint,order,0) +const IdentitySourceBox *identity_field_observation_get_box( + const IdentityFieldObservation*f){return f!=NULL?&f->box:NULL;} +IdentityOcrRun *identity_ocr_run_new(const char *evidence_id, + const char *sha,const char *type,const char *side,guint page, + const char *languages,const char *profile) +{ + if(evidence_id==NULL||sha==NULL||strlen(sha)!=64|| + !identity_ocr_document_type_is_valid(type)|| + !identity_ocr_document_side_is_valid(side)||page==0|| + languages==NULL||profile==NULL)return NULL; + IdentityOcrRun*r=g_new0(IdentityOcrRun,1);r->id=g_uuid_string_random(); + r->evidence_id=g_strdup(evidence_id);r->expected_sha256=g_strdup(sha); + r->document_type=g_strdup(type);r->document_side=g_strdup(side); + r->page=page;r->languages=g_strdup(languages);r->profile=g_strdup(profile); + r->fields=g_ptr_array_new_with_free_func( + (GDestroyNotify)identity_field_observation_free);return r; +} +IdentityOcrRun *identity_ocr_run_copy(const IdentityOcrRun*r) +{ + if(r==NULL)return NULL; + IdentityOcrRun*c=identity_ocr_run_new(r->evidence_id, + r->expected_sha256,r->document_type,r->document_side,r->page, + r->languages,r->profile);g_free(c->id);c->id=g_strdup(r->id); + identity_ocr_run_set_outputs(c,r->version,r->available_languages, + r->parameters,r->raw_text,r->tsv); + identity_ocr_run_set_preview(c,r->preview); + identity_ocr_run_set_factual_notes(c,r->factual_notes); + if (r->human_transcription) + identity_ocr_run_set_corrected_transcription(c, + r->corrected_transcription, r->transcription_corrected_at); + for(guint i=0;ifields->len;i++)identity_ocr_run_add_field(c, + identity_field_observation_copy(g_ptr_array_index(r->fields,i))); + return c; +} +void identity_ocr_run_free(IdentityOcrRun*r) +{ + if(r==NULL)return; + g_free(r->id);g_free(r->evidence_id);g_free(r->expected_sha256); + g_free(r->document_type);g_free(r->document_side);g_free(r->languages); + g_free(r->profile);g_free(r->version);g_free(r->available_languages); + g_free(r->parameters);g_free(r->raw_text);g_free(r->tsv); + g_free(r->factual_notes); + g_free(r->corrected_transcription);g_free(r->transcription_corrected_at); + g_clear_pointer(&r->preview,g_bytes_unref); + g_ptr_array_unref(r->fields);g_free(r); +} +void identity_ocr_run_set_outputs(IdentityOcrRun*r,const char*v, + const char*a,const char*p,const char*t,const char*tsv) +{if(r==NULL)return;r->version=g_strdup(v);r->available_languages=g_strdup(a); + r->parameters=g_strdup(p);r->raw_text=g_strdup(t);r->tsv=g_strdup(tsv);} +void identity_ocr_run_add_field(IdentityOcrRun*r,IdentityFieldObservation*f) +{if(r!=NULL&&f!=NULL)g_ptr_array_add(r->fields,f);} +gboolean identity_ocr_run_replace_evidence_id( + IdentityOcrRun *run, const char *evidence_identifier) +{ + if (run == NULL || evidence_identifier == NULL || + evidence_identifier[0] == '\0') return FALSE; + g_free(run->evidence_id); + run->evidence_id = g_strdup(evidence_identifier); + return run->evidence_id != NULL; +} +gboolean identity_ocr_run_replace_identifier( + IdentityOcrRun *run, const char *identifier) +{ + if (run == NULL || identifier == NULL || + !g_uuid_string_is_valid(identifier)) return FALSE; + g_free(run->id); + run->id = g_strdup(identifier); + return run->id != NULL; +} +void identity_ocr_run_set_factual_notes(IdentityOcrRun*r,const char*notes) +{if(r==NULL)return;g_free(r->factual_notes);r->factual_notes=g_strdup(notes);} +const char *identity_ocr_run_get_factual_notes(const IdentityOcrRun*r) +{return r!=NULL?r->factual_notes:NULL;} +gboolean identity_ocr_run_set_corrected_transcription(IdentityOcrRun*r, + const char*text,const char*at) +{ + if(r==NULL||text==NULL||!g_utf8_validate(text,-1,NULL)|| + strlen(text)>1048576||(r->raw_text!=NULL&&r->raw_text[0]!='\0'&& + text[0]=='\0'))return FALSE; + g_free(r->corrected_transcription);r->corrected_transcription=g_strdup(text); + g_free(r->transcription_corrected_at); + r->transcription_corrected_at=g_strdup(at); + r->human_transcription=TRUE;return TRUE; +} +void identity_ocr_run_reset_corrected_transcription(IdentityOcrRun*r) +{if(r==NULL)return;g_clear_pointer(&r->corrected_transcription,g_free); + g_clear_pointer(&r->transcription_corrected_at,g_free); + r->human_transcription=FALSE;} +const char *identity_ocr_run_get_corrected_transcription(const IdentityOcrRun*r) +{return r!=NULL?r->corrected_transcription:NULL;} +const char *identity_ocr_run_get_transcription_corrected_at(const IdentityOcrRun*r) +{return r!=NULL?r->transcription_corrected_at:NULL;} +gboolean identity_ocr_run_has_human_transcription(const IdentityOcrRun*r) +{return r!=NULL&&r->human_transcription;} +void identity_ocr_run_set_preview(IdentityOcrRun*r,GBytes*b) +{if(r==NULL)return;g_clear_pointer(&r->preview,g_bytes_unref);r->preview=b!=NULL?g_bytes_ref(b):NULL;} +GBytes *identity_ocr_run_get_preview(const IdentityOcrRun*r) +{return r!=NULL?r->preview:NULL;} +#define RG(name,type,field,zero) type identity_ocr_run_get_##name(\ + const IdentityOcrRun*r){return r!=NULL?r->field:zero;} +RG(identifier,const char*,id,NULL) RG(evidence_id,const char*,evidence_id,NULL) +RG(raw_text,const char*,raw_text,NULL) RG(tsv,const char*,tsv,NULL) +RG(document_type,const char*,document_type,NULL) +RG(document_side,const char*,document_side,NULL) +RG(languages,const char*,languages,NULL) RG(page,guint,page,0) +RG(expected_sha256,const char*,expected_sha256,NULL) +RG(profile,const char*,profile,NULL) RG(version,const char*,version,NULL) +RG(available_languages,const char*,available_languages,NULL) +const GPtrArray *identity_ocr_run_get_fields(const IdentityOcrRun*r) +{return r!=NULL?r->fields:NULL;} diff --git a/src/models/person_evidence_selection.c b/src/models/person_evidence_selection.c index 187411a..b2b2876 100644 --- a/src/models/person_evidence_selection.c +++ b/src/models/person_evidence_selection.c @@ -88,11 +88,16 @@ PersonEvidenceSelection *person_evidence_selection_copy( copy, item->record, NULL)) person_evidence_selection_set_type(copy, item->identifier, item->type_identifier); - } else + } else { person_evidence_selection_add_staged(copy, item->source_path, item->staging_path, item->original_name, item->mime_type, item->type_identifier, item->size_bytes, item->sha256, item->description, item->prepared_at, NULL); + PersonEvidenceSelectionItem *copied_item = + g_ptr_array_index(copy->items, copy->items->len - 1); + g_free(copied_item->identifier); + copied_item->identifier = g_strdup(item->identifier); + } } if (source != NULL && source->active_identifier != NULL) { PersonEvidenceSelectionItem *active = diff --git a/src/views/create_person_dialog.c b/src/views/create_person_dialog.c index cf91b63..0f72ef5 100644 --- a/src/views/create_person_dialog.c +++ b/src/views/create_person_dialog.c @@ -7,9 +7,15 @@ #include "core/evidence_staging_task.h" #include "core/person_confirmation_summary.h" #include "core/person_dialog_lifecycle.h" +#include "core/identity_ocr_preprocessor.h" +#include "core/identity_ocr_workflow.h" +#include "core/identity_field_extractor.h" +#include "core/ocr_analysis.h" #include "models/evidence_selection_model.h" #include "models/person_evidence_selection.h" +#include "models/identity_ocr.h" #include "widgets/evidence_preview_widget.h" +#include "widgets/ocr_provenance_overlay.h" struct CreatePersonDialogResult { @@ -23,6 +29,7 @@ struct CreatePersonDialogResult GPtrArray *role_assignments; PersonEvidenceSelection *evidence_selection; EvidenceStaging *staging; + GPtrArray *ocr_runs; }; typedef struct { @@ -49,6 +56,23 @@ typedef struct GtkButton *add_existing; GtkButton *remove_retained; GtkButton *import_files; + GtkButton *ocr_start; + GtkButton *ocr_cancel; + GtkDropDown *ocr_document_type; + GtkDropDown *ocr_document_side; + GtkDropDown *ocr_languages; + GtkDropDown *ocr_profile; + GtkSpinButton *ocr_page; + GtkTextView *ocr_text; + GtkTextView *ocr_corrected_text; + GtkStack *ocr_transcription_stack; + GtkBox *ocr_fields; + GtkDropDown *ocr_manual_code; + GtkEntry *ocr_manual_value; + GtkTextView *ocr_factual_notes; + GtkLabel *ocr_status; + OcrProvenanceOverlay *ocr_overlay; + GPtrArray *ocr_language_codes; GtkStringList *evidence_labels; GtkStringList *retained_labels; GtkStringList *type_filter_labels; @@ -63,6 +87,12 @@ typedef struct char *investigation_root_path; TaskManager *task_manager; BackgroundTask *staging_task; + GTask *ocr_task; + GCancellable *ocr_cancellable; + GPtrArray *ocr_runs; + char *tesseract_path; + char *tesseract_version; + guint64 ocr_generation; PersonDialogLifecycle *lifecycle; CreatePersonDialogCallback callback; CreatePersonDialogSessionCheck session_check; @@ -74,6 +104,21 @@ typedef struct gboolean updating_retained; } CreatePersonDialogState; +typedef struct { + char *root, *identifier, *relative, *sha256, *mime; + char *document_type, *document_side, *languages, *profile; + char *executable, *version; + guint page; + guint profile_index; + guint64 generation; + GWeakRef window; +} IdentityOcrJob; + +typedef struct { + char *executable; + GWeakRef window; +} IdentityLanguageJob; + typedef struct { GWeakRef window; guint64 generation; @@ -84,6 +129,102 @@ static void create_person_dialog_select_record(CreatePersonDialogState *state, const EvidenceRecord *record, const char *business_type); static void create_person_dialog_on_retained_changed( GtkDropDown *dropdown, GParamSpec *pspec, gpointer data); +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; + if (job == NULL) return; + g_free(job->executable); + g_weak_ref_clear(&job->window); + g_free(job); +} + +static void identity_languages_worker(GTask *task, gpointer source, + gpointer task_data, GCancellable *cancellable) +{ + IdentityLanguageJob *job = task_data; + GError *error = NULL; + char *raw = ocr_analysis_list_languages(job->executable, cancellable, + &error); + GPtrArray *parsed = raw != NULL + ? ocr_analysis_parse_languages(raw) : NULL; + GPtrArray *choices = parsed != NULL + ? ocr_analysis_build_language_choices(parsed) : NULL; + g_free(raw); + g_clear_pointer(&parsed, g_ptr_array_unref); + (void) source; + if (choices != NULL) g_task_return_pointer(task, choices, + (GDestroyNotify) g_ptr_array_unref); + else g_task_return_error(task, error); +} + +static const char *identity_language_label(const char *code) +{ + if (g_str_equal(code, "fra")) return "Français (fra)"; + if (g_str_equal(code, "eng")) return "Anglais (eng)"; + if (g_str_equal(code, "fra+eng")) return "Français + anglais (fra+eng)"; + return code; +} + +static void identity_languages_completed(GObject *source, + GAsyncResult *result, gpointer data) +{ + IdentityLanguageJob *job = data; + GtkWindow *window = g_weak_ref_get(&job->window); + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + GError *error = NULL; + GPtrArray *choices = g_task_propagate_pointer(G_TASK(result), &error); + (void) source; + if (state != NULL) { + g_clear_pointer(&state->ocr_language_codes, g_ptr_array_unref); + state->ocr_language_codes = choices; + choices = NULL; + GtkStringList *labels = gtk_string_list_new(NULL); + for (guint i = 0; state->ocr_language_codes != NULL && + i < state->ocr_language_codes->len; i++) { + const char *code = g_ptr_array_index(state->ocr_language_codes, i); + gtk_string_list_append(labels, identity_language_label(code)); + } + 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); + gtk_widget_set_sensitive(GTK_WIDGET(state->ocr_start), + state->ocr_language_codes != NULL && + state->ocr_language_codes->len > 0); + if (state->ocr_language_codes == NULL || + state->ocr_language_codes->len == 0) + gtk_label_set_text(state->ocr_status, + "Aucune langue Tesseract installée."); + g_object_unref(labels); + } + g_clear_pointer(&choices, g_ptr_array_unref); + g_clear_error(&error); + g_clear_object(&window); +} /** @brief Copie et nettoie un texte facultatif. */ static char *create_person_dialog_copy(const char *text) @@ -102,6 +243,464 @@ static char *create_person_dialog_get_notes(CreatePersonDialogState *state) gtk_text_buffer_get_bounds(buffer, &start, &end); return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); } + +static void identity_ocr_job_free(gpointer data) +{ + IdentityOcrJob *job = data; + if (job == NULL) return; + g_free(job->root); g_free(job->identifier); g_free(job->relative); + g_free(job->sha256); g_free(job->mime); g_free(job->document_type); + g_free(job->document_side); g_free(job->languages); g_free(job->profile); + g_free(job->executable); g_free(job->version); + g_weak_ref_clear(&job->window); g_free(job); +} + +static void create_person_dialog_ocr_worker(GTask *task, gpointer source, + gpointer task_data, GCancellable *cancellable) +{ + IdentityOcrJob *job = task_data; + IdentityOcrWorkflowRequest request = { + .root_path = job->root, + .evidence_identifier = job->identifier, + .relative_path = job->relative, + .expected_sha256 = job->sha256, + .mime_type = job->mime, + .document_type = job->document_type, + .document_side = job->document_side, + .languages = job->languages, + .preprocessing_profile = job->profile, + .tesseract_executable = job->executable, + .tesseract_version = job->version, + .page_number = job->page, + .profile = (IdentityOcrPreprocessProfile) job->profile_index, + .generation = job->generation + }; + GError *error = NULL; + (void) source; + IdentityOcrRun *run = identity_ocr_workflow_execute( + &request, cancellable, &error); + if (run != NULL) + g_task_return_pointer(task, run, + (GDestroyNotify) identity_ocr_run_free); + else + g_task_return_error(task, error != NULL ? error : + g_error_new_literal(G_IO_ERROR, G_IO_ERROR_FAILED, + "L’OCR contrôlé a échoué.")); +} + +static void create_person_dialog_on_field_accept(GtkButton *button, + gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "identity-field"); + (void) data; + identity_field_observation_accept(field); + gtk_button_set_label(button, "Acceptée"); + gtk_widget_set_sensitive(GTK_WIDGET(button), FALSE); + GtkEntry *entry = g_object_get_data( + G_OBJECT(button), "identity-entry"); + if (entry != NULL) + gtk_editable_set_editable(GTK_EDITABLE(entry), FALSE); +} + +static void create_person_dialog_on_field_reedit(GtkButton *button, + gpointer data) +{ + GtkEntry *entry = g_object_get_data( + G_OBJECT(button), "identity-entry"); + GtkButton *save = g_object_get_data( + G_OBJECT(button), "identity-save"); + (void) data; + if (entry == NULL || save == NULL) return; + gtk_editable_set_editable(GTK_EDITABLE(entry), TRUE); + gtk_widget_set_sensitive(GTK_WIDGET(save), TRUE); + gtk_widget_grab_focus(GTK_WIDGET(entry)); + gtk_editable_set_position(GTK_EDITABLE(entry), -1); +} + +static void create_person_dialog_on_field_restore(GtkButton *button, + gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "identity-field"); + GtkEntry *entry = g_object_get_data( + G_OBJECT(button), "identity-entry"); + GtkButton *save = g_object_get_data( + G_OBJECT(button), "identity-save"); + (void) data; + if (!identity_field_observation_restore_raw(field) || entry == NULL) + return; + gtk_editable_set_text(GTK_EDITABLE(entry), + identity_field_observation_get_raw_value(field)); + gtk_editable_set_editable(GTK_EDITABLE(entry), TRUE); + if (save != NULL) { + gtk_button_set_label(save, "Enregistrer la correction"); + gtk_widget_set_sensitive(GTK_WIDGET(save), TRUE); + } +} + +static void create_person_dialog_on_field_modify(GtkButton *button, + gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "identity-field"); + (void) data; + GtkEntry *entry = g_object_get_data( + G_OBJECT(button), "identity-entry"); + const char *value = entry != NULL + ? gtk_editable_get_text(GTK_EDITABLE(entry)) + : identity_field_observation_get_raw_value(field); + if (!identity_field_observation_modify(field, value, + "Valeur revue individuellement dans l’assistant.")) { + if (entry != NULL) + gtk_widget_add_css_class(GTK_WIDGET(entry), "error"); + return; + } + if (entry != NULL) { + gtk_widget_remove_css_class(GTK_WIDGET(entry), "error"); + gtk_editable_set_editable(GTK_EDITABLE(entry), FALSE); + } + gtk_button_set_label(button, "Modifiée"); + gtk_widget_set_sensitive(GTK_WIDGET(button), FALSE); +} + +static void create_person_dialog_on_field_reject(GtkButton *button, + gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "identity-field"); + (void) data; + identity_field_observation_reject(field); + gtk_button_set_label(button, "Rejetée"); +} + +static void create_person_dialog_on_field_show(GtkButton *button, + gpointer data) +{ + CreatePersonDialogState *state = data; + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "identity-field"); + ocr_provenance_overlay_set_field(state->ocr_overlay, field, + state->ocr_generation); +} + +static void create_person_dialog_on_add_manual_field(GtkButton *button, + gpointer data) +{ + static const char *const codes[] = { + "document_type","issuing_country","issuing_authority", + "document_number","surname","birth_name","given_names", + "sex_as_printed","nationality","birth_date","birth_place", + "issue_date","expiry_date","address_as_printed", + "mrz_line_1","mrz_line_2","mrz_line_3"}; + CreatePersonDialogState *state = data; + guint selected = gtk_drop_down_get_selected(state->ocr_manual_code); + const char *value = + gtk_editable_get_text(GTK_EDITABLE(state->ocr_manual_value)); + (void) button; + if (state->ocr_runs == NULL || state->ocr_runs->len == 0 || + selected >= G_N_ELEMENTS(codes) || value[0] == '\0') return; + IdentityOcrRun *run = g_ptr_array_index( + state->ocr_runs, state->ocr_runs->len - 1); + IdentityFieldObservation *field = + identity_field_observation_new_manual( + codes[selected], value, + identity_ocr_run_get_fields(run)->len); + if (field == NULL) return; + identity_ocr_run_add_field(run, field); + gtk_editable_set_text(GTK_EDITABLE(state->ocr_manual_value), ""); + create_person_dialog_render_ocr_fields(state, run); +} + +static void create_person_dialog_capture_factual_notes( + CreatePersonDialogState *state) +{ + if (state == NULL || state->ocr_runs == NULL || + state->ocr_runs->len == 0) return; + GtkTextBuffer *buffer = + gtk_text_view_get_buffer(state->ocr_factual_notes); + GtkTextIter start, end; + gtk_text_buffer_get_bounds(buffer, &start, &end); + char *notes = gtk_text_buffer_get_text( + buffer, &start, &end, FALSE); + IdentityOcrRun *run = g_ptr_array_index( + state->ocr_runs, state->ocr_runs->len - 1); + identity_ocr_run_set_factual_notes(run, + notes[0] != '\0' ? notes : NULL); + g_free(notes); +} + +static char *create_person_dialog_text_view_contents(GtkTextView *view) +{ + GtkTextIter start; + GtkTextIter end; + GtkTextBuffer *buffer = gtk_text_view_get_buffer(view); + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} + +static void create_person_dialog_capture_corrected_transcription( + CreatePersonDialogState *state) +{ + if (state == NULL || state->ocr_corrected_text == NULL || + state->ocr_runs == NULL || state->ocr_runs->len == 0) return; + IdentityOcrRun *run = g_ptr_array_index( + state->ocr_runs, state->ocr_runs->len - 1); + char *text = create_person_dialog_text_view_contents( + state->ocr_corrected_text); + const char *raw = identity_ocr_run_get_raw_text(run); + if (g_strcmp0(text, raw) == 0) + identity_ocr_run_reset_corrected_transcription(run); + else { + GDateTime *now = g_date_time_new_now_utc(); + char *timestamp = g_date_time_format( + now, "%Y-%m-%dT%H:%M:%SZ"); + identity_ocr_run_set_corrected_transcription( + run, text, timestamp); + g_free(timestamp); + g_date_time_unref(now); + } + g_free(text); +} + +static void create_person_dialog_on_reset_transcription( + GtkButton *button, gpointer data) +{ + CreatePersonDialogState *state = data; + (void) button; + if (state->ocr_runs == NULL || state->ocr_runs->len == 0) return; + IdentityOcrRun *run = g_ptr_array_index( + state->ocr_runs, state->ocr_runs->len - 1); + identity_ocr_run_reset_corrected_transcription(run); + gtk_text_buffer_set_text(gtk_text_view_get_buffer( + state->ocr_corrected_text), + identity_ocr_run_get_raw_text(run), -1); + gtk_text_view_set_editable(state->ocr_corrected_text, TRUE); +} + +static void create_person_dialog_render_ocr_fields( + CreatePersonDialogState *state, IdentityOcrRun *run) +{ + GtkWidget *child; + while ((child = gtk_widget_get_first_child( + GTK_WIDGET(state->ocr_fields))) != NULL) + gtk_box_remove(state->ocr_fields, child); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + for (guint i = 0; fields != NULL && i < fields->len; i++) { + IdentityFieldObservation *field = g_ptr_array_index( + (GPtrArray *) fields, i); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + const IdentitySourceBox *box = + identity_field_observation_get_box(field); + char *source = box != NULL && box->available + ? g_strdup_printf("page %d, zone %d,%d %dx%d, confiance %.1f", + box->page, box->x, box->y, box->width, box->height, + identity_field_observation_get_confidence(field)) + : g_strdup("Zone source indisponible"); + char *label = g_strdup_printf( + "%s\nValeur OCR brute : %s\nOrigine : %s — %s", + identity_field_observation_get_code(field), + identity_field_observation_get_raw_value(field) != NULL + ? identity_field_observation_get_raw_value(field) + : "absente", + identity_field_observation_get_origin(field), source); + GtkWidget *field_label = gtk_label_new(label); + gtk_label_set_wrap(GTK_LABEL(field_label), TRUE); + gtk_label_set_xalign(GTK_LABEL(field_label), 0.0f); + gtk_box_append(GTK_BOX(row), field_label); + GtkWidget *entry = gtk_entry_new(); + gtk_widget_set_hexpand(entry, TRUE); + gtk_widget_set_tooltip_text(entry, + "Modifiez la valeur puis choisissez « Modifier »."); + gtk_editable_set_text(GTK_EDITABLE(entry), + identity_field_observation_get_corrected_value(field) != NULL + ? identity_field_observation_get_corrected_value(field) + : identity_field_observation_get_raw_value(field)); + gtk_box_append(GTK_BOX(row), entry); + GtkWidget *actions = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget *accept = gtk_button_new_with_label("Accepter"); + GtkWidget *modify = gtk_button_new_with_label("Modifier"); + GtkWidget *reedit = gtk_button_new_with_label("Modifier à nouveau"); + GtkWidget *restore = + gtk_button_new_with_label("Revenir à la valeur OCR"); + GtkWidget *reject = gtk_button_new_with_label("Rejeter"); + GtkWidget *show = gtk_button_new_with_label("Voir la zone"); + g_object_set_data(G_OBJECT(row), "identity-field-code", + (gpointer) identity_field_observation_get_code(field)); + g_object_set_data(G_OBJECT(row), "identity-field-order", + GUINT_TO_POINTER(identity_field_observation_get_order(field) + 1)); + g_object_set_data(G_OBJECT(row), "identity-value-entry", entry); + g_object_set_data(G_OBJECT(row), "identity-accept-button", accept); + g_object_set_data(G_OBJECT(row), "identity-modify-button", modify); + g_object_set_data(G_OBJECT(row), "identity-reedit-button", reedit); + g_object_set_data(G_OBJECT(row), "identity-restore-button", restore); + g_object_set_data(G_OBJECT(row), "identity-reject-button", reject); + g_object_set_data(G_OBJECT(accept), "identity-field", field); + g_object_set_data(G_OBJECT(accept), "identity-entry", entry); + g_object_set_data(G_OBJECT(modify), "identity-field", field); + g_object_set_data(G_OBJECT(modify), "identity-entry", entry); + g_object_set_data(G_OBJECT(reject), "identity-field", field); + g_object_set_data(G_OBJECT(reedit), "identity-entry", entry); + g_object_set_data(G_OBJECT(reedit), "identity-save", modify); + g_object_set_data(G_OBJECT(restore), "identity-field", field); + g_object_set_data(G_OBJECT(restore), "identity-entry", entry); + g_object_set_data(G_OBJECT(restore), "identity-save", modify); + g_object_set_data(G_OBJECT(show), "identity-field", field); + gtk_box_append(GTK_BOX(actions), show); + gtk_box_append(GTK_BOX(actions), accept); + gtk_box_append(GTK_BOX(actions), modify); + gtk_box_append(GTK_BOX(actions), reedit); + if (identity_field_observation_get_raw_value(field) != NULL) + gtk_box_append(GTK_BOX(actions), restore); + gtk_box_append(GTK_BOX(actions), reject); + gtk_box_append(GTK_BOX(row), actions); + g_signal_connect(accept, "clicked", + G_CALLBACK(create_person_dialog_on_field_accept), state); + g_signal_connect(modify, "clicked", + G_CALLBACK(create_person_dialog_on_field_modify), state); + g_signal_connect(reject, "clicked", + G_CALLBACK(create_person_dialog_on_field_reject), state); + g_signal_connect(reedit, "clicked", + G_CALLBACK(create_person_dialog_on_field_reedit), state); + g_signal_connect(restore, "clicked", + G_CALLBACK(create_person_dialog_on_field_restore), state); + g_signal_connect(show, "clicked", + G_CALLBACK(create_person_dialog_on_field_show), state); + gtk_box_append(state->ocr_fields, row); + g_free(source); + g_free(label); + } +} + +static void create_person_dialog_ocr_completed(GObject *source, + GAsyncResult *result, gpointer data) +{ + IdentityOcrJob *job = data; + GtkWindow *window = g_weak_ref_get(&job->window); + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + GError *error = NULL; + IdentityOcrRun *run = g_task_propagate_pointer(G_TASK(result), &error); + (void) source; + if (state != NULL && state->ocr_generation == job->generation && + (state->session_check == NULL || + state->session_check(state->user_data))) { + if (run != NULL) { + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->ocr_text), + identity_ocr_run_get_raw_text(run), -1); + gtk_text_buffer_set_text(gtk_text_view_get_buffer( + state->ocr_corrected_text), + identity_ocr_run_get_raw_text(run), -1); + gtk_text_view_set_editable(state->ocr_corrected_text, TRUE); + gtk_stack_set_visible_child_name( + state->ocr_transcription_stack, "corrected"); + 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); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + if (fields != NULL && fields->len > 0) + ocr_provenance_overlay_set_field(state->ocr_overlay, + g_ptr_array_index((GPtrArray *) fields, 0), + state->ocr_generation); + g_ptr_array_add(state->ocr_runs, run); + run = NULL; + gtk_label_set_text(state->ocr_status, + "OCR terminé. Chaque proposition reste À vérifier."); + } else + gtk_label_set_text(state->ocr_status, + error != NULL ? error->message : "OCR annulé."); + g_clear_object(&state->ocr_task); + g_clear_object(&state->ocr_cancellable); + } + identity_ocr_run_free(run); + g_clear_error(&error); + g_clear_object(&window); +} + +static void create_person_dialog_on_ocr_cancel(GtkButton *button, + gpointer data) +{ + CreatePersonDialogState *state = data; + (void) button; + state->ocr_generation++; + if (state->ocr_cancellable != NULL) + g_cancellable_cancel(state->ocr_cancellable); + gtk_label_set_text(state->ocr_status, "Annulation de l’OCR…"); + ocr_provenance_overlay_clear(state->ocr_overlay, state->ocr_generation); +} + +static void create_person_dialog_on_ocr_start(GtkButton *button, + gpointer data) +{ + static const char *const types[] = {"identity_card","passport", + "driving_licence","residence_permit","other"}; + static const char *const sides[] = {"front","back","identity_page", + "other_page"}; + CreatePersonDialogState *state = data; + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get_active(state->person_evidence_selection); + IdentityOcrJob *job; + const EvidenceRecord *record; + (void) button; + if (item == NULL || state->tesseract_path == NULL) { + gtk_label_set_text(state->ocr_status, + "Tesseract est absent ou aucune preuve compatible n’est retenue."); + return; + } + job = g_new0(IdentityOcrJob, 1); + job->root = g_strdup(state->investigation_root_path); + 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)); + if (person_evidence_selection_item_get_origin(item) == + PERSON_EVIDENCE_ORIGIN_EXISTING) { + record = person_evidence_selection_item_get_record(item); + job->relative = g_strdup(evidence_record_get_relative_path(record)); + } else { + g_free(job->root); + job->root = g_path_get_dirname( + person_evidence_selection_item_get_staging_path(item)); + job->relative = g_path_get_basename( + person_evidence_selection_item_get_staging_path(item)); + } + guint selected = gtk_drop_down_get_selected(state->ocr_document_type); + job->document_type = g_strdup(types[MIN(selected, + G_N_ELEMENTS(types)-1)]); + selected = gtk_drop_down_get_selected(state->ocr_document_side); + job->document_side = g_strdup(sides[MIN(selected, + G_N_ELEMENTS(sides)-1)]); + selected = gtk_drop_down_get_selected(state->ocr_languages); + if (selected == GTK_INVALID_LIST_POSITION || + state->ocr_language_codes == NULL || + selected >= state->ocr_language_codes->len) { + identity_ocr_job_free(job); + gtk_label_set_text(state->ocr_status, + "Aucune langue Tesseract disponible."); + return; + } + job->languages = g_strdup(g_ptr_array_index( + state->ocr_language_codes, selected)); + static const char *const profiles[] = { + "none", "orientation", "grayscale", "upscale"}; + job->profile_index = gtk_drop_down_get_selected(state->ocr_profile); + job->profile = g_strdup(profiles[MIN(job->profile_index, + G_N_ELEMENTS(profiles)-1)]); + job->page = (guint) gtk_spin_button_get_value_as_int(state->ocr_page); + job->executable = g_strdup(state->tesseract_path); + job->version = g_strdup(state->tesseract_version); + job->generation = ++state->ocr_generation; + g_weak_ref_init(&job->window, G_OBJECT(state->window)); + g_clear_object(&state->ocr_cancellable); + state->ocr_cancellable = g_cancellable_new(); + state->ocr_task = g_task_new(NULL, state->ocr_cancellable, + create_person_dialog_ocr_completed, job); + g_task_set_task_data(state->ocr_task, job, identity_ocr_job_free); + gtk_label_set_text(state->ocr_status, + "OCR contrôlé en cours… Aucune donnée n’est encore persistée."); + g_task_run_in_thread(state->ocr_task, create_person_dialog_ocr_worker); +} /** @brief Libère l'état attaché à la fenêtre. */ static void create_person_dialog_state_free(gpointer data) { @@ -112,6 +711,14 @@ static void create_person_dialog_state_free(gpointer data) background_task_cancel(state->staging_task); background_task_unref(state->staging_task); } + state->ocr_generation++; + if (state->ocr_cancellable != NULL) + g_cancellable_cancel(state->ocr_cancellable); + g_clear_object(&state->ocr_task); + g_clear_object(&state->ocr_cancellable); + g_clear_pointer(&state->ocr_runs, g_ptr_array_unref); + g_clear_pointer(&state->ocr_language_codes, g_ptr_array_unref); + g_clear_pointer(&state->ocr_overlay, ocr_provenance_overlay_free); person_dialog_lifecycle_cancel(state->lifecycle); g_clear_pointer(&state->evidence_identifiers, g_ptr_array_unref); g_clear_pointer(&state->visible_records, g_ptr_array_unref); @@ -125,6 +732,8 @@ static void create_person_dialog_state_free(gpointer data) g_clear_object(&state->retained_labels); g_clear_object(&state->type_filter_labels); g_free(state->investigation_root_path); + g_free(state->tesseract_path); + g_free(state->tesseract_version); if (state->user_data_destroy != NULL) state->user_data_destroy(state->user_data); g_free(state); @@ -211,8 +820,33 @@ static void create_person_dialog_on_retained_changed( (void) pspec; if (state->updating_retained || selected == GTK_INVALID_LIST_POSITION || item == NULL) return; + if (state->ocr_start != NULL) { + const char *mime = + person_evidence_selection_item_get_mime_type(item); + const char *name = + person_evidence_selection_item_get_original_name(item); + char *lower = name != NULL ? g_ascii_strdown(name, -1) : NULL; + gboolean compatible = + g_strcmp0(mime, "image/png") == 0 || + g_strcmp0(mime, "image/jpeg") == 0 || + g_strcmp0(mime, "image/heic") == 0 || + g_strcmp0(mime, "image/heif") == 0 || + g_strcmp0(mime, "application/pdf") == 0 || + (lower != NULL && (g_str_has_suffix(lower, ".png") || + g_str_has_suffix(lower, ".jpg") || + g_str_has_suffix(lower, ".jpeg") || + g_str_has_suffix(lower, ".heic") || + g_str_has_suffix(lower, ".heif") || + g_str_has_suffix(lower, ".pdf"))); + gtk_widget_set_sensitive(GTK_WIDGET(state->ocr_start), + compatible && state->tesseract_path != NULL); + g_free(lower); + } person_evidence_selection_set_active(state->person_evidence_selection, person_evidence_selection_item_get_identifier(item)); + if (state->ocr_overlay != NULL) + ocr_provenance_overlay_clear(state->ocr_overlay, + ++state->ocr_generation); static const char *const types[] = { "screenshot", "photo", "video", "document", "email", "archive", "audio", "text", "other"}; @@ -292,6 +926,26 @@ static void create_person_dialog_on_remove_retained( : NULL; (void) button; if (identifier != NULL) { + state->ocr_generation++; + if (state->ocr_cancellable != NULL) + g_cancellable_cancel(state->ocr_cancellable); + for (guint i = state->ocr_runs != NULL ? state->ocr_runs->len : 0; + i > 0; i--) { + IdentityOcrRun *run = g_ptr_array_index(state->ocr_runs, i - 1); + if (g_strcmp0(identity_ocr_run_get_evidence_id(run), + identifier) == 0) + g_ptr_array_remove_index(state->ocr_runs, i - 1); + } + while (gtk_widget_get_first_child(GTK_WIDGET(state->ocr_fields)) != + NULL) + gtk_box_remove(state->ocr_fields, + gtk_widget_get_first_child(GTK_WIDGET(state->ocr_fields))); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->ocr_text), + "", -1); + gtk_label_set_text(state->ocr_status, + "Aucune session OCR active pour la preuve retirée."); + ocr_provenance_overlay_clear(state->ocr_overlay, + state->ocr_generation); person_evidence_selection_remove( state->person_evidence_selection, identifier); if (staging_path != NULL) @@ -678,6 +1332,8 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) state->person_evidence_selection = NULL; result->staging = state->staging; state->staging = NULL; + result->ocr_runs = state->ocr_runs; + state->ocr_runs = NULL; if (!person_dialog_lifecycle_complete(state->lifecycle)) { create_person_dialog_result_free(result); g_free(notes); @@ -690,9 +1346,11 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) static void create_person_dialog_update_navigation(CreatePersonDialogState *state) { - static const char *const pages[] = {"person", "roles", "evidence", "summary"}; + static const char *const pages[] = { + "person", "roles", "evidence", "identity-ocr", "summary"}; static const char *const steps[] = { - "1 Personne", "2 Rôles", "3 Preuves", "4 Confirmation"}; + "1 Personne", "2 Rôles", "3 Preuves", "4 OCR identité", + "5 Confirmation"}; GString *progress = g_string_new(NULL); gtk_stack_set_visible_child_name(state->stack, pages[state->step]); for (guint i = 0; i < G_N_ELEMENTS(steps); i++) { @@ -706,7 +1364,7 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat } gtk_label_set_markup(state->progress, progress->str); g_string_free(progress, TRUE); - if (state->step == 3) { + if (state->step == 4) { static const char *const status_labels[] = { "Inconnu", "Présumé", "Confirmé"}; GPtrArray *role_labels = g_ptr_array_new(); @@ -726,16 +1384,62 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat : "Non renseigné", gtk_spin_button_get_value_as_int(state->confidence), notes, role_labels, state->person_evidence_selection); - gtk_label_set_text(state->summary, text); + GString *confirmation = g_string_new(text); + g_string_append(confirmation, + "\n\nOCR identité\nL’OCR peut contenir des erreurs. Ces " + "informations sont seulement présentées sur le document ; " + "l’authenticité n’est pas établie et aucune fusion de personne " + "ne sera effectuée. Aucune écriture n’a encore eu lieu."); + for (guint run_index = 0; + state->ocr_runs != NULL && run_index < state->ocr_runs->len; + run_index++) { + IdentityOcrRun *run = g_ptr_array_index( + state->ocr_runs, run_index); + g_string_append_printf(confirmation, + "\n• %s — %s — page %u — Tesseract %s — %s", + identity_ocr_run_get_document_type(run), + identity_ocr_run_get_document_side(run), + identity_ocr_run_get_page(run), + identity_ocr_run_get_version(run) != NULL + ? identity_ocr_run_get_version(run) : "version inconnue", + identity_ocr_run_get_languages(run)); + if (identity_ocr_run_get_factual_notes(run) != NULL) + g_string_append_printf(confirmation, + "\n Notes factuelles : %s", + identity_ocr_run_get_factual_notes(run)); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + for (guint field_index = 0; + fields != NULL && field_index < fields->len; field_index++) { + IdentityFieldObservation *field = + g_ptr_array_index((GPtrArray *) fields, field_index); + IdentityReviewStatus review = + identity_field_observation_get_status(field); + if (review == IDENTITY_REVIEW_ACCEPTED || + review == IDENTITY_REVIEW_MODIFIED) + g_string_append_printf(confirmation, + "\n - %s : %s (brut : %s ; origine : %s)", + identity_field_observation_get_code(field), + identity_field_observation_get_corrected_value(field) + != NULL + ? identity_field_observation_get_corrected_value(field) + : identity_field_observation_get_raw_value(field), + identity_field_observation_get_raw_value(field) != NULL + ? identity_field_observation_get_raw_value(field) + : "absente", + identity_field_observation_get_origin(field)); + } + } + gtk_label_set_text(state->summary, confirmation->str); + g_string_free(confirmation, TRUE); g_free(text); g_free(notes); g_ptr_array_unref(role_labels); } gtk_widget_set_sensitive(GTK_WIDGET(state->previous), state->step > 0); - gtk_widget_set_visible(GTK_WIDGET(state->next), state->step < 3); - gtk_widget_set_visible(GTK_WIDGET(state->create), state->step == 3); + gtk_widget_set_visible(GTK_WIDGET(state->next), state->step < 4); + gtk_widget_set_visible(GTK_WIDGET(state->create), state->step == 4); gtk_widget_set_sensitive(GTK_WIDGET(state->create), - state->step == 3 && + state->step == 4 && person_evidence_selection_is_confirmable( state->person_evidence_selection)); } @@ -755,8 +1459,12 @@ static void create_person_dialog_on_next(GtkButton *button, gpointer data) gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); return; } + if (state->step == 3) + create_person_dialog_capture_corrected_transcription(state); + if (state->step == 3) + create_person_dialog_capture_factual_notes(state); gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); - if (state->step < 3) state->step++; + if (state->step < 4) state->step++; create_person_dialog_update_navigation(state); } /** @brief Ajoute une ligne libellée au formulaire. */ @@ -771,14 +1479,16 @@ static void create_person_dialog_add_row(GtkGrid *grid, int row, } gboolean create_person_dialog_present(GtkWindow *parent, const GPtrArray *records, const char *investigation_root_path, - TaskManager *task_manager, CreatePersonDialogSessionCheck session_check, + TaskManager *task_manager, const ToolInfo *tesseract_tool, + CreatePersonDialogSessionCheck session_check, CreatePersonDialogCallback callback, gpointer data, GDestroyNotify data_destroy) { static const char *const statuses[] = {"Inconnu", "Présumé", "Confirmé", NULL}; CreatePersonDialogState *state = NULL; GtkWidget *box = NULL, *grid = NULL, *actions = NULL, *cancel = NULL; - GtkWidget *roles_box = NULL, *evidence_box = NULL, *summary = NULL; + GtkWidget *roles_box = NULL, *evidence_box = NULL, *ocr_box = NULL; + GtkWidget *summary = NULL; if (parent == NULL || investigation_root_path == NULL || task_manager == NULL) return FALSE; state = g_new0(CreatePersonDialogState, 1); @@ -799,6 +1509,16 @@ gboolean create_person_dialog_present(GtkWindow *parent, evidence_selection_model_list_type_codes(state->selection_model); state->investigation_root_path = g_strdup(investigation_root_path); state->task_manager = task_manager; + state->ocr_runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + if (tesseract_tool != NULL && + tool_info_get_availability(tesseract_tool) == + TOOL_AVAILABILITY_AVAILABLE) { + state->tesseract_path = g_strdup( + tool_info_get_resolved_path(tesseract_tool)); + state->tesseract_version = g_strdup( + tool_info_get_detected_version(tesseract_tool)); + } state->lifecycle = person_dialog_lifecycle_new(); state->window = GTK_WINDOW(gtk_window_new()); gtk_window_set_application(state->window, @@ -806,7 +1526,7 @@ gboolean create_person_dialog_present(GtkWindow *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, 820, 680); + gtk_window_set_default_size(state->window, 960, 700); 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); @@ -930,17 +1650,198 @@ gboolean create_person_dialog_present(GtkWindow *parent, create_person_dialog_state_free(state); return FALSE; } - gtk_box_append(GTK_BOX(evidence_box), - evidence_preview_widget_get_widget(state->preview)); - gtk_stack_add_titled(state->stack, evidence_box, "evidence", "3 — Preuves"); + { + GtkWidget *evidence_paned = + 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); + 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); + gtk_paned_set_resize_start_child( + GTK_PANED(evidence_paned), FALSE); + gtk_paned_set_resize_end_child(GTK_PANED(evidence_paned), TRUE); + gtk_stack_add_titled(state->stack, evidence_paned, + "evidence", "3 — Preuves"); + } + ocr_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(ocr_box, "create-person-ocr-review-content"); + GtkWidget *ocr_warning = gtk_label_new( + "L’OCR produit des propositions à vérifier. Il ne confirme ni " + "l’identité ni l’authenticité du document."); + gtk_label_set_wrap(GTK_LABEL(ocr_warning), TRUE); + static const char *const document_types[] = { + "Carte nationale d’identité", "Passeport", "Permis de conduire", + "Titre de séjour", "Autre document d’identité", NULL}; + static const char *const document_sides[] = { + "Recto", "Verso", "Page d’identité", "Autre page", NULL}; + static const char *const profile_labels[] = { + "Aucun prétraitement", "Orientation normalisée", + "Niveaux de gris et contraste modéré", + "Agrandissement contrôlé", NULL}; + state->ocr_document_type = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings(document_types)); + state->ocr_document_side = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings(document_sides)); + { + static const char *const pending_languages[] = { + "Détection des langues…", NULL}; + state->ocr_languages = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings(pending_languages)); + } + state->ocr_profile = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings(profile_labels)); + state->ocr_page = GTK_SPIN_BUTTON( + gtk_spin_button_new_with_range(1, 999, 1)); + state->ocr_start = GTK_BUTTON( + gtk_button_new_with_label("Analyser comme document d’identité")); + state->ocr_cancel = GTK_BUTTON( + gtk_button_new_with_label("Annuler l’OCR")); + gtk_widget_set_sensitive(GTK_WIDGET(state->ocr_start), FALSE); + gtk_widget_set_tooltip_text(GTK_WIDGET(state->ocr_start), + state->tesseract_path != NULL + ? "Lancer explicitement Tesseract sur la copie contrôlée" + : "Tesseract est absent du registre d’outils"); + gtk_box_append(GTK_BOX(ocr_box), GTK_WIDGET(state->ocr_document_type)); + gtk_box_append(GTK_BOX(ocr_box), GTK_WIDGET(state->ocr_document_side)); + gtk_box_append(GTK_BOX(ocr_box), GTK_WIDGET(state->ocr_page)); + gtk_box_append(GTK_BOX(ocr_box), GTK_WIDGET(state->ocr_languages)); + gtk_box_append(GTK_BOX(ocr_box), GTK_WIDGET(state->ocr_profile)); + GtkWidget *ocr_actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_box_append(GTK_BOX(ocr_actions), GTK_WIDGET(state->ocr_start)); + gtk_box_append(GTK_BOX(ocr_actions), GTK_WIDGET(state->ocr_cancel)); + gtk_box_append(GTK_BOX(ocr_box), ocr_actions); + state->ocr_status = GTK_LABEL(gtk_label_new( + state->tesseract_path != NULL ? "OCR facultatif, non lancé." + : "Tesseract absent : OCR indisponible.")); + gtk_box_append(GTK_BOX(ocr_box), GTK_WIDGET(state->ocr_status)); + state->ocr_overlay = ocr_provenance_overlay_new(); + state->ocr_fields = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 6)); + GtkWidget *field_scroll = gtk_scrolled_window_new(); + gtk_widget_set_size_request(field_scroll, -1, 180); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(field_scroll), + GTK_WIDGET(state->ocr_fields)); + gtk_box_append(GTK_BOX(ocr_box), field_scroll); + { + static const char *const field_codes[] = { + "Type de document", "Pays émetteur", "Autorité émettrice", + "Numéro du document", "Nom", "Nom de naissance", "Prénoms", + "Sexe imprimé", "Nationalité", "Date de naissance", + "Lieu de naissance", "Date de délivrance", "Date d’expiration", + "Adresse imprimée", "Ligne MRZ 1", "Ligne MRZ 2", + "Ligne MRZ 3", NULL}; + GtkWidget *manual_box = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + state->ocr_manual_code = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings(field_codes)); + state->ocr_manual_value = GTK_ENTRY(gtk_entry_new()); + gtk_entry_set_placeholder_text(state->ocr_manual_value, + "Valeur réellement visible dans la preuve"); + gtk_widget_set_hexpand(GTK_WIDGET(state->ocr_manual_value), TRUE); + GtkWidget *add_manual = + gtk_button_new_with_label("Ajouter un champ manquant"); + gtk_box_append(GTK_BOX(manual_box), + GTK_WIDGET(state->ocr_manual_code)); + gtk_box_append(GTK_BOX(manual_box), + GTK_WIDGET(state->ocr_manual_value)); + gtk_box_append(GTK_BOX(manual_box), add_manual); + gtk_box_append(GTK_BOX(ocr_box), manual_box); + g_signal_connect(add_manual, "clicked", + G_CALLBACK(create_person_dialog_on_add_manual_field), state); + } + gtk_box_append(GTK_BOX(ocr_box), gtk_label_new( + "Notes factuelles sur le document " + "(tronqué, flou, masqué ou incomplet)")); + state->ocr_factual_notes = GTK_TEXT_VIEW(gtk_text_view_new()); + gtk_widget_set_name(GTK_WIDGET(state->ocr_factual_notes), + "create-person-ocr-factual-notes"); + gtk_text_view_set_wrap_mode( + state->ocr_factual_notes, GTK_WRAP_WORD_CHAR); + gtk_widget_set_size_request( + GTK_WIDGET(state->ocr_factual_notes), -1, 90); + gtk_box_append(GTK_BOX(ocr_box), + GTK_WIDGET(state->ocr_factual_notes)); + state->ocr_text = GTK_TEXT_VIEW(gtk_text_view_new()); + gtk_widget_set_name(GTK_WIDGET(state->ocr_text), + "create-person-ocr-raw-text"); + gtk_text_view_set_editable(state->ocr_text, FALSE); + gtk_text_view_set_monospace(state->ocr_text, TRUE); + GtkWidget *ocr_text_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(ocr_text_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(ocr_text_scroll), + GTK_WIDGET(state->ocr_text)); + state->ocr_corrected_text = GTK_TEXT_VIEW(gtk_text_view_new()); + gtk_widget_set_name(GTK_WIDGET(state->ocr_corrected_text), + "identity-corrected-transcription"); + gtk_text_view_set_wrap_mode( + state->ocr_corrected_text, GTK_WRAP_WORD_CHAR); + GtkWidget *corrected_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(corrected_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(corrected_scroll), + GTK_WIDGET(state->ocr_corrected_text)); + state->ocr_transcription_stack = GTK_STACK(gtk_stack_new()); + gtk_widget_set_name(GTK_WIDGET(state->ocr_transcription_stack), + "create-person-ocr-transcription-stack"); + gtk_widget_set_size_request( + GTK_WIDGET(state->ocr_transcription_stack), -1, 150); + gtk_stack_add_titled(state->ocr_transcription_stack, ocr_text_scroll, + "raw", "OCR brut"); + gtk_stack_add_titled( + state->ocr_transcription_stack, corrected_scroll, + "corrected", "Transcription corrigée"); + GtkWidget *transcription_switcher = gtk_stack_switcher_new(); + gtk_widget_set_name(transcription_switcher, + "create-person-ocr-transcription-switcher"); + gtk_stack_switcher_set_stack( + GTK_STACK_SWITCHER(transcription_switcher), + state->ocr_transcription_stack); + gtk_box_append(GTK_BOX(ocr_box), transcription_switcher); + gtk_box_append(GTK_BOX(ocr_box), + GTK_WIDGET(state->ocr_transcription_stack)); + GtkWidget *reset_transcription = gtk_button_new_with_label( + "Réinitialiser depuis l’OCR brut"); + gtk_widget_set_halign(reset_transcription, GTK_ALIGN_START); + g_signal_connect(reset_transcription, "clicked", + G_CALLBACK(create_person_dialog_on_reset_transcription), state); + gtk_box_append(GTK_BOX(ocr_box), reset_transcription); + GtkWidget *ocr_left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(ocr_left, "create-person-ocr-left-panel"); + gtk_widget_set_size_request(ocr_left, 500, -1); + gtk_box_append(GTK_BOX(ocr_left), ocr_warning); + GtkWidget *ocr_scroll = gtk_scrolled_window_new(); + gtk_widget_set_name(ocr_scroll, "create-person-ocr-scroll"); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(ocr_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_propagate_natural_height( + GTK_SCROLLED_WINDOW(ocr_scroll), FALSE); + gtk_widget_set_vexpand(ocr_scroll, TRUE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(ocr_scroll), ocr_box); + gtk_box_append(GTK_BOX(ocr_left), ocr_scroll); + GtkWidget *ocr_paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_name(ocr_paned, "create-person-ocr-paned"); + gtk_paned_set_start_child(GTK_PANED(ocr_paned), ocr_left); + GtkWidget *ocr_preview = + ocr_provenance_overlay_get_widget(state->ocr_overlay); + gtk_widget_set_name(ocr_preview, "create-person-ocr-preview"); + gtk_widget_set_size_request(ocr_preview, 180, -1); + gtk_paned_set_end_child(GTK_PANED(ocr_paned), ocr_preview); + gtk_paned_set_resize_start_child(GTK_PANED(ocr_paned), FALSE); + gtk_paned_set_resize_end_child(GTK_PANED(ocr_paned), TRUE); + gtk_stack_add_titled(state->stack, ocr_paned, "identity-ocr", + "4 — OCR identité"); state->summary = GTK_LABEL(gtk_label_new("")); summary = GTK_WIDGET(state->summary); gtk_label_set_wrap(state->summary, TRUE); gtk_label_set_xalign(state->summary, 0.0f); gtk_label_set_selectable(state->summary, TRUE); - gtk_stack_add_titled(state->stack, summary, "summary", "4 — Confirmation"); + gtk_stack_add_titled(state->stack, summary, "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_halign(actions, GTK_ALIGN_END); + actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_name(actions, "create-person-actions"); + gtk_widget_set_halign(actions, GTK_ALIGN_END); cancel = gtk_button_new_with_label("Annuler"); state->previous = GTK_BUTTON(gtk_button_new_with_label("Précédent")); state->next = GTK_BUTTON(gtk_button_new_with_label("Suivant")); @@ -953,6 +1854,7 @@ gboolean create_person_dialog_present(GtkWindow *parent, state->progress = GTK_LABEL(gtk_label_new(NULL)); gtk_label_set_xalign(state->progress, 0.0f); gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->progress)); + gtk_widget_set_vexpand(GTK_WIDGET(state->stack), TRUE); gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->stack)); gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->error)); gtk_box_append(GTK_BOX(box), actions); gtk_window_set_child(state->window, box); @@ -964,6 +1866,10 @@ gboolean create_person_dialog_present(GtkWindow *parent, G_CALLBACK(create_person_dialog_on_next), state); g_signal_connect(state->create, "clicked", G_CALLBACK(create_person_dialog_on_create), state); + g_signal_connect(state->ocr_start, "clicked", + G_CALLBACK(create_person_dialog_on_ocr_start), state); + g_signal_connect(state->ocr_cancel, "clicked", + G_CALLBACK(create_person_dialog_on_ocr_cancel), state); g_signal_connect(state->search, "changed", G_CALLBACK(create_person_dialog_on_search_changed), state); g_signal_connect(state->type_filter, "notify::selected", @@ -985,7 +1891,20 @@ gboolean create_person_dialog_present(GtkWindow *parent, create_person_dialog_update_navigation(state); g_object_set_data_full(G_OBJECT(state->window), "person-dialog-state", state, create_person_dialog_state_free); - gtk_window_present(state->window); return TRUE; + if (state->tesseract_path != NULL) { + IdentityLanguageJob *job = g_new0(IdentityLanguageJob, 1); + job->executable = g_strdup(state->tesseract_path); + g_weak_ref_init(&job->window, G_OBJECT(state->window)); + GTask *task = g_task_new(NULL, NULL, identity_languages_completed, job); + g_task_set_task_data(task, job, identity_language_job_free); + 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); + return TRUE; } void create_person_dialog_result_free(CreatePersonDialogResult *result) { @@ -997,6 +1916,7 @@ void create_person_dialog_result_free(CreatePersonDialogResult *result) g_clear_pointer(&result->evidence_selection, person_evidence_selection_free); g_clear_pointer(&result->staging, evidence_staging_free); + g_clear_pointer(&result->ocr_runs, g_ptr_array_unref); g_free(result); } const PersonEntityInput *create_person_dialog_result_get_input( @@ -1017,3 +1937,21 @@ EvidenceStaging *create_person_dialog_result_steal_staging( if (result != NULL) result->staging = NULL; return staging; } +const GPtrArray *create_person_dialog_result_get_ocr_runs( + const CreatePersonDialogResult *result) +{ + return result != NULL ? result->ocr_runs : NULL; +} +gboolean create_person_dialog_test_overlay_has_region(GtkWindow *window) +{ + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + return state != NULL && + ocr_provenance_overlay_has_region(state->ocr_overlay); +} +guint64 create_person_dialog_test_ocr_generation(GtkWindow *window) +{ + CreatePersonDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; + return state != NULL ? state->ocr_generation : 0; +} diff --git a/src/views/evidence_identity_ocr_dialog.c b/src/views/evidence_identity_ocr_dialog.c new file mode 100644 index 0000000..026f2f3 --- /dev/null +++ b/src/views/evidence_identity_ocr_dialog.c @@ -0,0 +1,1043 @@ +#include "views/evidence_identity_ocr_dialog.h" + +#include "core/file_hash.h" +#include "core/identity_ocr_workflow.h" +#include "core/ocr_analysis.h" +#include "widgets/ocr_provenance_overlay.h" + +#include + +struct EvidenceIdentityOcrDialogResult { + GtkWindow *dialog; + char *person_identifier; + char *temporary_identifier; + char *sha256; + char *mime_type; + guint64 size; + IdentityOcrRun *run; +}; + +typedef struct { + char *root; + char *relative; + char *identifier; + char *sha256; + char *mime; + char *type; + char *side; + char *languages; + char *profile; + char *executable; + char *version; + guint page; + guint profile_index; + guint64 generation; + GWeakRef window; +} EvidenceIdentityOcrJob; + +typedef struct { + GtkWindow *window; + GtkDropDown *person; + GtkDropDown *type; + GtkDropDown *side; + GtkSpinButton *page; + GtkDropDown *languages; + GtkDropDown *profile; + GtkButton *start; + GtkButton *continue_with_ocr; + GtkLabel *status; + GtkTextView *raw_text; + GtkTextView *corrected_text; + GtkButton *save_transcription; + GtkTextView *notes; + GtkBox *fields; + OcrProvenanceOverlay *overlay; + GPtrArray *person_identifiers; + GPtrArray *language_codes; + char *file_path; + char *temporary_identifier; + char *sha256; + char *mime_type; + guint64 size; + char *tesseract_path; + char *tesseract_version; + IdentityOcrRun *run; + GTask *task; + BackgroundTask *submission_task; + GCancellable *cancellable; + guint64 generation; + EvidenceIdentityOcrDialogSessionCheck session_check; + EvidenceIdentityOcrDialogCallback callback; + gpointer user_data; + GDestroyNotify user_data_destroy; + gboolean completed; + gboolean submission_pending; + gboolean review_mode; +} EvidenceIdentityOcrDialogState; + +static const char *const document_types[] = { + "identity_card", "passport", "driving_licence", + "residence_permit", "other"}; +static const char *const document_sides[] = { + "front", "back", "identity_page", "other_page"}; +static const char *const profiles[] = { + "none", "orientation", "grayscale", "upscale"}; + +static char *transcription_text(GtkTextView *view) +{ + GtkTextIter start, end; + GtkTextBuffer *buffer = gtk_text_view_get_buffer(view); + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} + +static void on_edit_transcription(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + (void) button; + gtk_text_view_set_editable(state->corrected_text, TRUE); + gtk_widget_set_sensitive(GTK_WIDGET(state->save_transcription), TRUE); +} + +static void on_save_transcription(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + char *text = transcription_text(state->corrected_text); + GDateTime *now = g_date_time_new_now_utc(); + char *timestamp = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ"); + (void) button; + if (identity_ocr_run_set_corrected_transcription( + state->run, text, timestamp)) { + gtk_text_view_set_editable(state->corrected_text, FALSE); + gtk_widget_set_sensitive(GTK_WIDGET(state->save_transcription), FALSE); + } + g_date_time_unref(now); + g_free(timestamp); + g_free(text); +} + +static void on_reset_transcription(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + const char *raw = state->run != NULL + ? identity_ocr_run_get_raw_text(state->run) : ""; + (void) button; + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->corrected_text), + raw != NULL ? raw : "", -1); + identity_ocr_run_reset_corrected_transcription(state->run); + gtk_text_view_set_editable(state->corrected_text, TRUE); + 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; + if (dot == NULL) return NULL; + if (g_ascii_strcasecmp(dot, ".png") == 0) return "image/png"; + if (g_ascii_strcasecmp(dot, ".jpg") == 0 || + g_ascii_strcasecmp(dot, ".jpeg") == 0) return "image/jpeg"; + if (g_ascii_strcasecmp(dot, ".heic") == 0) return "image/heic"; + if (g_ascii_strcasecmp(dot, ".heif") == 0) return "image/heif"; + if (g_ascii_strcasecmp(dot, ".pdf") == 0) return "application/pdf"; + return NULL; +} + +gboolean evidence_identity_ocr_dialog_file_is_compatible( + const char *file_path) +{ + return mime_from_path(file_path) != NULL; +} + +static void evidence_identity_ocr_job_free(gpointer data) +{ + EvidenceIdentityOcrJob *job = data; + if (job == NULL) return; + g_free(job->root); g_free(job->relative); g_free(job->identifier); + g_free(job->sha256); g_free(job->mime); g_free(job->type); + g_free(job->side); g_free(job->languages); g_free(job->profile); + g_free(job->executable); g_free(job->version); + g_weak_ref_clear(&job->window); + g_free(job); +} + +static void evidence_identity_ocr_dialog_state_free(gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + if (state == NULL) return; + state->generation++; + if (state->cancellable != NULL) g_cancellable_cancel(state->cancellable); + g_clear_object(&state->task); + if (state->submission_task != NULL) { + background_task_cancel(state->submission_task); + background_task_unref(state->submission_task); + } + g_clear_object(&state->cancellable); + identity_ocr_run_free(state->run); + g_clear_pointer(&state->overlay, ocr_provenance_overlay_free); + g_clear_pointer(&state->person_identifiers, g_ptr_array_unref); + g_clear_pointer(&state->language_codes, g_ptr_array_unref); + g_free(state->file_path); g_free(state->temporary_identifier); + g_free(state->sha256); g_free(state->mime_type); + g_free(state->tesseract_path); g_free(state->tesseract_version); + if (state->user_data_destroy != NULL) + state->user_data_destroy(state->user_data); + g_free(state); +} + +static void evidence_identity_ocr_dialog_complete( + EvidenceIdentityOcrDialogState *state, + EvidenceIdentityOcrDialogResult *result) +{ + if (state == NULL || state->completed) { + evidence_identity_ocr_dialog_result_free(result); + return; + } + state->completed = TRUE; + if (state->cancellable != NULL) g_cancellable_cancel(state->cancellable); + gtk_widget_set_visible(GTK_WIDGET(state->window), FALSE); + if (state->callback != NULL) state->callback(result, state->user_data); + else evidence_identity_ocr_dialog_result_free(result); + gtk_window_destroy(state->window); +} + +static EvidenceIdentityOcrDialogResult *build_result( + EvidenceIdentityOcrDialogState *state, gboolean with_ocr) +{ + EvidenceIdentityOcrDialogResult *result = + g_new0(EvidenceIdentityOcrDialogResult, 1); + result->dialog = g_object_ref(state->window); + guint selected = gtk_drop_down_get_selected(state->person); + if (with_ocr && selected > 0 && + selected - 1 < state->person_identifiers->len) + result->person_identifier = g_strdup(g_ptr_array_index( + state->person_identifiers, selected - 1)); + result->temporary_identifier = + g_strdup(state->temporary_identifier); + result->sha256 = g_strdup(state->sha256); + result->mime_type = g_strdup(state->mime_type); + result->size = state->size; + if (with_ocr) { + char *corrected = transcription_text(state->corrected_text); + if (g_strcmp0(corrected, + identity_ocr_run_get_raw_text(state->run)) == 0) + identity_ocr_run_reset_corrected_transcription(state->run); + else { + GDateTime *now = g_date_time_new_now_utc(); + char *timestamp = g_date_time_format( + now, "%Y-%m-%dT%H:%M:%SZ"); + identity_ocr_run_set_corrected_transcription( + state->run, corrected, timestamp); + g_free(timestamp); + g_date_time_unref(now); + } + g_free(corrected); + GtkTextBuffer *buffer = gtk_text_view_get_buffer(state->notes); + GtkTextIter start, end; + gtk_text_buffer_get_bounds(buffer, &start, &end); + char *notes = gtk_text_buffer_get_text( + buffer, &start, &end, FALSE); + identity_ocr_run_set_factual_notes( + state->run, notes[0] != '\0' ? notes : NULL); + g_free(notes); + result->run = state->run; + state->run = NULL; + } + return result; +} + +static void on_cancel(GtkButton *button, gpointer data) +{ + (void) button; + evidence_identity_ocr_dialog_complete(data, NULL); +} + +static gboolean on_close(GtkWindow *window, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + (void) window; + if (state->completed) return FALSE; + evidence_identity_ocr_dialog_complete(state, NULL); + return TRUE; +} + +static void on_without_ocr(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + (void) button; + evidence_identity_ocr_dialog_complete(state, build_result(state, FALSE)); +} + +static void on_continue_with_ocr(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + guint selected = gtk_drop_down_get_selected(state->person); + (void) button; + if (state->submission_pending) return; + if (state->session_check != NULL && + !state->session_check(state->user_data)) { + gtk_label_set_text(state->status, + "La session d’enquête a changé. Relancez l’analyse."); + return; + } + if (state->run == NULL) { + gtk_label_set_text(state->status, + "Aucun résultat OCR valide n’est disponible."); + return; + } + if (selected == 0 || selected == GTK_INVALID_LIST_POSITION) { + gtk_label_set_text(state->status, + "Sélectionnez une personne avant de poursuivre."); + return; + } + state->submission_pending = TRUE; + gtk_widget_set_sensitive(GTK_WIDGET(state->continue_with_ocr), FALSE); + gtk_label_set_text(state->status, state->review_mode + ? "Enregistrement de la révision…" + : "Import OCR en cours…"); + if (state->callback != NULL) + state->callback(build_result(state, TRUE), state->user_data); + else { + state->submission_pending = FALSE; + gtk_widget_set_sensitive( + GTK_WIDGET(state->continue_with_ocr), TRUE); + gtk_label_set_text(state->status, + "Impossible de poursuivre : aucun gestionnaire d’import."); + } +} + +static void on_field_accept(GtkButton *button, gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "field"); + GtkEntry *entry = g_object_get_data(G_OBJECT(button), "entry"); + (void) data; + identity_field_observation_accept(field); + gtk_button_set_label(button, "Acceptée"); + gtk_editable_set_editable(GTK_EDITABLE(entry), FALSE); +} + +static void on_field_modify(GtkButton *button, gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "field"); + GtkEntry *entry = g_object_get_data(G_OBJECT(button), "entry"); + (void) data; + if (!identity_field_observation_modify(field, + gtk_editable_get_text(GTK_EDITABLE(entry)), + "Valeur revue individuellement pendant l’import.")) return; + gtk_button_set_label(button, "Modifiée"); + gtk_widget_set_sensitive(GTK_WIDGET(button), FALSE); + gtk_editable_set_editable(GTK_EDITABLE(entry), FALSE); +} + +static void on_field_reedit(GtkButton *button, gpointer data) +{ + GtkEntry *entry = g_object_get_data(G_OBJECT(button), "entry"); + GtkButton *modify = g_object_get_data(G_OBJECT(button), "modify"); + (void) data; + gtk_editable_set_editable(GTK_EDITABLE(entry), TRUE); + gtk_widget_set_sensitive(GTK_WIDGET(modify), TRUE); + gtk_widget_grab_focus(GTK_WIDGET(entry)); +} + +static void on_field_restore(GtkButton *button, gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "field"); + GtkEntry *entry = g_object_get_data(G_OBJECT(button), "entry"); + GtkButton *modify = g_object_get_data(G_OBJECT(button), "modify"); + (void) data; + if (!identity_field_observation_restore_raw(field)) return; + gtk_editable_set_text(GTK_EDITABLE(entry), + identity_field_observation_get_raw_value(field)); + gtk_editable_set_editable(GTK_EDITABLE(entry), TRUE); + gtk_button_set_label(modify, "Modifier"); + gtk_widget_set_sensitive(GTK_WIDGET(modify), TRUE); +} + +static void on_field_reject(GtkButton *button, gpointer data) +{ + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "field"); + (void) data; + identity_field_observation_reject(field); + gtk_button_set_label(button, "Rejetée"); +} + +static void on_field_show(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + IdentityFieldObservation *field = g_object_get_data( + G_OBJECT(button), "field"); + ocr_provenance_overlay_set_field( + state->overlay, field, state->generation); +} + +static void render_fields(EvidenceIdentityOcrDialogState *state) +{ + GtkWidget *child; + while ((child = gtk_widget_get_first_child( + GTK_WIDGET(state->fields))) != NULL) + gtk_box_remove(state->fields, child); + const GPtrArray *fields = identity_ocr_run_get_fields(state->run); + for (guint index = 0; fields != NULL && index < fields->len; index++) { + IdentityFieldObservation *field = + g_ptr_array_index((GPtrArray *) fields, index); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + char *description = g_strdup_printf( + "%s — OCR brut : %s — origine : %s", + identity_field_observation_get_code(field), + identity_field_observation_get_raw_value(field) != NULL + ? identity_field_observation_get_raw_value(field) : "absente", + identity_field_observation_get_origin(field)); + GtkWidget *label = gtk_label_new(description); + GtkWidget *entry = gtk_entry_new(); + GtkWidget *actions = gtk_flow_box_new(); + GtkWidget *show = gtk_button_new_with_label("Voir la zone"); + GtkWidget *accept = gtk_button_new_with_label("Accepter"); + GtkWidget *modify = gtk_button_new_with_label("Modifier"); + GtkWidget *reedit = gtk_button_new_with_label("Modifier à nouveau"); + GtkWidget *restore = + gtk_button_new_with_label("Revenir à la valeur OCR"); + GtkWidget *reject = gtk_button_new_with_label("Rejeter"); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_wrap_mode(GTK_LABEL(label), PANGO_WRAP_WORD_CHAR); + gtk_widget_set_hexpand(entry, TRUE); + gtk_flow_box_set_selection_mode( + GTK_FLOW_BOX(actions), GTK_SELECTION_NONE); + gtk_flow_box_set_max_children_per_line(GTK_FLOW_BOX(actions), 3); + gtk_flow_box_set_min_children_per_line(GTK_FLOW_BOX(actions), 1); + gtk_flow_box_set_row_spacing(GTK_FLOW_BOX(actions), 4); + gtk_flow_box_set_column_spacing(GTK_FLOW_BOX(actions), 4); + gtk_editable_set_text(GTK_EDITABLE(entry), + identity_field_observation_get_corrected_value(field) != NULL + ? identity_field_observation_get_corrected_value(field) + : identity_field_observation_get_raw_value(field)); + g_object_set_data(G_OBJECT(row), "identity-field-code", + (gpointer) identity_field_observation_get_code(field)); + g_object_set_data(G_OBJECT(row), "identity-field-order", + GUINT_TO_POINTER(identity_field_observation_get_order(field) + 1)); + g_object_set_data(G_OBJECT(row), "identity-value-entry", entry); + g_object_set_data(G_OBJECT(row), "identity-modify-button", modify); + g_object_set_data(G_OBJECT(row), "identity-reedit-button", reedit); + GtkWidget *buttons[] = {show, accept, modify, restore, reject}; + for (guint i = 0; i < G_N_ELEMENTS(buttons); i++) { + g_object_set_data(G_OBJECT(buttons[i]), "field", field); + g_object_set_data(G_OBJECT(buttons[i]), "entry", entry); + } + g_object_set_data(G_OBJECT(reedit), "entry", entry); + g_object_set_data(G_OBJECT(reedit), "modify", modify); + g_object_set_data(G_OBJECT(restore), "modify", modify); + g_signal_connect(show, "clicked", G_CALLBACK(on_field_show), state); + g_signal_connect(accept, "clicked", G_CALLBACK(on_field_accept), state); + g_signal_connect(modify, "clicked", G_CALLBACK(on_field_modify), state); + g_signal_connect(reedit, "clicked", G_CALLBACK(on_field_reedit), state); + g_signal_connect(restore, "clicked", G_CALLBACK(on_field_restore), state); + g_signal_connect(reject, "clicked", G_CALLBACK(on_field_reject), state); + gtk_flow_box_append(GTK_FLOW_BOX(actions), show); + gtk_flow_box_append(GTK_FLOW_BOX(actions), accept); + gtk_flow_box_append(GTK_FLOW_BOX(actions), modify); + gtk_flow_box_append(GTK_FLOW_BOX(actions), reedit); + if (identity_field_observation_get_raw_value(field) != NULL) + gtk_flow_box_append(GTK_FLOW_BOX(actions), restore); + gtk_flow_box_append(GTK_FLOW_BOX(actions), reject); + gtk_box_append(GTK_BOX(row), label); + gtk_box_append(GTK_BOX(row), entry); + gtk_box_append(GTK_BOX(row), actions); + gtk_box_append(state->fields, row); + g_free(description); + } +} + +static void on_add_manual(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + GtkEntry *entry = g_object_get_data(G_OBJECT(button), "entry"); + GtkDropDown *codes = g_object_get_data(G_OBJECT(button), "codes"); + static const char *const field_codes[] = { + "document_type","issuing_country","issuing_authority", + "document_number","surname","birth_name","given_names", + "sex_as_printed","nationality","birth_date","birth_place", + "issue_date","expiry_date","address_as_printed", + "mrz_line_1","mrz_line_2","mrz_line_3"}; + guint selected = gtk_drop_down_get_selected(codes); + const char *value = gtk_editable_get_text(GTK_EDITABLE(entry)); + if (state->run == NULL || selected >= G_N_ELEMENTS(field_codes) || + value[0] == '\0') return; + IdentityFieldObservation *field = identity_field_observation_new_manual( + field_codes[selected], value, + identity_ocr_run_get_fields(state->run)->len); + identity_ocr_run_add_field(state->run, field); + gtk_editable_set_text(GTK_EDITABLE(entry), ""); + render_fields(state); +} + +static void workflow_worker(GTask *task, gpointer source, + gpointer task_data, GCancellable *cancellable) +{ + EvidenceIdentityOcrJob *job = task_data; + IdentityOcrWorkflowRequest request = { + .root_path=job->root, .evidence_identifier=job->identifier, + .relative_path=job->relative, .expected_sha256=job->sha256, + .mime_type=job->mime, .document_type=job->type, + .document_side=job->side, .languages=job->languages, + .preprocessing_profile=job->profile, + .tesseract_executable=job->executable, + .tesseract_version=job->version, .page_number=job->page, + .profile=(IdentityOcrPreprocessProfile)job->profile_index, + .generation=job->generation}; + GError *error = NULL; + IdentityOcrRun *run = identity_ocr_workflow_execute( + &request, cancellable, &error); + (void) source; + if (run != NULL) + g_task_return_pointer(task, run, + (GDestroyNotify) identity_ocr_run_free); + else + g_task_return_error(task, error); +} + +static void workflow_completed(GObject *source, GAsyncResult *result, + gpointer data) +{ + EvidenceIdentityOcrJob *job = data; + GtkWindow *window = g_weak_ref_get(&job->window); + EvidenceIdentityOcrDialogState *state = window != NULL + ? g_object_get_data(G_OBJECT(window), + "evidence-identity-ocr-state") : NULL; + GError *error = NULL; + IdentityOcrRun *run = g_task_propagate_pointer(G_TASK(result), &error); + (void) source; + if (state != NULL && job->generation == state->generation && + (state->session_check == NULL || + state->session_check(state->user_data))) { + identity_ocr_run_free(state->run); + state->run = run; + run = NULL; + if (state->run != NULL) { + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->raw_text), + identity_ocr_run_get_raw_text(state->run), -1); + gtk_text_buffer_set_text( + gtk_text_view_get_buffer(state->corrected_text), + identity_ocr_run_get_raw_text(state->run), -1); + render_fields(state); + gtk_text_view_set_editable(state->corrected_text, TRUE); + gtk_widget_set_sensitive( + GTK_WIDGET(state->save_transcription), TRUE); + ocr_provenance_overlay_set_image(state->overlay, + identity_ocr_run_get_preview(state->run), state->generation); + gtk_label_set_text(state->status, + "OCR terminé. Vérifiez chaque proposition."); + gtk_widget_set_sensitive( + GTK_WIDGET(state->continue_with_ocr), TRUE); + } else + gtk_label_set_text(state->status, + error != NULL ? error->message : "OCR annulé."); + g_clear_object(&state->task); + g_clear_object(&state->cancellable); + } + identity_ocr_run_free(run); + g_clear_error(&error); + g_clear_object(&window); +} + +static void on_start(GtkButton *button, gpointer data) +{ + EvidenceIdentityOcrDialogState *state = data; + guint selected_language = gtk_drop_down_get_selected(state->languages); + guint selected_type = gtk_drop_down_get_selected(state->type); + guint selected_side = gtk_drop_down_get_selected(state->side); + guint selected_profile = gtk_drop_down_get_selected(state->profile); + (void) button; + if (selected_language == GTK_INVALID_LIST_POSITION || + state->language_codes == NULL || + selected_language >= state->language_codes->len) return; + state->generation++; + if (state->cancellable != NULL) g_cancellable_cancel(state->cancellable); + g_clear_object(&state->task); + g_clear_object(&state->cancellable); + EvidenceIdentityOcrJob *job = g_new0(EvidenceIdentityOcrJob, 1); + job->root = g_path_get_dirname(state->file_path); + job->relative = g_path_get_basename(state->file_path); + job->identifier = g_strdup(state->temporary_identifier); + job->sha256 = g_strdup(state->sha256); + job->mime = g_strdup(state->mime_type); + job->type = g_strdup(document_types[MIN( + selected_type, G_N_ELEMENTS(document_types)-1)]); + job->side = g_strdup(document_sides[MIN( + selected_side, G_N_ELEMENTS(document_sides)-1)]); + job->languages = g_strdup(g_ptr_array_index( + state->language_codes, selected_language)); + job->profile_index = MIN(selected_profile, G_N_ELEMENTS(profiles)-1); + job->profile = g_strdup(profiles[job->profile_index]); + job->executable = g_strdup(state->tesseract_path); + job->version = g_strdup(state->tesseract_version); + job->page = (guint) gtk_spin_button_get_value_as_int(state->page); + job->generation = state->generation; + g_weak_ref_init(&job->window, G_OBJECT(state->window)); + state->cancellable = g_cancellable_new(); + state->task = g_task_new(NULL, state->cancellable, + workflow_completed, job); + g_task_set_task_data(state->task, job, evidence_identity_ocr_job_free); + gtk_widget_set_sensitive(GTK_WIDGET(state->continue_with_ocr), FALSE); + gtk_label_set_text(state->status, + "OCR contrôlé en cours… Aucune écriture n’est effectuée."); + g_task_run_in_thread(state->task, workflow_worker); +} + +static gboolean populate_languages(EvidenceIdentityOcrDialogState *state, + GError **error) +{ + char *raw = ocr_analysis_list_languages( + state->tesseract_path, NULL, error); + GPtrArray *parsed = raw != NULL + ? ocr_analysis_parse_languages(raw) : NULL; + state->language_codes = parsed != NULL + ? ocr_analysis_build_language_choices(parsed) : NULL; + g_free(raw); + g_clear_pointer(&parsed, g_ptr_array_unref); + if (state->language_codes == NULL || + state->language_codes->len == 0) return FALSE; + GtkStringList *labels = gtk_string_list_new(NULL); + for (guint i = 0; i < state->language_codes->len; i++) + gtk_string_list_append(labels, + g_ptr_array_index(state->language_codes, i)); + gtk_drop_down_set_model(state->languages, G_LIST_MODEL(labels)); + g_object_unref(labels); + gtk_drop_down_set_selected(state->languages, 0); + return TRUE; +} + +gboolean evidence_identity_ocr_dialog_present( + GtkWindow *parent, const char *file_path, const GPtrArray *persons, + const char *preselected_person_identifier, + const ToolInfo *tesseract_tool, + EvidenceIdentityOcrDialogSessionCheck session_check, + EvidenceIdentityOcrDialogCallback callback, + gpointer user_data, GDestroyNotify user_data_destroy, + GError **error) +{ + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (parent == NULL || file_path == NULL || persons == NULL || + callback == NULL || !evidence_identity_ocr_dialog_file_is_compatible( + file_path)) { + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, + "Les paramètres du dialogue OCR d’import sont invalides."); + return FALSE; + } + EvidenceIdentityOcrDialogState *state = + g_new0(EvidenceIdentityOcrDialogState, 1); + state->file_path = g_strdup(file_path); + state->temporary_identifier = g_uuid_string_random(); + state->mime_type = g_strdup(mime_from_path(file_path)); + state->session_check = session_check; + state->callback = callback; + state->user_data = user_data; + state->user_data_destroy = user_data_destroy; + state->person_identifiers = g_ptr_array_new_with_free_func(g_free); + if (!file_hash_compute_sha256( + file_path, NULL, &state->sha256, &state->size, error)) + goto failure; + if (tesseract_tool != NULL && + tool_info_get_availability(tesseract_tool) == + TOOL_AVAILABILITY_AVAILABLE) { + state->tesseract_path = g_strdup( + tool_info_get_resolved_path(tesseract_tool)); + state->tesseract_version = g_strdup( + tool_info_get_detected_version(tesseract_tool)); + } + 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); + 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); + gtk_widget_set_margin_end(root, 14); + gtk_widget_set_margin_top(root, 14); + gtk_widget_set_margin_bottom(root, 14); + GtkWidget *intro = gtk_label_new( + "L’OCR est facultatif et ne confirme ni l’identité ni " + "l’authenticité du document."); + gtk_label_set_wrap(GTK_LABEL(intro), TRUE); + gtk_label_set_xalign(GTK_LABEL(intro), 0.0f); + gtk_box_append(GTK_BOX(root), intro); + GtkStringList *person_labels = gtk_string_list_new(NULL); + gtk_string_list_append(person_labels, + "Sélectionner explicitement une personne existante"); + guint preselected = 0; + for (guint i = 0; i < persons->len; i++) { + EntityRecord *person = g_ptr_array_index((GPtrArray *) persons, i); + if (g_strcmp0(entity_record_get_type_identifier(person), + "person") != 0) continue; + const char *identifier = entity_record_get_identifier(person); + char *short_id = g_strndup(identifier, 8); + char *label = g_strdup_printf("%s%s%s — %s", + entity_record_get_label(person) != NULL + ? entity_record_get_label(person) + : entity_record_get_value(person), + entity_record_get_label(person) != NULL ? " — " : "", + entity_record_get_label(person) != NULL + ? entity_record_get_value(person) : "", + short_id); + gtk_string_list_append(person_labels, label); + g_ptr_array_add(state->person_identifiers, g_strdup(identifier)); + if (g_strcmp0(identifier, preselected_person_identifier) == 0) + preselected = state->person_identifiers->len; + g_free(short_id); g_free(label); + } + state->person = GTK_DROP_DOWN(gtk_drop_down_new( + G_LIST_MODEL(person_labels), NULL)); + gtk_widget_set_name(GTK_WIDGET(state->person), "identity-person"); + gtk_drop_down_set_selected(state->person, preselected); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->person)); + state->type = GTK_DROP_DOWN(gtk_drop_down_new_from_strings( + (const char *[]){"Carte d’identité","Passeport","Permis", + "Titre de séjour","Autre",NULL})); + gtk_widget_set_name(GTK_WIDGET(state->type), "identity-document-type"); + state->side = GTK_DROP_DOWN(gtk_drop_down_new_from_strings( + (const char *[]){"Recto","Verso","Page d’identité", + "Autre page",NULL})); + gtk_widget_set_name(GTK_WIDGET(state->side), "identity-document-side"); + state->page = GTK_SPIN_BUTTON( + gtk_spin_button_new_with_range(1, 999, 1)); + state->languages = GTK_DROP_DOWN( + gtk_drop_down_new_from_strings( + (const char *[]){"Langues indisponibles",NULL})); + gtk_widget_set_name(GTK_WIDGET(state->languages), "identity-languages"); + state->profile = GTK_DROP_DOWN(gtk_drop_down_new_from_strings( + (const char *[]){"Aucun prétraitement","Orientation", + "Niveaux de gris","Agrandissement",NULL})); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->type)); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->side)); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->page)); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->languages)); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->profile)); + state->start = GTK_BUTTON(gtk_button_new_with_label( + "Analyser comme document d’identité")); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->start)); + state->status = GTK_LABEL(gtk_label_new( + "Vous pouvez poursuivre l’import sans lancer l’OCR.")); + gtk_widget_set_name(GTK_WIDGET(state->status), + "evidence-identity-ocr-status"); + gtk_label_set_wrap(state->status, TRUE); + gtk_label_set_xalign(state->status, 0.0f); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->status)); + state->overlay = ocr_provenance_overlay_new(); + state->fields = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 6)); + GtkWidget *scroll = gtk_scrolled_window_new(); + gtk_widget_set_size_request(scroll, -1, 180); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), + GTK_WIDGET(state->fields)); + gtk_box_append(GTK_BOX(root), scroll); + GtkWidget *manual = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + GtkDropDown *codes = GTK_DROP_DOWN(gtk_drop_down_new_from_strings( + (const char *[]){"Type de document","Pays émetteur", + "Autorité émettrice","Numéro","Nom","Nom de naissance", + "Prénoms","Sexe","Nationalité","Date de naissance", + "Lieu de naissance","Délivrance","Expiration","Adresse", + "MRZ 1","MRZ 2","MRZ 3",NULL})); + GtkWidget *manual_entry = gtk_entry_new(); + gtk_widget_set_hexpand(manual_entry, TRUE); + gtk_entry_set_placeholder_text(GTK_ENTRY(manual_entry), + "Valeur visible mais omise par l’OCR"); + GtkWidget *add_manual = + gtk_button_new_with_label("Ajouter un champ manquant"); + g_object_set_data(G_OBJECT(add_manual), "codes", codes); + g_object_set_data(G_OBJECT(add_manual), "entry", manual_entry); + g_signal_connect(add_manual, "clicked", + G_CALLBACK(on_add_manual), state); + gtk_box_append(GTK_BOX(manual), GTK_WIDGET(codes)); + gtk_box_append(GTK_BOX(manual), manual_entry); + gtk_box_append(GTK_BOX(manual), add_manual); + gtk_box_append(GTK_BOX(root), manual); + gtk_box_append(GTK_BOX(root), gtk_label_new( + "Notes factuelles : document tronqué, flou, masqué ou incomplet")); + state->notes = GTK_TEXT_VIEW(gtk_text_view_new()); + gtk_widget_set_name(GTK_WIDGET(state->notes), "identity-notes"); + gtk_widget_set_size_request(GTK_WIDGET(state->notes), -1, 70); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->notes)); + state->raw_text = GTK_TEXT_VIEW(gtk_text_view_new()); + gtk_widget_set_name(GTK_WIDGET(state->raw_text), "identity-raw-text"); + gtk_text_view_set_editable(state->raw_text, FALSE); + gtk_widget_set_size_request(GTK_WIDGET(state->raw_text), -1, 100); + gtk_box_append(GTK_BOX(root), gtk_label_new("Texte OCR brut")); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->raw_text)); + gtk_box_append(GTK_BOX(root), gtk_label_new( + "Transcription corrigée manuellement")); + state->corrected_text = GTK_TEXT_VIEW(gtk_text_view_new()); + gtk_widget_set_name(GTK_WIDGET(state->corrected_text), + "identity-corrected-transcription"); + gtk_text_view_set_wrap_mode(state->corrected_text, GTK_WRAP_WORD_CHAR); + gtk_text_view_set_editable(state->corrected_text, TRUE); + gtk_widget_set_size_request(GTK_WIDGET(state->corrected_text), -1, 120); + gtk_box_append(GTK_BOX(root), GTK_WIDGET(state->corrected_text)); + GtkWidget *transcription_actions = gtk_flow_box_new(); + gtk_flow_box_set_selection_mode( + GTK_FLOW_BOX(transcription_actions), GTK_SELECTION_NONE); + GtkWidget *edit_transcription = + gtk_button_new_with_label("Modifier la transcription"); + state->save_transcription = GTK_BUTTON( + gtk_button_new_with_label("Enregistrer la correction")); + GtkWidget *reedit_transcription = + gtk_button_new_with_label("Modifier à nouveau"); + gtk_widget_set_name(reedit_transcription, + "identity-corrected-transcription-reedit"); + GtkWidget *reset_transcription = + gtk_button_new_with_label("Réinitialiser depuis l’OCR brut"); + gtk_flow_box_append(GTK_FLOW_BOX(transcription_actions), + edit_transcription); + gtk_flow_box_append(GTK_FLOW_BOX(transcription_actions), + GTK_WIDGET(state->save_transcription)); + gtk_flow_box_append(GTK_FLOW_BOX(transcription_actions), + reedit_transcription); + gtk_flow_box_append(GTK_FLOW_BOX(transcription_actions), + reset_transcription); + gtk_box_append(GTK_BOX(root), transcription_actions); + g_signal_connect(edit_transcription, "clicked", + G_CALLBACK(on_edit_transcription), state); + g_signal_connect(state->save_transcription, "clicked", + G_CALLBACK(on_save_transcription), state); + g_signal_connect(reedit_transcription, "clicked", + G_CALLBACK(on_edit_transcription), state); + g_signal_connect(reset_transcription, "clicked", + G_CALLBACK(on_reset_transcription), state); + GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_name(actions, "identity-final-actions"); + gtk_widget_set_halign(actions, GTK_ALIGN_END); + GtkWidget *cancel = gtk_button_new_with_label("Annuler"); + gtk_widget_set_name(cancel, "evidence-identity-ocr-cancel"); + GtkWidget *without = gtk_button_new_with_label("Importer sans OCR"); + state->continue_with_ocr = GTK_BUTTON( + gtk_button_new_with_label("Continuer l’import avec OCR")); + gtk_widget_set_name(GTK_WIDGET(state->continue_with_ocr), + "evidence-identity-continue-with-ocr"); + gtk_widget_set_sensitive(GTK_WIDGET(state->continue_with_ocr), FALSE); + gtk_box_append(GTK_BOX(actions), cancel); + gtk_box_append(GTK_BOX(actions), without); + gtk_box_append(GTK_BOX(actions), + GTK_WIDGET(state->continue_with_ocr)); + GtkWidget *left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(left, "identity-left-panel"); + gtk_widget_set_size_request(left, 520, -1); + GtkWidget *form_scroll = gtk_scrolled_window_new(); + gtk_widget_set_name(form_scroll, "identity-form-scroll"); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(form_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_propagate_natural_height( + GTK_SCROLLED_WINDOW(form_scroll), FALSE); + gtk_widget_set_vexpand(form_scroll, TRUE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(form_scroll), root); + gtk_box_append(GTK_BOX(left), form_scroll); + gtk_widget_set_margin_start(actions, 14); + gtk_widget_set_margin_end(actions, 14); + gtk_widget_set_margin_bottom(actions, 10); + gtk_box_append(GTK_BOX(left), actions); + GtkWidget *paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_name(paned, "evidence-identity-ocr-paned"); + gtk_paned_set_start_child(GTK_PANED(paned), left); + GtkWidget *overlay = + ocr_provenance_overlay_get_widget(state->overlay); + gtk_widget_set_size_request(overlay, 180, -1); + gtk_widget_set_hexpand(overlay, TRUE); + gtk_paned_set_end_child(GTK_PANED(paned), overlay); + 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); + g_object_set_data_full(G_OBJECT(state->window), + "evidence-identity-ocr-state", state, + evidence_identity_ocr_dialog_state_free); + g_signal_connect(state->window, "close-request", + G_CALLBACK(on_close), state); + g_signal_connect(cancel, "clicked", G_CALLBACK(on_cancel), state); + g_signal_connect(without, "clicked", G_CALLBACK(on_without_ocr), state); + g_signal_connect(state->continue_with_ocr, "clicked", + G_CALLBACK(on_continue_with_ocr), state); + g_signal_connect(state->start, "clicked", G_CALLBACK(on_start), state); + gtk_widget_set_sensitive(GTK_WIDGET(state->start), + 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); + return TRUE; + +failure_window: + /* + * FALSE signifie que l'appelant conserve user_data. La fenêtre possède + * déjà l'état, mais son destructeur ne doit donc pas libérer ce contexte. + */ + state->user_data_destroy = NULL; + gtk_window_destroy(state->window); + return FALSE; +failure: + evidence_identity_ocr_dialog_state_free(state); + return FALSE; +} + +gboolean evidence_identity_ocr_dialog_present_review( + GtkWindow *parent,const char *file_path,const GPtrArray *persons, + const char *preselected_person_identifier, + const IdentityOcrRun *persisted_run, + EvidenceIdentityOcrDialogSessionCheck session_check, + EvidenceIdentityOcrDialogCallback callback, + gpointer user_data,GDestroyNotify user_data_destroy,GError **error) +{ + if (persisted_run == NULL) { + g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT, + "L’analyse OCR à réviser est absente."); + return FALSE; + } + if (!evidence_identity_ocr_dialog_present(parent,file_path,persons, + preselected_person_identifier,NULL,session_check,callback, + user_data,user_data_destroy,error)) return FALSE; + GListModel *windows=gtk_window_get_toplevels(); + EvidenceIdentityOcrDialogState *state=NULL; + for(guint i=0;irun); + state->run=identity_ocr_run_copy(persisted_run); + g_free(state->temporary_identifier); + state->temporary_identifier=g_strdup( + identity_ocr_run_get_evidence_id(persisted_run)); + gtk_window_set_title(state->window,"Réviser l’analyse OCR"); + state->review_mode=TRUE; + gtk_widget_set_visible(GTK_WIDGET(state->start),FALSE); + gtk_widget_set_visible(GTK_WIDGET(state->type),FALSE); + gtk_widget_set_visible(GTK_WIDGET(state->side),FALSE); + gtk_widget_set_visible(GTK_WIDGET(state->page),FALSE); + gtk_widget_set_visible(GTK_WIDGET(state->languages),FALSE); + gtk_widget_set_visible(GTK_WIDGET(state->profile),FALSE); + gtk_button_set_label(state->continue_with_ocr, + "Enregistrer la révision"); + gtk_widget_set_sensitive(GTK_WIDGET(state->continue_with_ocr),TRUE); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->raw_text), + identity_ocr_run_get_raw_text(state->run),-1); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->corrected_text), + identity_ocr_run_get_corrected_transcription(state->run)!=NULL + ?identity_ocr_run_get_corrected_transcription(state->run) + :identity_ocr_run_get_raw_text(state->run),-1); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(state->notes), + identity_ocr_run_get_factual_notes(state->run)!=NULL + ?identity_ocr_run_get_factual_notes(state->run):"",-1); + render_fields(state); + gtk_label_set_text(state->status, + "Révision locale des données persistées — Tesseract n’est pas lancé."); + return TRUE; +} + +void evidence_identity_ocr_dialog_result_free( + EvidenceIdentityOcrDialogResult *result) +{ + if (result == NULL) return; + g_clear_object(&result->dialog); + g_free(result->person_identifier); + g_free(result->temporary_identifier); + g_free(result->sha256); + g_free(result->mime_type); + identity_ocr_run_free(result->run); + g_free(result); +} +gboolean evidence_identity_ocr_dialog_result_has_ocr( + const EvidenceIdentityOcrDialogResult *result) +{ return result != NULL && result->run != NULL; } +const char *evidence_identity_ocr_dialog_result_get_person_identifier( + const EvidenceIdentityOcrDialogResult *result) +{ return result != NULL ? result->person_identifier : NULL; } +const char *evidence_identity_ocr_dialog_result_get_temporary_identifier( + const EvidenceIdentityOcrDialogResult *result) +{ return result != NULL ? result->temporary_identifier : NULL; } +const char *evidence_identity_ocr_dialog_result_get_sha256( + const EvidenceIdentityOcrDialogResult *result) +{ return result != NULL ? result->sha256 : NULL; } +guint64 evidence_identity_ocr_dialog_result_get_size( + const EvidenceIdentityOcrDialogResult *result) +{ return result != NULL ? result->size : 0; } +const char *evidence_identity_ocr_dialog_result_get_mime_type( + const EvidenceIdentityOcrDialogResult *result) +{ return result != NULL ? result->mime_type : NULL; } +IdentityOcrRun *evidence_identity_ocr_dialog_result_steal_run( + EvidenceIdentityOcrDialogResult *result) +{ + IdentityOcrRun *run = result != NULL ? result->run : NULL; + if (result != NULL) result->run = NULL; + return run; +} + +GtkWindow *evidence_identity_ocr_dialog_result_get_dialog( + const EvidenceIdentityOcrDialogResult *result) +{ + return result != NULL ? result->dialog : NULL; +} + +void evidence_identity_ocr_dialog_finish_import( + GtkWindow *dialog, const GError *error) +{ + EvidenceIdentityOcrDialogState *state = dialog != NULL + ? g_object_get_data(G_OBJECT(dialog), + "evidence-identity-ocr-state") : NULL; + if (state == NULL || state->completed) return; + if (error != NULL) { + state->submission_pending = FALSE; + gtk_widget_set_sensitive( + GTK_WIDGET(state->continue_with_ocr), TRUE); + char *message = g_strdup_printf(state->review_mode + ? "La révision OCR a échoué : %s" + : "L’import OCR a échoué : %s", error->message); + gtk_label_set_text(state->status, message); + g_free(message); + return; + } + state->completed = TRUE; + gtk_label_set_text(state->status, state->review_mode + ? "Révision OCR enregistrée." + : "Import OCR terminé."); + if (state->submission_task != NULL) { + background_task_unref(state->submission_task); + state->submission_task = NULL; + } + gtk_window_close(state->window); +} + +void evidence_identity_ocr_dialog_set_submission_task( + GtkWindow *dialog, BackgroundTask *task) +{ + EvidenceIdentityOcrDialogState *state = dialog != NULL + ? g_object_get_data(G_OBJECT(dialog), + "evidence-identity-ocr-state") : NULL; + if (state == NULL || state->completed) return; + if (state->submission_task != NULL) + background_task_unref(state->submission_task); + state->submission_task = background_task_ref(task); +} diff --git a/src/views/evidence_import_dialog.c b/src/views/evidence_import_dialog.c index 183bc6b..8ed5cfa 100644 --- a/src/views/evidence_import_dialog.c +++ b/src/views/evidence_import_dialog.c @@ -5,6 +5,9 @@ #include "views/evidence_import_dialog.h" #include "models/evidence_type.h" +#include "core/file_hash.h" +#include "core/task_manager.h" +#include "widgets/evidence_preview_widget.h" #include @@ -33,6 +36,8 @@ typedef struct GtkEntry *source_entry; GtkTextView *description_text_view; GtkLabel *error_label; + TaskManager *preview_task_manager; + EvidencePreviewWidget *preview; char *file_path; GPtrArray *type_codes; @@ -391,6 +396,8 @@ static void evidence_import_dialog_state_free( g_ptr_array_unref( state->type_codes ); + g_clear_pointer(&state->preview, evidence_preview_widget_free); + g_clear_pointer(&state->preview_task_manager, task_manager_free); g_free( state @@ -752,6 +759,7 @@ gboolean evidence_import_dialog_present( GtkWidget *button_box = NULL; GtkWidget *cancel_button = NULL; GtkWidget *import_button = NULL; + GtkWidget *paned = NULL; GDateTime *current_date_time = NULL; char *current_date_time_iso8601 = NULL; @@ -971,6 +979,13 @@ gboolean evidence_import_dialog_present( parent ); + gtk_window_set_application( + state->window, + gtk_window_get_application( + parent + ) + ); + gtk_window_set_modal( state->window, TRUE @@ -983,8 +998,8 @@ gboolean evidence_import_dialog_present( gtk_window_set_default_size( state->window, - 620, - 520 + 1200, + 800 ); root_box = @@ -1396,10 +1411,24 @@ gboolean evidence_import_dialog_present( button_box ); - gtk_window_set_child( - state->window, - root_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); + state->preview_task_manager = task_manager_new(); + state->preview = evidence_preview_widget_new( + state->preview_task_manager, NULL, NULL); + if (state->preview == NULL) { + gtk_window_destroy(state->window); + evidence_import_dialog_state_free(state); + return FALSE; + } + gtk_paned_set_end_child(GTK_PANED(paned), + evidence_preview_widget_get_widget(state->preview)); + gtk_paned_set_position(GTK_PANED(paned), 420); + 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); g_object_set_data_full( G_OBJECT( @@ -1419,6 +1448,35 @@ gboolean evidence_import_dialog_present( state ); + { + char *directory = g_path_get_dirname(state->file_path); + char *basename = g_path_get_basename(state->file_path); + char *sha256 = NULL; + guint64 size = 0U; + gboolean uncertain = FALSE; + char *content_type = g_content_type_guess( + state->file_path, NULL, 0U, &uncertain); + char *mime = content_type != NULL + ? g_content_type_get_mime_type(content_type) : NULL; + if (file_hash_compute_sha256( + state->file_path, NULL, &sha256, &size, NULL)) { + EvidencePreviewRequest *request = evidence_preview_request_new( + directory, "00000000-0000-4000-8000-000000000001", + basename, sha256, + mime != NULL ? mime : "application/octet-stream", 1U); + evidence_preview_widget_show( + state->preview, request, "Aperçu SPECIMEN contrôlé"); + evidence_preview_request_free(request); + } + g_free(mime); + g_free(content_type); + g_free(sha256); + g_free(basename); + g_free(directory); + (void) size; + (void) uncertain; + } + g_signal_connect( cancel_button, "clicked", diff --git a/src/views/evidence_metadata_dialog.c b/src/views/evidence_metadata_dialog.c index 4106ffb..ec1694e 100644 --- a/src/views/evidence_metadata_dialog.c +++ b/src/views/evidence_metadata_dialog.c @@ -6,6 +6,9 @@ #include "views/evidence_metadata_dialog.h" #include "models/evidence_type.h" +#include "dao/identity_ocr_dao.h" +#include "views/evidence_identity_ocr_dialog.h" +#include "widgets/ocr_provenance_overlay.h" struct EvidenceMetadataDialogResult { @@ -23,10 +26,45 @@ typedef struct GtkStringList *type_labels; GPtrArray *type_identifiers; EvidenceMetadataDialogCallback callback; + EvidenceMetadataDialogAnalyzeCallback analyze_callback; + char *evidence_identifier; + char *investigation_root; + GPtrArray *ocr_runs; + GPtrArray *ocr_documents; + GPtrArray *ocr_run_identifiers; + GtkStringList *ocr_run_labels; + GtkDropDown *ocr_run_dropdown; + GtkLabel *ocr_summary; + GtkTextBuffer *ocr_raw_buffer; + GtkTextBuffer *ocr_corrected_buffer; + GtkBox *ocr_details; + GtkButton *ocr_revise_button; + OcrProvenanceOverlay *ocr_overlay; + gulong ocr_selection_handler; + guint64 ocr_generation; gpointer user_data; gboolean completed; } EvidenceMetadataDialogContext; +static void evidence_metadata_dialog_complete( + EvidenceMetadataDialogContext *context, + EvidenceMetadataDialogResult *result +); + +/** @brief Ferme le dialogue avec Échap sans enregistrer. */ +static gboolean evidence_metadata_dialog_on_key_pressed( + GtkEventControllerKey *controller, guint keyval, guint keycode, + GdkModifierType state, gpointer user_data +) +{ + (void) controller; + (void) keycode; + (void) state; + if (keyval != GDK_KEY_Escape) return FALSE; + evidence_metadata_dialog_complete(user_data, NULL); + return TRUE; +} + /** @brief Copie un texte facultatif et transforme une chaîne vide en NULL. */ static char *evidence_metadata_dialog_copy_optional(const char *text) { @@ -43,9 +81,39 @@ static void evidence_metadata_dialog_context_free(gpointer data) if (context == NULL) return; g_clear_pointer(&context->type_identifiers, g_ptr_array_unref); g_clear_object(&context->type_labels); + g_clear_pointer(&context->ocr_runs, g_ptr_array_unref); + g_clear_pointer(&context->ocr_documents, g_ptr_array_unref); + g_clear_pointer(&context->ocr_run_identifiers, g_ptr_array_unref); + g_clear_object(&context->ocr_run_labels); + g_free(context->investigation_root); + g_free(context->evidence_identifier); g_free(context); } +static void evidence_metadata_dialog_on_analyze( + GtkButton *button, gpointer user_data) +{ + EvidenceMetadataDialogContext *context = user_data; + (void) button; + if (context != NULL && context->analyze_callback != NULL) { + gboolean revise=GPOINTER_TO_INT(g_object_get_data( + G_OBJECT(button),"revise-existing")); + const char *run_identifier = NULL; + if (revise && context->ocr_run_dropdown != NULL) { + guint selected = gtk_drop_down_get_selected( + context->ocr_run_dropdown); + if (selected < context->ocr_run_identifiers->len) + run_identifier = g_ptr_array_index( + context->ocr_run_identifiers, selected); + } + if (!revise || run_identifier != NULL) + context->analyze_callback(context->window, + context->evidence_identifier, revise, run_identifier, + context->user_data); + evidence_metadata_dialog_complete(context, NULL); + } +} + /** @brief Détruit la fenêtre hors de la pile du signal GTK courant. */ static gboolean evidence_metadata_dialog_destroy_idle(gpointer data) { @@ -61,15 +129,19 @@ static void evidence_metadata_dialog_complete( EvidenceMetadataDialogResult *result ) { + EvidenceMetadataDialogCallback callback = NULL; + gpointer callback_data = NULL; if (context == NULL || context->completed) return; context->completed = TRUE; - if (context->callback != NULL) context->callback(result, context->user_data); - else evidence_metadata_dialog_result_free(result); + callback = context->callback; + callback_data = context->user_data; gtk_widget_set_visible(GTK_WIDGET(context->window), FALSE); g_idle_add( evidence_metadata_dialog_destroy_idle, g_object_ref(context->window) ); + if (callback != NULL) callback(result, callback_data); + else evidence_metadata_dialog_result_free(result); } /** @brief Annule la modification. */ @@ -117,10 +189,281 @@ static void evidence_metadata_dialog_on_save( evidence_metadata_dialog_complete(context, result); } -gboolean evidence_metadata_dialog_present( +static void evidence_metadata_dialog_clear_box(GtkBox *box) +{ + GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(box)); + while (child != NULL) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(box, child); + child = next; + } +} + +static IdentityFieldObservation *evidence_metadata_dialog_field_model( + const IdentityFieldObservationRecord *record) +{ + IdentitySourceBox box = { + .page = (gint) record->page_number, + .x = (gint) record->source_x, + .y = (gint) record->source_y, + .width = (gint) record->source_width, + .height = (gint) record->source_height, + .image_width = (gint) record->source_image_width, + .image_height = (gint) record->source_image_height, + .available = record->has_source_box + }; + IdentityFieldObservation *field = record->raw_value != NULL + ? identity_field_observation_new(record->field_code, + record->raw_value, + record->has_confidence ? record->confidence : -1.0, + &box, (guint) record->display_order) + : identity_field_observation_new_manual(record->field_code, + record->corrected_value, (guint) record->display_order); + if (field != NULL) + identity_field_observation_set_origin(field, record->origin); + return field; +} + +static void evidence_metadata_dialog_show_region( + GtkButton *button, gpointer user_data) +{ + EvidenceMetadataDialogContext *context = user_data; + IdentityFieldObservationRecord *record = g_object_get_data( + G_OBJECT(button), "ocr-field-record"); + IdentityFieldObservation *field = + evidence_metadata_dialog_field_model(record); + context->ocr_generation++; + ocr_provenance_overlay_set_field( + context->ocr_overlay, field, context->ocr_generation); + identity_field_observation_free(field); +} + +static void evidence_metadata_dialog_render_ocr( + EvidenceMetadataDialogContext *context) +{ + guint selected = gtk_drop_down_get_selected(context->ocr_run_dropdown); + evidence_metadata_dialog_clear_box(context->ocr_details); + context->ocr_generation++; + ocr_provenance_overlay_clear( + context->ocr_overlay, context->ocr_generation); + gtk_text_buffer_set_text(context->ocr_raw_buffer, "", -1); + gtk_text_buffer_set_text(context->ocr_corrected_buffer, "", -1); + gtk_label_set_text(context->ocr_summary, + "Aucune analyse OCR sélectionnée."); + if (context->ocr_revise_button != NULL) + gtk_widget_set_sensitive( + GTK_WIDGET(context->ocr_revise_button), FALSE); + if (selected >= context->ocr_runs->len || + selected >= context->ocr_run_identifiers->len) return; + + IdentityOcrRunRecord *run = g_ptr_array_index( + context->ocr_runs, selected); + const char *selected_identifier = g_ptr_array_index( + context->ocr_run_identifiers, selected); + if (g_strcmp0(run->id, selected_identifier) != 0) return; + char *summary = g_strdup_printf( + "Run actif : %s\nUTC : %s\nMoteur : %s %s\n" + "Langues : %s\nDocument : %s — %s — page %" G_GINT64_FORMAT + "\nStatut : %s\nSHA-256 preuve : %s\n" + "Artefact texte : %s\nSHA-256 texte : %s\n" + "Artefact TSV : %s\nSHA-256 TSV : %s", + run->id, run->executed_at, run->engine, run->engine_version, + run->requested_languages, run->document_type, run->document_side, + run->page_number, run->status, run->expected_sha256, + run->text_relative_path, run->text_sha256, + run->tsv_relative_path, run->tsv_sha256); + gtk_label_set_text(context->ocr_summary, summary); + g_free(summary); + char *raw_path = g_build_filename(context->investigation_root, + run->text_relative_path, NULL); + char *raw = NULL; + if (!g_file_get_contents(raw_path, &raw, NULL, NULL)) + raw = g_strdup("Artefact OCR brut indisponible"); + gtk_text_buffer_set_text(context->ocr_raw_buffer, raw, -1); + gtk_text_buffer_set_text(context->ocr_corrected_buffer, + run->corrected_transcription != NULL + ? run->corrected_transcription + : "Aucune transcription corrigée", -1); + g_free(raw_path); + g_free(raw); + + IdentityDocumentObservationRecord *document = NULL; + for (guint index = 0; index < context->ocr_documents->len; index++) { + IdentityDocumentObservationRecord *candidate = + g_ptr_array_index(context->ocr_documents, index); + if (g_strcmp0(candidate->ocr_run_id, run->id) == 0) { + document = candidate; + break; + } + } + if (document == NULL) { + gtk_box_append(context->ocr_details, + gtk_label_new("Observation OCR persistée indisponible.")); + return; + } + char *description = g_strdup_printf( + "Observation %s\nPersonne liée : %s\nDocument : %s — %s — " + "page %" G_GINT64_FORMAT "\nÉtat : %s\nNotes : %s", + document->id, + document->person_id != NULL ? document->person_id + : "Personne indisponible", + document->document_type, document->document_side, + document->page_number, document->review_state, + document->factual_notes != NULL ? document->factual_notes + : "Aucune note"); + GtkWidget *document_label = gtk_label_new(description); + gtk_widget_set_name(document_label, "evidence-ocr-observation"); + gtk_label_set_wrap(GTK_LABEL(document_label), TRUE); + gtk_label_set_xalign(GTK_LABEL(document_label), 0.0f); + gtk_box_append(context->ocr_details, document_label); + g_free(description); + + IdentityOcrDao *dao = g_object_get_data( + G_OBJECT(context->window), "identity-ocr-dao"); + GError *error = NULL; + GPtrArray *fields = identity_ocr_dao_list_fields_by_document( + dao, document->id, &error); + for (guint index = 0; fields != NULL && index < fields->len; index++) { + IdentityFieldObservationRecord *field = + g_ptr_array_index(fields, index); + char *field_text = g_strdup_printf( + "%s — brut : %s — corrigé : %s — statut : %s — " + "origine : %s — page %" G_GINT64_FORMAT, + field->field_code, + field->raw_value != NULL ? field->raw_value : "absent", + field->corrected_value != NULL + ? field->corrected_value : "absente", + field->review_status, field->origin, field->page_number); + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget *label = gtk_label_new(field_text); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_widget_set_hexpand(label, TRUE); + gtk_box_append(GTK_BOX(row), label); + GtkWidget *show = gtk_button_new_with_label("Voir la zone"); + gtk_widget_set_sensitive(show, field->has_source_box); + g_object_set_data(G_OBJECT(show), "ocr-field-record", field); + g_signal_connect(show, "clicked", + G_CALLBACK(evidence_metadata_dialog_show_region), context); + gtk_box_append(GTK_BOX(row), show); + gtk_box_append(context->ocr_details, row); + g_free(field_text); + } + g_object_set_data_full(G_OBJECT(context->ocr_details), + "active-ocr-fields", fields, fields != NULL + ? (GDestroyNotify) g_ptr_array_unref : NULL); + g_clear_error(&error); + if (context->ocr_revise_button != NULL) + gtk_widget_set_sensitive( + GTK_WIDGET(context->ocr_revise_button), TRUE); +} + +static void evidence_metadata_dialog_ocr_selected( + GObject *object, GParamSpec *spec, gpointer user_data) +{ + (void) object; + (void) spec; + evidence_metadata_dialog_render_ocr(user_data); +} + +static GtkWidget *evidence_metadata_dialog_build_ocr( + EvidenceMetadataDialogContext *context, Database *database, + const char *root, const EvidenceRecord *record) +{ + if (database == NULL || root == NULL) return NULL; + GError *error = NULL; + IdentityOcrDao *dao = identity_ocr_dao_new(database); + context->ocr_runs = identity_ocr_dao_list_runs_by_evidence( + dao, evidence_record_get_identifier(record), &error); + context->ocr_documents = identity_ocr_dao_list_documents_by_evidence( + dao, evidence_record_get_identifier(record), &error); + if (context->ocr_runs == NULL || context->ocr_runs->len == 0 || + context->ocr_documents == NULL) { + g_clear_error(&error); + identity_ocr_dao_free(dao); + return NULL; + } + g_object_set_data_full(G_OBJECT(context->window), "identity-ocr-dao", + dao, (GDestroyNotify) identity_ocr_dao_free); + context->investigation_root = g_strdup(root); + context->ocr_run_identifiers = + g_ptr_array_new_with_free_func(g_free); + context->ocr_run_labels = gtk_string_list_new(NULL); + for (guint index = 0; index < context->ocr_runs->len; index++) { + IdentityOcrRunRecord *run = + g_ptr_array_index(context->ocr_runs, index); + char *short_id = g_strndup(run->id, 8); + char *label = g_strdup_printf( + "%s — %s — %s — %s/%s — p.%" G_GINT64_FORMAT + " — %s — %s", + run->executed_at, run->engine, run->requested_languages, + run->document_type, run->document_side, run->page_number, + run->status, short_id); + gtk_string_list_append(context->ocr_run_labels, label); + g_ptr_array_add(context->ocr_run_identifiers, g_strdup(run->id)); + g_free(short_id); + g_free(label); + } + GtkWidget *section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(section, "evidence-ocr-readonly"); + gtk_box_append(GTK_BOX(section), gtk_label_new( + "Analyse OCR d’identité")); + context->ocr_run_dropdown = GTK_DROP_DOWN(gtk_drop_down_new( + G_LIST_MODEL(g_object_ref(context->ocr_run_labels)), NULL)); + gtk_widget_set_name(GTK_WIDGET(context->ocr_run_dropdown), + "evidence-ocr-run-selector"); + gtk_box_append(GTK_BOX(section), + GTK_WIDGET(context->ocr_run_dropdown)); + context->ocr_summary = GTK_LABEL(gtk_label_new(NULL)); + gtk_widget_set_name(GTK_WIDGET(context->ocr_summary), + "evidence-ocr-active-run"); + gtk_label_set_wrap(context->ocr_summary, TRUE); + gtk_label_set_xalign(context->ocr_summary, 0.0f); + gtk_box_append(GTK_BOX(section), GTK_WIDGET(context->ocr_summary)); + GtkWidget *raw = gtk_text_view_new(); + gtk_widget_set_name(raw, "evidence-ocr-raw"); + gtk_text_view_set_editable(GTK_TEXT_VIEW(raw), FALSE); + context->ocr_raw_buffer = + gtk_text_view_get_buffer(GTK_TEXT_VIEW(raw)); + gtk_box_append(GTK_BOX(section), gtk_label_new("Texte OCR brut")); + gtk_box_append(GTK_BOX(section), raw); + GtkWidget *corrected = gtk_text_view_new(); + gtk_widget_set_name(corrected, "evidence-ocr-corrected"); + gtk_text_view_set_editable(GTK_TEXT_VIEW(corrected), FALSE); + context->ocr_corrected_buffer = + gtk_text_view_get_buffer(GTK_TEXT_VIEW(corrected)); + gtk_box_append(GTK_BOX(section), + gtk_label_new("Transcription corrigée")); + gtk_box_append(GTK_BOX(section), corrected); + context->ocr_details = GTK_BOX(gtk_box_new( + GTK_ORIENTATION_VERTICAL, 6)); + gtk_widget_set_name(GTK_WIDGET(context->ocr_details), + "evidence-ocr-details"); + gtk_box_append(GTK_BOX(section), GTK_WIDGET(context->ocr_details)); + context->ocr_overlay = ocr_provenance_overlay_new(); + g_object_set_data_full(G_OBJECT(section), "ocr-readonly-overlay", + context->ocr_overlay, (GDestroyNotify) ocr_provenance_overlay_free); + gtk_box_append(GTK_BOX(section), + ocr_provenance_overlay_get_widget(context->ocr_overlay)); + context->ocr_selection_handler = g_signal_connect( + context->ocr_run_dropdown, "notify::selected", + G_CALLBACK(evidence_metadata_dialog_ocr_selected), context); + /* + * Politique d'ouverture et de rafraîchissement : le dernier run inséré + * devient actif. La révision, elle, lit toujours l'UUID de la sélection + * courante et ne déduit jamais un run de cette politique. + */ + gtk_drop_down_set_selected(context->ocr_run_dropdown, + context->ocr_runs->len - 1); + return section; +} + +gboolean evidence_metadata_dialog_present_with_ocr( GtkWindow *parent, const EvidenceRecord *record, - const GPtrArray *evidence_types, EvidenceMetadataDialogCallback callback, - gpointer user_data + const GPtrArray *evidence_types, Database *database, + const char *investigation_root, + EvidenceMetadataDialogAnalyzeCallback analyze_callback, + EvidenceMetadataDialogCallback callback, gpointer user_data ) { EvidenceMetadataDialogContext *context = NULL; @@ -133,6 +476,9 @@ gboolean evidence_metadata_dialog_present( context = g_new0(EvidenceMetadataDialogContext, 1); context->window = GTK_WINDOW(gtk_window_new()); context->callback = callback; context->user_data = user_data; + context->analyze_callback = analyze_callback; + context->evidence_identifier = + g_strdup(evidence_record_get_identifier(record)); context->type_identifiers = g_ptr_array_new_with_free_func(g_free); labels = gtk_string_list_new(NULL); context->type_labels = labels; @@ -149,7 +495,11 @@ gboolean evidence_metadata_dialog_present( 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); - if (parent != NULL) gtk_window_set_transient_for(context->window, parent); + 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); main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); @@ -158,7 +508,7 @@ gboolean evidence_metadata_dialog_present( gtk_box_append(GTK_BOX(main_box), gtk_label_new( "Le fichier, son UUID, sa taille, sa date d'import et son SHA-256 resteront inchangés.")); context->type_dropdown = GTK_DROP_DOWN(gtk_drop_down_new( - G_LIST_MODEL(labels), NULL)); + G_LIST_MODEL(g_object_ref(labels)), NULL)); gtk_drop_down_set_selected(context->type_dropdown, selected); gtk_box_append(GTK_BOX(main_box), gtk_label_new("Type de preuve")); gtk_box_append(GTK_BOX(main_box), GTK_WIDGET(context->type_dropdown)); @@ -179,15 +529,51 @@ gboolean evidence_metadata_dialog_present( gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(description_scroll), description_view); gtk_box_append(GTK_BOX(main_box), gtk_label_new("Description")); gtk_box_append(GTK_BOX(main_box), description_scroll); + GtkWidget *ocr = evidence_metadata_dialog_build_ocr( + context, database, investigation_root, record); + if (ocr != NULL) { + GtkWidget *ocr_scroll = gtk_scrolled_window_new(); + gtk_widget_set_size_request(ocr_scroll, -1, 260); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(ocr_scroll), ocr); + gtk_box_append(GTK_BOX(main_box), ocr_scroll); + } buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_halign(buttons, GTK_ALIGN_END); cancel = gtk_button_new_with_label("Annuler"); save = gtk_button_new_with_label("Enregistrer"); + if (analyze_callback != NULL && ocr != NULL) { + GtkWidget *revise = gtk_button_new_with_label( + "Réviser l’analyse OCR"); + context->ocr_revise_button = GTK_BUTTON(revise); + gtk_widget_set_name(revise, "revise-existing-identity-ocr"); + g_object_set_data(G_OBJECT(revise),"revise-existing", + GINT_TO_POINTER(TRUE)); + g_signal_connect(revise, "clicked", + G_CALLBACK(evidence_metadata_dialog_on_analyze), context); + gtk_box_append(GTK_BOX(buttons), revise); + evidence_metadata_dialog_render_ocr(context); + } + if (analyze_callback != NULL && + evidence_identity_ocr_dialog_file_is_compatible( + evidence_record_get_original_name(record))) { + GtkWidget *analyze = gtk_button_new_with_label( + "Relancer une nouvelle analyse OCR"); + gtk_widget_set_name(analyze, "analyze-existing-identity"); + g_signal_connect(analyze, "clicked", + G_CALLBACK(evidence_metadata_dialog_on_analyze), context); + gtk_box_append(GTK_BOX(buttons), analyze); + } gtk_widget_add_css_class(save, "suggested-action"); g_signal_connect(cancel, "clicked", G_CALLBACK(evidence_metadata_dialog_on_cancel), context); g_signal_connect(save, "clicked", G_CALLBACK(evidence_metadata_dialog_on_save), context); g_signal_connect(context->window, "close-request", G_CALLBACK(evidence_metadata_dialog_on_close_request), context); + { + GtkEventController *keys = gtk_event_controller_key_new(); + g_signal_connect(keys, "key-pressed", + G_CALLBACK(evidence_metadata_dialog_on_key_pressed), context); + gtk_widget_add_controller(GTK_WIDGET(context->window), keys); + } 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); @@ -195,6 +581,16 @@ gboolean evidence_metadata_dialog_present( return TRUE; } +gboolean evidence_metadata_dialog_present( + GtkWindow *parent, const EvidenceRecord *record, + const GPtrArray *evidence_types, EvidenceMetadataDialogCallback callback, + gpointer user_data) +{ + return evidence_metadata_dialog_present_with_ocr( + parent, record, evidence_types, NULL, NULL, NULL, + callback, user_data); +} + void evidence_metadata_dialog_result_free(EvidenceMetadataDialogResult *result) { if (result == NULL) return; diff --git a/src/views/main_window.c b/src/views/main_window.c index bf69afd..3be02fd 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -119,6 +119,8 @@ struct MainWindow gpointer extract_metadata_user_data; MainWindowRecoverPdfPasswordCallback recover_pdf_password_callback; gpointer recover_pdf_password_user_data; + MainWindowIdentityOcrCallback identity_ocr_callback; + gpointer identity_ocr_user_data; MainWindowGraphNodeMovedCallback graph_node_moved_callback; @@ -242,6 +244,15 @@ static void main_window_on_recover_pdf_password_requested( window->recover_pdf_password_callback(identifier, window->recover_pdf_password_user_data); } +static void main_window_on_identity_ocr_requested( + const char *identifier, gboolean revise_existing, + const char *run_identifier, gpointer data) +{ + MainWindow *window = data; + if (window != NULL && window->identity_ocr_callback != NULL) + window->identity_ocr_callback(identifier, revise_existing, + run_identifier, window->identity_ocr_user_data); +} /** * @brief Ouvre dans le workspace l'entité choisie dans la sidebar. @@ -1043,6 +1054,8 @@ MainWindow *main_window_new( main_window_on_extract_metadata_requested, main_window); workspace_set_recover_pdf_password_callback(main_window->workspace, main_window_on_recover_pdf_password_requested, main_window); + workspace_set_identity_ocr_callback(main_window->workspace, + main_window_on_identity_ocr_requested, main_window); workspace_widget = workspace_get_widget( main_window->workspace @@ -1628,6 +1641,14 @@ void main_window_set_recover_pdf_password_callback(MainWindow *main_window, main_window->recover_pdf_password_callback = callback; main_window->recover_pdf_password_user_data = user_data; } +void main_window_set_identity_ocr_callback( + MainWindow *main_window, MainWindowIdentityOcrCallback callback, + gpointer user_data) +{ + if (main_window == NULL) return; + main_window->identity_ocr_callback = callback; + main_window->identity_ocr_user_data = user_data; +} void main_window_set_tree_selection_callback( MainWindow *main_window, @@ -1995,6 +2016,17 @@ void main_window_set_selected_evidence( ); } +void main_window_set_identity_ocr_runs( + MainWindow *main_window, GPtrArray *owned_records) +{ + if (main_window == NULL) { + g_clear_pointer(&owned_records, g_ptr_array_unref); + return; + } + workspace_set_identity_ocr_runs( + main_window->workspace, owned_records); +} + void main_window_set_evidence_observations(MainWindow *main_window, const GPtrArray *observations) { @@ -2110,10 +2142,6 @@ void main_window_free( * Les structures qui manipulent encore leurs widgets doivent être * nettoyées avant gtk_window_destroy(). */ - sidebar_free( - main_window->sidebar - ); - if (main_window->workspace != NULL) { workspace_set_verify_evidence_callback( @@ -2180,6 +2208,10 @@ void main_window_free( ); } + sidebar_free( + main_window->sidebar + ); + workspace_free( main_window->workspace ); diff --git a/src/views/manage_entity_evidence_dialog.c b/src/views/manage_entity_evidence_dialog.c index 2dc1ff0..304e00d 100644 --- a/src/views/manage_entity_evidence_dialog.c +++ b/src/views/manage_entity_evidence_dialog.c @@ -3,40 +3,53 @@ * @brief Dialogue de sélection des preuves associées à une entité. ******************************************************************************/ #include "views/manage_entity_evidence_dialog.h" +#include "widgets/evidence_preview_widget.h" #include typedef struct { char *identifier; char *path; + char *relative_path; + char *sha256; + char *mime_type; GtkCheckButton *check; } EvidenceChoice; typedef struct { GtkWindow *window; - GtkStack *preview; - GtkPicture *picture; - GtkVideo *video; + EvidencePreviewWidget *preview; + char *root_path; GPtrArray *choices; ManageEntityEvidenceDialogCallback callback; + ManageEntityEvidenceImportCallback import_callback; gpointer callback_data; gboolean completed; } ManageEvidenceState; +/** @brief Ferme le gestionnaire puis lance un import pour cette personne. */ +static void manage_evidence_on_import(GtkButton *button, gpointer data) +{ + ManageEvidenceState *state = data; + (void) button; + if (state == NULL || state->completed) return; + state->completed = TRUE; + if (state->import_callback != NULL) + state->import_callback(state->callback_data); + gtk_window_close(state->window); +} /** @brief Libère une option privée. */ static void evidence_choice_free(gpointer data) { EvidenceChoice *choice = data; if (choice == NULL) return; - g_free(choice->identifier); g_free(choice->path); g_free(choice); + g_free(choice->identifier); g_free(choice->path); + g_free(choice->relative_path); g_free(choice->sha256); + g_free(choice->mime_type); g_free(choice); } /** @brief Arrête et efface l'aperçu courant. */ static void manage_evidence_clear_preview(ManageEvidenceState *state) { - GtkMediaStream *stream = NULL; - if (state == NULL || state->video == NULL) return; - stream = gtk_video_get_media_stream(state->video); - if (stream != NULL) gtk_media_stream_pause(stream); - gtk_video_set_media_stream(state->video, NULL); - gtk_picture_set_paintable(state->picture, NULL); + if (state == NULL || state->preview == NULL) return; + evidence_preview_widget_clear(state->preview); } /** @brief Libère l'état du dialogue. */ static void manage_evidence_state_free(gpointer data) @@ -44,35 +57,21 @@ static void manage_evidence_state_free(gpointer data) ManageEvidenceState *state = data; if (state == NULL) return; manage_evidence_clear_preview(state); - g_ptr_array_unref(state->choices); g_free(state); + g_clear_pointer(&state->preview, evidence_preview_widget_free); + g_ptr_array_unref(state->choices); g_free(state->root_path); g_free(state); } /** @brief Affiche l'aperçu de l'option manipulée. */ static void manage_evidence_on_toggled(GtkCheckButton *button, gpointer data) { ManageEvidenceState *state = data; EvidenceChoice *choice = g_object_get_data(G_OBJECT(button), "choice"); - GFile *file = NULL; GFileInfo *info = NULL; char *mime = NULL; - const char *content_type = NULL; if (state == NULL || choice == NULL || choice->path == NULL) return; - manage_evidence_clear_preview(state); - file = g_file_new_for_path(choice->path); - info = g_file_query_info(file, G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, - G_FILE_QUERY_INFO_NONE, NULL, NULL); - if (info != NULL) content_type = g_file_info_get_content_type(info); - if (content_type != NULL) mime = g_content_type_get_mime_type(content_type); - if (mime != NULL && g_str_has_prefix(mime, "image/")) - { - gtk_picture_set_filename(state->picture, choice->path); - gtk_stack_set_visible_child_name(state->preview, "image"); - } - else if (mime != NULL && g_str_has_prefix(mime, "video/")) - { - GtkMediaStream *stream = gtk_media_file_new_for_filename(choice->path); - gtk_video_set_media_stream(state->video, stream); g_object_unref(stream); - gtk_stack_set_visible_child_name(state->preview, "video"); - } - else gtk_stack_set_visible_child_name(state->preview, "unsupported"); - g_free(mime); g_clear_object(&info); g_clear_object(&file); + EvidencePreviewRequest *request = evidence_preview_request_new( + state->root_path, choice->identifier, choice->relative_path, + choice->sha256, choice->mime_type, 1U); + evidence_preview_widget_show(state->preview, request, + gtk_check_button_get_label(choice->check)); + evidence_preview_request_free(request); } /** @brief Termine le dialogue en renvoyant la sélection. */ static void manage_evidence_on_save(GtkButton *button, gpointer data) @@ -98,27 +97,31 @@ static gboolean manage_evidence_on_close(GtkWindow *window, gpointer data) } gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, const GPtrArray *records, const GPtrArray *selected_ids, - const char *root_path, ManageEntityEvidenceDialogCallback callback, - gpointer callback_data, GError **error) + const char *root_path, TaskManager *task_manager, + ManageEntityEvidenceDialogCallback callback, + gpointer callback_data, ManageEntityEvidenceImportCallback import_callback, + GError **error) { ManageEvidenceState *state = NULL; GtkWidget *root = NULL; GtkWidget *content = NULL; GtkWidget *list = NULL; GtkWidget *scroll = NULL; - GtkWidget *save = NULL; + GtkWidget *save = NULL; GtkWidget *import = NULL; GtkWidget *actions = NULL; g_return_val_if_fail(error == NULL || *error == NULL, FALSE); if (!GTK_IS_WINDOW(parent) || records == NULL || root_path == NULL || + task_manager == NULL || callback == NULL) return FALSE; state = g_new0(ManageEvidenceState, 1); state->choices = g_ptr_array_new_with_free_func(evidence_choice_free); state->callback = callback; state->callback_data = callback_data; + state->root_path = g_strdup(root_path); + 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, 900, 620); + gtk_window_set_default_size(state->window, 1200, 800); 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); - content = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); scroll = gtk_scrolled_window_new(); gtk_widget_set_size_request(scroll, 380, 480); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), list); @@ -134,6 +137,20 @@ gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, char *canonical_root = g_canonicalize_filename(root_path, NULL); char *canonical_path = g_canonicalize_filename(candidate, NULL); choice->identifier = g_strdup(evidence_record_get_identifier(record)); + choice->relative_path = g_strdup( + evidence_record_get_relative_path(record)); + choice->sha256 = g_strdup(evidence_record_get_sha256(record)); + { + gboolean uncertain = FALSE; + char *content_type = g_content_type_guess( + choice->path != NULL ? choice->path : candidate, + NULL, 0U, &uncertain); + choice->mime_type = content_type != NULL + ? g_content_type_get_mime_type(content_type) + : g_strdup("application/octet-stream"); + g_free(content_type); + (void) uncertain; + } if (canonical_root != NULL && canonical_path != NULL && g_str_has_prefix(canonical_path, canonical_root) && (canonical_path[strlen(canonical_root)] == G_DIR_SEPARATOR || @@ -150,21 +167,32 @@ gboolean manage_entity_evidence_dialog_present(GtkWindow *parent, gtk_box_append(GTK_BOX(list), GTK_WIDGET(choice->check)); g_ptr_array_add(state->choices, choice); } - state->preview = GTK_STACK(gtk_stack_new()); - state->picture = GTK_PICTURE(gtk_picture_new()); - gtk_picture_set_content_fit(state->picture, GTK_CONTENT_FIT_CONTAIN); - state->video = GTK_VIDEO(gtk_video_new()); - gtk_stack_add_named(state->preview, gtk_label_new("Sélectionnez une preuve."), "unsupported"); - gtk_stack_add_named(state->preview, GTK_WIDGET(state->picture), "image"); - gtk_stack_add_named(state->preview, GTK_WIDGET(state->video), "video"); - gtk_widget_set_hexpand(GTK_WIDGET(state->preview), TRUE); - gtk_box_append(GTK_BOX(content), scroll); gtk_box_append(GTK_BOX(content), GTK_WIDGET(state->preview)); + state->preview = evidence_preview_widget_new(task_manager, NULL, NULL); + if (state->preview == NULL) { + gtk_window_destroy(state->window); + manage_evidence_state_free(state); + return FALSE; + } + content = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_name(content, "manage-evidence-paned"); + 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); + 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"); + import = gtk_button_new_with_label("Importer une nouvelle preuve"); + actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_add_css_class(save, "suggested-action"); gtk_widget_set_halign(save, GTK_ALIGN_END); - gtk_box_append(GTK_BOX(root), content); gtk_box_append(GTK_BOX(root), save); + gtk_box_append(GTK_BOX(actions), import); + gtk_box_append(GTK_BOX(actions), save); + gtk_widget_set_halign(actions, GTK_ALIGN_END); + gtk_box_append(GTK_BOX(root), content); gtk_box_append(GTK_BOX(root), actions); gtk_window_set_child(state->window, root); g_object_set_data_full(G_OBJECT(state->window), "state", state, manage_evidence_state_free); 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; } diff --git a/src/views/metadata_analysis_dialog.c b/src/views/metadata_analysis_dialog.c index 312367e..b1933fe 100644 --- a/src/views/metadata_analysis_dialog.c +++ b/src/views/metadata_analysis_dialog.c @@ -5,6 +5,15 @@ #include "views/metadata_analysis_dialog.h" +static gboolean metadata_analysis_dialog_on_escape( + GtkWidget *widget, GVariant *arguments, gpointer user_data) +{ + (void) widget; + (void) arguments; + gtk_window_destroy(GTK_WINDOW(user_data)); + return TRUE; +} + void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary, const char *json_path, const char *version) { @@ -58,6 +67,18 @@ void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary, gtk_widget_set_halign(close_button, GTK_ALIGN_END); g_signal_connect_swapped(close_button, "clicked", G_CALLBACK(gtk_window_destroy), window); + { + GtkShortcutController *shortcuts = GTK_SHORTCUT_CONTROLLER( + gtk_shortcut_controller_new()); + gtk_shortcut_controller_set_scope( + shortcuts, GTK_SHORTCUT_SCOPE_LOCAL); + gtk_shortcut_controller_add_shortcut(shortcuts, + gtk_shortcut_new( + gtk_keyval_trigger_new(GDK_KEY_Escape, 0), + gtk_callback_action_new( + metadata_analysis_dialog_on_escape, window, NULL))); + gtk_widget_add_controller(window, GTK_EVENT_CONTROLLER(shortcuts)); + } gtk_box_append(GTK_BOX(content), close_button); gtk_window_set_child(GTK_WINDOW(window), content); gtk_window_present(GTK_WINDOW(window)); diff --git a/src/widgets/ocr_provenance_overlay.c b/src/widgets/ocr_provenance_overlay.c new file mode 100644 index 0000000..118e82a --- /dev/null +++ b/src/widgets/ocr_provenance_overlay.c @@ -0,0 +1,118 @@ +#include "widgets/ocr_provenance_overlay.h" +#include "core/ocr_region_geometry.h" + +struct OcrProvenanceOverlay { + GtkWidget *root; + GtkPicture *picture; + GtkDrawingArea *drawing; + GtkLabel *details; + GdkTexture *texture; + IdentitySourceBox box; + gboolean has_box; + guint64 generation; +}; + +static void draw_region(GtkDrawingArea *area, cairo_t *cr, int width, + int height, gpointer data) +{ + OcrProvenanceOverlay *overlay = data; + OcrDisplayRegion region; + (void) area; + if (!overlay->has_box || + !ocr_region_geometry_transform(overlay->box.image_width, + overlay->box.image_height, overlay->box.x, overlay->box.y, + overlay->box.width, overlay->box.height, width, height, + OCR_REGION_FIT_CONTAIN, ®ion)) + return; + cairo_set_source_rgba(cr, 1.0, 0.15, 0.05, 0.20); + cairo_rectangle(cr, region.x, region.y, region.width, region.height); + cairo_fill_preserve(cr); + cairo_set_source_rgba(cr, 1.0, 0.1, 0.0, 0.95); + cairo_set_line_width(cr, 3.0); + cairo_stroke(cr); +} + +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->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); + 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), GTK_WIDGET(overlay->details)); + return overlay; +} + +void ocr_provenance_overlay_free(OcrProvenanceOverlay *overlay) +{ + if (overlay == NULL) return; + g_clear_object(&overlay->texture); + g_free(overlay); +} + +GtkWidget *ocr_provenance_overlay_get_widget(OcrProvenanceOverlay *overlay) +{ + return overlay != NULL ? overlay->root : NULL; +} + +void ocr_provenance_overlay_set_image(OcrProvenanceOverlay *overlay, + GBytes *bytes, guint64 generation) +{ + if (overlay == NULL || generation < overlay->generation) return; + overlay->generation = generation; + g_clear_object(&overlay->texture); + overlay->texture = bytes != NULL ? gdk_texture_new_from_bytes(bytes, NULL) + : NULL; + gtk_picture_set_paintable(overlay->picture, + overlay->texture != NULL ? GDK_PAINTABLE(overlay->texture) : NULL); + gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); +} + +void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay, + const IdentityFieldObservation *field, guint64 generation) +{ + if (overlay == NULL || generation < overlay->generation) return; + overlay->generation = generation; + const IdentitySourceBox *box = field != NULL + ? identity_field_observation_get_box(field) : NULL; + overlay->has_box = box != NULL && box->available; + if (overlay->has_box) overlay->box = *box; + char *text = overlay->has_box + ? g_strdup_printf("%s — confiance %.1f — page %d — " + "coordonnées %d,%d %dx%d — origine %s", + identity_field_observation_get_raw_value(field), + identity_field_observation_get_confidence(field), box->page, + box->x, box->y, box->width, box->height, + identity_field_observation_get_origin(field)) + : g_strdup("Zone source indisponible"); + gtk_label_set_text(overlay->details, text); + g_free(text); + gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); +} + +void ocr_provenance_overlay_clear(OcrProvenanceOverlay *overlay, + guint64 generation) +{ + if (overlay == NULL || generation < overlay->generation) return; + overlay->generation = generation; + overlay->has_box = FALSE; + gtk_label_set_text(overlay->details, "Zone source indisponible"); + gtk_picture_set_paintable(overlay->picture, NULL); + g_clear_object(&overlay->texture); + gtk_widget_queue_draw(GTK_WIDGET(overlay->drawing)); +} + +gboolean ocr_provenance_overlay_has_region( + const OcrProvenanceOverlay *overlay) +{ + return overlay != NULL && overlay->has_box; +} diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index bfdfd49..028f5c9 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -13,6 +13,8 @@ #include "widgets/entity_details_panel.h" #include "widgets/investigation_graph_view.h" #include "widgets/evidence_preview_widget.h" +#include "widgets/ocr_provenance_overlay.h" +#include "views/evidence_identity_ocr_dialog.h" #include #include @@ -98,6 +100,20 @@ struct Workspace GtkWidget *analyze_rib_button; GtkWidget *extract_metadata_button; GtkWidget *recover_pdf_password_button; + GtkWidget *analyze_identity_button; + GtkWidget *identity_ocr_section; + GtkDropDown *identity_ocr_selector; + GtkStringList *identity_ocr_labels; + GtkLabel *identity_ocr_summary; + GtkTextBuffer *identity_ocr_raw; + GtkTextBuffer *identity_ocr_corrected; + GtkBox *identity_ocr_fields; + GtkWidget *revise_identity_button; + GtkWidget *rerun_identity_button; + OcrProvenanceOverlay *identity_ocr_overlay; + GPtrArray *identity_ocr_records; + WorkspaceIdentityOcrCallback identity_ocr_callback; + gpointer identity_ocr_user_data; EvidencePreviewWidget *evidence_preview; char *selected_evidence_identifier; @@ -1106,6 +1122,152 @@ static void workspace_on_recover_pdf_password_clicked(GtkButton *button, workspace->recover_pdf_password_user_data); } +WorkspaceIdentityOcrRecord *workspace_identity_ocr_record_new( + IdentityOcrRun *owned_run, const char *person_identifier, + const char *executed_at, const char *text_relative_path, + const char *text_sha256, const char *tsv_relative_path, + const char *tsv_sha256) +{ + if (owned_run == NULL) return NULL; + WorkspaceIdentityOcrRecord *record = + g_new0(WorkspaceIdentityOcrRecord, 1); + record->run = owned_run; + record->person_identifier = g_strdup(person_identifier); + record->executed_at = g_strdup(executed_at); + record->text_relative_path = g_strdup(text_relative_path); + record->text_sha256 = g_strdup(text_sha256); + record->tsv_relative_path = g_strdup(tsv_relative_path); + record->tsv_sha256 = g_strdup(tsv_sha256); + return record; +} + +void workspace_identity_ocr_record_free(WorkspaceIdentityOcrRecord *record) +{ + if (record == NULL) return; + identity_ocr_run_free(record->run); + g_free(record->person_identifier); + g_free(record->executed_at); + g_free(record->text_relative_path); + g_free(record->text_sha256); + g_free(record->tsv_relative_path); + g_free(record->tsv_sha256); + g_free(record); +} + +static void workspace_clear_box(GtkBox *box) +{ + GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(box)); + while (child != NULL) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(box, child); + child = next; + } +} + +static void workspace_render_identity_ocr(Workspace *workspace) +{ + guint selected = gtk_drop_down_get_selected( + workspace->identity_ocr_selector); + workspace_clear_box(workspace->identity_ocr_fields); + gtk_text_buffer_set_text(workspace->identity_ocr_raw, "", -1); + gtk_text_buffer_set_text(workspace->identity_ocr_corrected, "", -1); + ocr_provenance_overlay_clear(workspace->identity_ocr_overlay, + selected + 1U); + if (workspace->identity_ocr_records == NULL || + selected >= workspace->identity_ocr_records->len) { + gtk_label_set_text(workspace->identity_ocr_summary, + "Aucune analyse OCR sélectionnée."); + gtk_widget_set_sensitive(workspace->revise_identity_button, FALSE); + return; + } + WorkspaceIdentityOcrRecord *record = g_ptr_array_index( + workspace->identity_ocr_records, selected); + IdentityOcrRun *run = record->run; + char *summary = g_strdup_printf( + "Run actif : %s\nUTC : %s\nPersonne liée : %s\n" + "Document : %s — %s — page %u\nLangues : %s\n" + "Notes : %s\nArtefact texte : %s\nSHA-256 texte : %s\n" + "Artefact TSV : %s\nSHA-256 TSV : %s\nSHA-256 preuve : %s", + identity_ocr_run_get_identifier(run), + record->executed_at != NULL ? record->executed_at : "inconnue", + record->person_identifier != NULL + ? record->person_identifier : "indisponible", + identity_ocr_run_get_document_type(run), + identity_ocr_run_get_document_side(run), + identity_ocr_run_get_page(run), + identity_ocr_run_get_languages(run), + identity_ocr_run_get_factual_notes(run) != NULL + ? identity_ocr_run_get_factual_notes(run) : "Aucune note", + record->text_relative_path, record->text_sha256, + record->tsv_relative_path, record->tsv_sha256, + identity_ocr_run_get_expected_sha256(run)); + gtk_label_set_text(workspace->identity_ocr_summary, summary); + g_free(summary); + gtk_text_buffer_set_text(workspace->identity_ocr_raw, + identity_ocr_run_get_raw_text(run), -1); + gtk_text_buffer_set_text(workspace->identity_ocr_corrected, + identity_ocr_run_get_corrected_transcription(run) != NULL + ? identity_ocr_run_get_corrected_transcription(run) + : "Aucune transcription corrigée", -1); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + for (guint index = 0; fields != NULL && index < fields->len; index++) { + IdentityFieldObservation *field = g_ptr_array_index( + (GPtrArray *) fields, index); + char *field_text = g_strdup_printf( + "%s — brut : %s — corrigé : %s — origine : %s — statut : %d", + identity_field_observation_get_code(field), + identity_field_observation_get_raw_value(field) != NULL + ? identity_field_observation_get_raw_value(field) : "absent", + identity_field_observation_get_corrected_value(field) != NULL + ? identity_field_observation_get_corrected_value(field) + : "absente", + identity_field_observation_get_origin(field), + identity_field_observation_get_status(field)); + GtkWidget *label = gtk_label_new(field_text); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_box_append(workspace->identity_ocr_fields, label); + g_free(field_text); + if (!ocr_provenance_overlay_has_region( + workspace->identity_ocr_overlay)) + ocr_provenance_overlay_set_field( + workspace->identity_ocr_overlay, field, selected + 1U); + } + gtk_widget_set_sensitive(workspace->revise_identity_button, TRUE); +} + +static void workspace_on_identity_ocr_selected( + GObject *object, GParamSpec *pspec, gpointer data) +{ + (void) object; + (void) pspec; + workspace_render_identity_ocr(data); +} + +static void workspace_on_identity_ocr_action( + GtkButton *button, gpointer data) +{ + Workspace *workspace = data; + gboolean revise = button == + GTK_BUTTON(workspace->revise_identity_button); + const char *run_identifier = NULL; + if (revise && workspace->identity_ocr_records != NULL) { + guint selected = gtk_drop_down_get_selected( + workspace->identity_ocr_selector); + if (selected < workspace->identity_ocr_records->len) { + WorkspaceIdentityOcrRecord *record = g_ptr_array_index( + workspace->identity_ocr_records, selected); + run_identifier = + identity_ocr_run_get_identifier(record->run); + } + } + if (workspace->identity_ocr_callback != NULL && + (!revise || run_identifier != NULL)) + workspace->identity_ocr_callback( + workspace->selected_evidence_identifier, + revise, run_identifier, workspace->identity_ocr_user_data); +} + Workspace *workspace_new(TaskManager *task_manager) { GtkWidget *evidence_content = NULL; @@ -1479,6 +1641,91 @@ Workspace *workspace_new(TaskManager *task_manager) G_CALLBACK(workspace_on_recover_pdf_password_clicked), workspace); gtk_box_append(GTK_BOX(evidence_content), workspace->recover_pdf_password_button); + workspace->analyze_identity_button = gtk_button_new_with_label( + "Analyser comme document d’identité"); + gtk_widget_set_name(workspace->analyze_identity_button, + "workspace-analyze-identity"); + gtk_widget_set_halign(workspace->analyze_identity_button, + GTK_ALIGN_START); + gtk_widget_set_sensitive(workspace->analyze_identity_button, FALSE); + g_signal_connect(workspace->analyze_identity_button, "clicked", + G_CALLBACK(workspace_on_identity_ocr_action), workspace); + gtk_box_append(GTK_BOX(evidence_content), + workspace->analyze_identity_button); + + workspace->identity_ocr_section = + gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_name(workspace->identity_ocr_section, + "workspace-identity-ocr-section"); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + gtk_label_new("Analyse OCR d’identité")); + workspace->identity_ocr_labels = gtk_string_list_new(NULL); + workspace->identity_ocr_selector = GTK_DROP_DOWN( + gtk_drop_down_new(G_LIST_MODEL(g_object_ref( + workspace->identity_ocr_labels)), NULL)); + gtk_widget_set_name(GTK_WIDGET(workspace->identity_ocr_selector), + "workspace-identity-ocr-selector"); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + GTK_WIDGET(workspace->identity_ocr_selector)); + workspace->identity_ocr_summary = GTK_LABEL(gtk_label_new(NULL)); + gtk_widget_set_name(GTK_WIDGET(workspace->identity_ocr_summary), + "workspace-identity-ocr-active-run"); + gtk_label_set_wrap(workspace->identity_ocr_summary, TRUE); + gtk_label_set_xalign(workspace->identity_ocr_summary, 0.0f); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + GTK_WIDGET(workspace->identity_ocr_summary)); + GtkWidget *raw_view = gtk_text_view_new(); + gtk_widget_set_name(raw_view, "workspace-identity-ocr-raw"); + gtk_text_view_set_editable(GTK_TEXT_VIEW(raw_view), FALSE); + workspace->identity_ocr_raw = + gtk_text_view_get_buffer(GTK_TEXT_VIEW(raw_view)); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + gtk_label_new("Texte OCR brut")); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), raw_view); + GtkWidget *corrected_view = gtk_text_view_new(); + gtk_widget_set_name(corrected_view, + "workspace-identity-ocr-corrected"); + gtk_text_view_set_editable(GTK_TEXT_VIEW(corrected_view), FALSE); + workspace->identity_ocr_corrected = + gtk_text_view_get_buffer(GTK_TEXT_VIEW(corrected_view)); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + gtk_label_new("Transcription corrigée")); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + corrected_view); + workspace->identity_ocr_fields = GTK_BOX( + gtk_box_new(GTK_ORIENTATION_VERTICAL, 6)); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + GTK_WIDGET(workspace->identity_ocr_fields)); + workspace->identity_ocr_overlay = ocr_provenance_overlay_new(); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + ocr_provenance_overlay_get_widget( + workspace->identity_ocr_overlay)); + GtkWidget *identity_actions = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + workspace->revise_identity_button = + gtk_button_new_with_label("Réviser l’analyse OCR"); + workspace->rerun_identity_button = + gtk_button_new_with_label("Relancer une nouvelle analyse OCR"); + gtk_widget_set_name(workspace->revise_identity_button, + "workspace-revise-identity-ocr"); + gtk_widget_set_name(workspace->rerun_identity_button, + "workspace-rerun-identity-ocr"); + g_signal_connect(workspace->revise_identity_button, "clicked", + G_CALLBACK(workspace_on_identity_ocr_action), workspace); + g_signal_connect(workspace->rerun_identity_button, "clicked", + G_CALLBACK(workspace_on_identity_ocr_action), workspace); + gtk_box_append(GTK_BOX(identity_actions), + workspace->revise_identity_button); + gtk_box_append(GTK_BOX(identity_actions), + workspace->rerun_identity_button); + gtk_box_append(GTK_BOX(workspace->identity_ocr_section), + identity_actions); + gtk_widget_set_visible(workspace->identity_ocr_section, FALSE); + gtk_box_append(GTK_BOX(evidence_content), + workspace->identity_ocr_section); + g_signal_connect(workspace->identity_ocr_selector, + "notify::selected", + G_CALLBACK(workspace_on_identity_ocr_selected), workspace); evidence_separator = gtk_separator_new( @@ -2686,6 +2933,7 @@ void workspace_set_selected_evidence( } workspace_clear_evidence_preview(workspace); + workspace_set_identity_ocr_runs(workspace, NULL); g_clear_pointer( &workspace->selected_evidence_identifier, @@ -2747,6 +2995,10 @@ void workspace_set_selected_evidence( g_str_has_suffix(lower, ".pdf"))); gtk_widget_set_sensitive(workspace->recover_pdf_password_button, lower != NULL && g_str_has_suffix(lower, ".pdf")); + gtk_widget_set_sensitive(workspace->analyze_identity_button, + evidence_identity_ocr_dialog_file_is_compatible(name) || + evidence_identity_ocr_dialog_file_is_compatible( + evidence_record_get_internal_name(evidence_record))); g_free(lower); } @@ -2889,6 +3141,56 @@ void workspace_set_eml_analysis_available( workspace->analyze_eml_button, available); } +void workspace_set_identity_ocr_runs( + Workspace *workspace, GPtrArray *owned_records) +{ + if (workspace == NULL) { + g_clear_pointer(&owned_records, g_ptr_array_unref); + return; + } + g_clear_pointer(&workspace->identity_ocr_records, g_ptr_array_unref); + workspace->identity_ocr_records = owned_records; + guint old_count = g_list_model_get_n_items( + G_LIST_MODEL(workspace->identity_ocr_labels)); + gtk_string_list_splice(workspace->identity_ocr_labels, + 0, old_count, NULL); + for (guint index = 0; + owned_records != NULL && index < owned_records->len; index++) { + WorkspaceIdentityOcrRecord *record = + g_ptr_array_index(owned_records, index); + char *short_id = g_strndup( + identity_ocr_run_get_identifier(record->run), 8); + char *label = g_strdup_printf( + "%s — %s/%s — page %u — %s", + record->executed_at != NULL ? record->executed_at : "UTC inconnue", + identity_ocr_run_get_document_type(record->run), + identity_ocr_run_get_document_side(record->run), + identity_ocr_run_get_page(record->run), short_id); + gtk_string_list_append(workspace->identity_ocr_labels, label); + g_free(label); + g_free(short_id); + } + gboolean available = + owned_records != NULL && owned_records->len > 0; + gtk_widget_set_visible(workspace->identity_ocr_section, available); + gtk_widget_set_sensitive(workspace->revise_identity_button, available); + gtk_widget_set_sensitive(workspace->rerun_identity_button, available); + if (available) + gtk_drop_down_set_selected(workspace->identity_ocr_selector, + owned_records->len - 1); + else + workspace_render_identity_ocr(workspace); +} + +void workspace_set_identity_ocr_callback( + Workspace *workspace, WorkspaceIdentityOcrCallback callback, + gpointer user_data) +{ + if (workspace == NULL) return; + workspace->identity_ocr_callback = callback; + workspace->identity_ocr_user_data = user_data; +} + void workspace_set_evidence_preview(Workspace *workspace, const char *file_path, const char *display_name) { @@ -3758,6 +4060,10 @@ void workspace_free(Workspace *workspace) &workspace->selected_evidence_identifier, g_free ); + g_clear_pointer(&workspace->identity_ocr_records, g_ptr_array_unref); + g_clear_object(&workspace->identity_ocr_labels); + g_clear_pointer(&workspace->identity_ocr_overlay, + ocr_provenance_overlay_free); g_free(workspace); } diff --git a/tests/fake_document_tool b/tests/fake_document_tool new file mode 100755 index 0000000000000000000000000000000000000000..4bccf56c0ff944c95269ec03020869c83c63c6ad GIT binary patch literal 16888 zcmeHPe{@^beZR6XSRq6X#Ed{7s!1SWv0^)MaGaAO$CmS49LG%@3a6znvh{3>$dV>K zB{n~XSH~_{cM328SW?JP+F zQMr_=KdDdXw@db?T`nRcBqfPUi6s3ht|e`{^m3HOTqJvaozl)O-IA}Qs&DgqX5ja! zuT$E!EEbNZNm*Zul&ZciX}9YdQ7}zP{w^t1`@Rl48i)UBQqyXatgqO(*U0f!QVt^@ zgL$ZohHrhx7WURVdrlvC_O-XxMVscm{L4r0_~L2LndPBi_o|i4LxJj0Fq{~y9&A`u zy=tW=9`V#Ma!dB)kH<|JM@0!?mG~{aa`%GOAE(se1SCHtpkAC0acx5pd>pu&GxGmX zKNRwFXAzuYE5xVp3*jS0@cWD4{|4NRpFPz9DCD1}NgynvyMFB`@Do^aTX6pW5KZ5%i)N2B8lx{5H;q`=hUQQtY;^g%Lk24D8;FEuM7p3BBr+U9 zbnKPj-+BBm9Z{r-fBAVj&Pv%HFw}8BFBrUt&ZLh??CJX-!eLm-o|Jmx@*u8ECF~!e zQ&QIhH9l(aPssDV!Vk#n@SvBQS>vhnF`5WSs0`k-=~xa0Y7>=B z6%LC7?t*UMUHSfwyoq9Il5fZv8__MSL6s+=WIeMy!4kb!ufTZYm8my z6&b>*%__OQB1bs=R9fN{Il@$*5>n%(zk(}o8Mxly<%0XTP-OXEZw@YR6*#Wt;Fthb z>de9CSSVvR=isg!d}|Iahn$!8=iry*@Mp3!JuuS)Gd(cV12aAFzv+QbTvvUrjhrgi zQe{V$Gp3D>nI)Oy+Q^T~=?2xWds^R)=q|+sQ%FXp|(&^GYRRNv)qxQqLlk@9KNGv_4B~NQdKDt3Wa;8*sysW)(&YTAaYo&wo%tWtiVN2GY z;u~K3A&OaI#TIR3?d6o!lJA)pX{oh8fRd>!$KS~wH0I^9A0z9ybvzp{;_%{j-in|6 zP4$7h1wJqlYLa)I(vpb@ExF~yQQuh_*OT=r-&t*smY(+#G^V9|6Pn|=mO^gJ2`%}y zmOMJ?JD16fjwLQQ$PMO>z!FAle5dXlKS)Nej7$G+>0kN2v6%04CEZX<_cy^k_2>K| z?30(|)cAKa7_hbK%x#mF+;CjgeQObKIqe$wFW#=@f;lqgnBEJAXs*3Ljiiofhk+Fh z_^W-V?%sngJ1(R7lT%*IeJy$T#21;2ch@sNh0u}@9)k#Isq+>u;eLE)YR6nJq9`L~I1|wRPy}d}O)di6YEl#{hUDSBzl4Y6 zGxtH*nxdsEAHxxdz^E`aDwwaK)_ZvWn%85<9v}vwCn)xG*>h5TRH#3c>TalfMos++ z=L9%@xUl1D?%1_+8`=%l?VQD96V*RQ1BTlE6+HCY1jU}N^cSN&C1{g^rcI+!@4{P3 z(Dn=3b<=3F(g0}5(Z43v$YCP0yD!v^j#7>f)6vlb1R_oC#vcp|kMCjaH=L~{U&JZm z#p^KbHc~Mv-bWQ<^vGpC?VpI|le8hCr7QmmQ?|fh{1$zSgKXX*TF~(s{Uc=9#Ye`|#Yq+Uv=WA$(oOgS)BNL@sy7LU0Leo*6%hHuE;vyo?H}Ateoo{CS zh%RK4N1^^M>txn?zqD?~C@ok@;jK;Z(j$kUF@7jc_7Evmb`}TxA?5~pGm5BJ-a^P? zhP!yv%ILuX72JPD6?{ggkaXpHR51ORs>5(0_PcNb$UWTNhKn6ElF88%)a-QS6Wm^C z;xzsds>o^g>!hYDcTeNJEO{=B=It`PkAI`W`-TYb&BEdRIT}7X=GysfIx?q+_%GBi z$kZypmGrAI z`_C;_v%gEu{+}W;G5hE87L@%vo#xTBkxzLjuANU2GhNB?=`E}0UGCb+&ztG8m!$O; zvQC%H;ZYx*Y!&K4sa`MC2B|I)>er<@U#Pc9b(T;^r256Lx#zu7Jtfq`QvDg!si%3w zW%Fe$&k+R&=&EVG^h0jNdp6>k`(>)<8&DfV{-H$P1L8y&Fy|OXeI(gZSd6R;E`~jEU5D+C!1Agu8TxM zhCf`@XvPx8UAMAoBizFqR&5%CCJV#qcF{c61;7x)F#fn1{G$YvL~m~}?jIO1!loOq z!us6NNFwGQf5OD@^hCn(U|{_5*!bhfgX1;^qoGL9ZojYh;oc%_yuitc{1A3jj8@b}cw0+t`8HyJYQIzbnb_3Q@akcteA;byHJEb5q-9CdZpGG|=7X?rd7`Yw>Nld6rOB z|45Ze>v%MZZzJ&bOR|W4RyMTpBr32f7V{o6B!BKxJnjHyES`Ju^kuw67}J^0z+kvH zf|FKHY$$3P0oDmG@kV#uET4iJ-9tvaARdk|#}%b(yr>QLckJB{G8q%{)svabw;*3S zmC5XdT>N1s^E~9kknciX`%xxy7V>k*1y~8ulADs__D#$&SmC&0_66nlNqhnDe)zkF z2rU=z*RzX|U5VdT)OVWjITfvQF8iA6!X4$q?1szNT(@EoKc$iFxA6N4;@Aqwn&wp8 zQ__6V`6b^(4UAFwo%s1tPH|_;BUbrdz|WxkyLshFtNeN3SEF5T=9Rx@mA?!8ZIu5h z>a+Zpey5xh$Oh>bKQApSWoPilT)fwH6NprU-m2Cy=Li%Wsvk8PRTGEBMd{qlY=?(^b(s+ zT#A$UWKW4O`pu-79+>HYnI4$wfteooKkI>ug=h8Mn7VH5krmR5PoC%^g@pHHJRMsg zFG6>)WbN&t)P+IYt+9B)X3Bbbg*-r)4=^&ncD5KOP9@#VGxO5aNw7@(Lf68P%@Sl7|PnJgNHeFrK#u zAgAI*B-b0HJ#IU=uKM}3)XQZ#o@VoM6}NQ%|1Q+`krOZl0U=jVRZh9CyLB9isZ&5iCQxKmo{u3hD+_0+oSYU)D#$N{Dr_v*lgyt#rGA7M{PLWa}!@3Ur>4a z8vZ(PbnN#EUCoD^I9_QTFQSyOyO1G2>bp;c)6-T5Rs-tzQ}}*l3dMgIIQenP<9@3& z_=&`W66g2!z)nc~XAZOi%;7)f~-E>1&sSy7nbclmp z%Ix#-Qr=N6roQJ_K9+L+W!7=7@Ed^3&=thxrXqL*xI2$2loaqn-$~EI3B}Fkv#2-U z=r6%9RPWyduK<78#(%a5{}sYZE@J9@t7>_@2>*j3IK3z?6wk$opW?B{zYusKejRVG ziXtnUitxLD&n=m69ly%LPdJ-nb+%gXM2q+t0bYUQ<$zat5EAPvpAf25|@F3BIZ z@xLeeUzI0*zQ%;BhdG|_o3tm2;LjJqUk6U@9rot?dIz{WAL81lMf_ZV3qVx?eiiVG z>E*RNPb$h1!j+OE*8ng09slN@m|5$I;EQ*EcThL|eGEW1zKIWS2AcgmUN;AHe0Lwl z;|yI7MD)H;q}w0T17;)^*Zqk>hNptj5I)ckcxnnW=sW(P?vKU%LpmNY#)jxo?|`8P z5(5K6Fv-z$FwK0be;b&Qo&-Ie)J;6-^z^WPKdO%Pag4!xK*!T)J?c06nSynCkjeNK z0X=3!4L?1nK@0Khk=m#0crvuXr~5iubbRLDa!W_khPGzFxU%RzO_pjco0z`7edD^O zc70=O>tgC(XNU-KsN$@(=T^G zcI}zq|smM9)$`3o{vBHbSutO(i4oRqKU0XGs6}W40NHXqPbEwt z$aRuh4}roH`N&P3P-yL;?A3Zh$z_tP;#WK+Z$&w+Wt6VgGfH};y*+*jvk)@mTiL7i zl#-_Ux4zXO?E zd$+VxQpK#z-($0{kn02`pHT~0=|I^lf4gn=>V8m3wJukDd;AYbd)5EUEtyib zSPO4~tNxQL_6Rc6er2!jzb&bVQrhd^Z?oUESP+#wYqPi4{}jsc<%c!@?E7+Qe&r^+ z{UH?QoKHFxyH!b?f)5AuDx0>D(SY_+uQ#R%5k@AJ&#c9HMQ^8UZ2vH{7=}r za_rUfloQnekrYp8SzY0upn&GSJndQQDr?_34K9+J2Fgy&LmD5VD0{V!aBMkQF}6eq z>#wp`lKz(gx%TRL%%rs6Wm$@hvQu)F%|7ZCyl9P(Do&wg+pqKr*pn|6|FBn-3`=_{ z6rH)PoXAwl`t3YrZ$E#4&Qq>b$Br!GVxM%lM0Wg@Krh8lwOgJ4={bdpog2zO$Ld9< b!&a#3Ra`pV*eJZ{QreJ=bd}A(rn3JA^;i~y literal 0 HcmV?d00001 diff --git a/tests/fake_document_tool.c b/tests/fake_document_tool.c index 8297dc2..e435ad1 100644 --- a/tests/fake_document_tool.c +++ b/tests/fake_document_tool.c @@ -54,6 +54,13 @@ static void write_repeated(FILE *stream, char value, long count) int main(int argc, char **argv) { + if (has_argument(argc, argv, "--list-langs")) + { + puts("List of available languages in synthetic fixture (2):"); + puts("eng"); + puts("fra"); + return 0; + } if (has_argument(argc, argv, "--emit")) { long stdout_size = argument_long( @@ -141,7 +148,27 @@ int main(int argc, char **argv) write_repeated(stdout, 'T', 8192); return 0; } - if (strstr(argv[1], "page-2") != NULL) + if (getenv("LABFY_FAKE_IDENTITY_OCR") != NULL && + has_argument(argc, argv, "tsv")) + { + puts("level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\t" + "left\ttop\twidth\theight\tconf\ttext"); + puts("5\t1\t1\t1\t1\t1\t10\t10\t80\t20\t95\tNOM"); + puts("5\t1\t1\t1\t1\t2\t100\t10\t120\t20\t94\tSPECIMEN"); + puts("5\t1\t1\t1\t2\t1\t10\t40\t100\t20\t93\tPRENOM"); + puts("5\t1\t1\t1\t2\t2\t120\t40\t100\t20\t92\tALICE"); + puts("5\t1\t1\t1\t3\t1\t10\t70\t100\t20\t91\tNUMERO"); + puts("5\t1\t1\t1\t3\t2\t120\t70\t100\t20\t90\tABC123"); + puts("5\t1\t1\t1\t4\t1\t10\t100\t110\t20\t89\tNATIONALITE"); + puts("5\t1\t1\t1\t4\t2\t130\t100\t120\t20\t88\tFRANCAIS"); + } + else if (getenv("LABFY_FAKE_IDENTITY_OCR") != NULL && + strstr(argv[1], "page-2") != NULL) + puts("NOM : PAGEDEUX\nNOM : SPECIMEN\nNOM : PAGE2"); + else if (getenv("LABFY_FAKE_IDENTITY_OCR") != NULL) + puts("NOM : SPECIMEN\nNOM : ALICE\nNOM : ABC123\n" + "NATIONALITÉ : FRANCAIS"); + else if (strstr(argv[1], "page-2") != NULL) puts("Texte OCR synthétique page deux."); else puts("Texte OCR synthétique page une."); diff --git a/tests/test_create_person_dialog_gtk.c b/tests/test_create_person_dialog_gtk.c index a42bb47..5de9df0 100644 --- a/tests/test_create_person_dialog_gtk.c +++ b/tests/test_create_person_dialog_gtk.c @@ -250,6 +250,9 @@ static gboolean click_cancel(gpointer data) g_signal_emit_by_name(button, "clicked"); button = find_button(GTK_WIDGET(dialog), "Suivant"); g_signal_emit_by_name(button, "clicked"); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "L’OCR produit des propositions à vérifier")); + g_signal_emit_by_name(button, "clicked"); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "Désignation : SPECIMEN")); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), @@ -300,7 +303,7 @@ static void activate(GtkApplication *application, gpointer data) gtk_application_window_new(application)); gtk_window_present(context->main_window); g_assert_true(create_person_dialog_present(context->main_window, - records, "/tmp", context->task_manager, NULL, completed, + records, "/tmp", context->task_manager, NULL, NULL, completed, context, NULL)); g_ptr_array_unref(records); g_idle_add(click_cancel, context); diff --git a/tests/test_create_person_dialog_ocr_gtk.c b/tests/test_create_person_dialog_ocr_gtk.c new file mode 100644 index 0000000..b8cf387 --- /dev/null +++ b/tests/test_create_person_dialog_ocr_gtk.c @@ -0,0 +1,651 @@ +#include "views/create_person_dialog.h" +#include "core/file_hash.h" +#include "core/person_creation_coordinator.h" +#include "dao/evidence_dao.h" +#include "dao/identity_ocr_dao.h" +#include "database/database.h" +#include "database/statement.h" +#include +#include +#include +#include + +typedef struct { + GtkApplication *application; + GtkWindow *main_window; + GtkWindow *dialog; + TaskManager *task_manager; + ToolRegistry *registry; + char *root; + char *database_path; + guint phase; + guint guard; + guint completion_count; + guint layout_index; + gboolean layout_resize_pending; + gboolean passed; + gboolean persisted; +} OcrGtkContext; +static void remove_tree(const char *root) +{ + GDir *directory=g_dir_open(root,0,NULL);const char *name; + if(directory==NULL)return; + while((name=g_dir_read_name(directory))!=NULL){ + char *path=g_build_filename(root,name,NULL); + if(g_file_test(path,G_FILE_TEST_IS_DIR))remove_tree(path); + else g_unlink(path); + g_free(path); + } + g_dir_close(directory);g_rmdir(root); +} + +static GtkWidget *find_button(GtkWidget *widget, const char *label) +{ + if (GTK_IS_BUTTON(widget) && + g_strcmp0(gtk_button_get_label(GTK_BUTTON(widget)), label) == 0) + return widget; + for (GtkWidget *child=gtk_widget_get_first_child(widget);child!=NULL; + child=gtk_widget_get_next_sibling(child)) { + GtkWidget *match=find_button(child,label); + if (match!=NULL)return match; + } + return NULL; +} +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 *match=find_named(child,name); + if(match!=NULL)return match; + } + return NULL; +} +static GtkWidget *find_ocr_field_row(GtkWidget *widget, const char *code, + guint order) +{ + const char *widget_code = g_object_get_data( + G_OBJECT(widget), "identity-field-code"); + guint widget_order = GPOINTER_TO_UINT(g_object_get_data( + G_OBJECT(widget), "identity-field-order")); + if (g_strcmp0(widget_code, code) == 0 && widget_order == order + 1) + return widget; + for(GtkWidget *child=gtk_widget_get_first_child(widget);child!=NULL; + child=gtk_widget_get_next_sibling(child)){ + GtkWidget *match=find_ocr_field_row(child,code,order); + if(match!=NULL)return match; + } + return NULL; +} +static guint count_ocr_field_rows(GtkWidget *widget) +{ + guint count=g_object_get_data( + G_OBJECT(widget),"identity-field-code")!=NULL?1:0; + for(GtkWidget *child=gtk_widget_get_first_child(widget);child!=NULL; + child=gtk_widget_get_next_sibling(child)) + count+=count_ocr_field_rows(child); + return count; +} +static GtkWidget *find_entry(GtkWidget *widget,const char *placeholder) +{ + if(GTK_IS_ENTRY(widget)&&g_strcmp0( + gtk_entry_get_placeholder_text(GTK_ENTRY(widget)),placeholder)==0) + return widget; + for(GtkWidget *child=gtk_widget_get_first_child(widget);child!=NULL; + child=gtk_widget_get_next_sibling(child)){ + GtkWidget *match=find_entry(child,placeholder); + if(match!=NULL)return match; + } + return NULL; +} +static GtkWidget *find_label(GtkWidget *widget,const char *text); +static void diagnose_ocr_field(GtkWidget *root, guint phase, + const char *code, guint order, GtkWidget *row) +{ + GtkWidget *entry=row!=NULL?g_object_get_data( + G_OBJECT(row),"identity-value-entry"):NULL; + GtkWidget *modify=row!=NULL?g_object_get_data( + G_OBJECT(row),"identity-modify-button"):NULL; + GtkWidget *reedit=row!=NULL?g_object_get_data( + G_OBJECT(row),"identity-reedit-button"):NULL; + const char *task_state=find_label(root,"OCR terminé")!=NULL + ?"terminée":"en cours ou en échec"; + g_printerr("Diagnostic OCR: étape=%u propositions=%u " + "code=%s ordre=%u ligne=%s entrée=%s modifier=%s " + "rééditer=%s tâche=%s\n",phase,count_ocr_field_rows(root),code,order, + row!=NULL?"oui":"non",entry!=NULL?"oui":"non", + modify!=NULL?"oui":"non",reedit!=NULL?"oui":"non",task_state); +} +static GtkDropDown *find_evidence_dropdown(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 match=first!=NULL&&g_strcmp0( + gtk_string_object_get_string(first),"Aucune preuve associée")==0; + g_clear_object(&first);if(match)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_dropdown(child); + if(match!=NULL)return match; + } + return NULL; +} +static GtkSpinButton *find_visible_spin(GtkWidget *widget) +{ + if(GTK_IS_SPIN_BUTTON(widget)&>k_widget_get_mapped(widget)) + return GTK_SPIN_BUTTON(widget); + for(GtkWidget *child=gtk_widget_get_first_child(widget);child!=NULL; + child=gtk_widget_get_next_sibling(child)){ + GtkSpinButton *match=find_visible_spin(child); + if(match!=NULL)return match; + } + return NULL; +} +static GtkWidget *find_label(GtkWidget *widget,const char *text) +{ + if(GTK_IS_LABEL(widget)&&strstr(gtk_label_get_text(GTK_LABEL(widget)), + text)!=NULL)return widget; + for(GtkWidget *child=gtk_widget_get_first_child(widget);child!=NULL; + child=gtk_widget_get_next_sibling(child)){ + GtkWidget *match=find_label(child,text); + if(match!=NULL)return match; + } + return NULL; +} +static char *text_view_contents(GtkTextView *view) +{ + GtkTextIter start,end;GtkTextBuffer *buffer=gtk_text_view_get_buffer(view); + gtk_text_buffer_get_bounds(buffer,&start,&end); + return gtk_text_buffer_get_text(buffer,&start,&end,FALSE); +} +static guint64 table_count(const char *database_path,const char *table) +{ + Database *database=database_open(database_path); + char *sql=g_strdup_printf("SELECT COUNT(*) FROM %s;",table); + DatabaseStatement *statement=database_statement_prepare(database,sql); + int64_t value=0;g_assert_nonnull(statement); + g_assert_cmpint(database_statement_step(statement),==, + DATABASE_STATEMENT_STEP_ROW); + g_assert_true(database_statement_column_int64(statement,0,&value)); + database_statement_finalize(statement);database_close(database); + g_free(sql);return (guint64)value; +} +static void verify_persisted_review(OcrGtkContext *context) +{ + GError *error=NULL; + Database *database=database_open(context->database_path); + IdentityOcrDao *dao=identity_ocr_dao_new(database); + GPtrArray *runs=identity_ocr_dao_list_runs_by_evidence(dao, + "10000000-0000-4000-8000-000000000002",&error); + g_assert_no_error(error);g_assert_cmpuint(runs->len,==,1); + IdentityOcrRunRecord *record=g_ptr_array_index(runs,0); + IdentityOcrRun *run=identity_ocr_dao_load_run(dao,context->root, + record->id,NULL,&error); + g_assert_no_error(error);g_assert_nonnull(run); + g_assert_cmpstr(identity_ocr_run_get_corrected_transcription(run),==, + "TRANSCRIPTION PERSONNE SPECIMEN CORRIGÉE"); + g_assert_nonnull(strstr(identity_ocr_run_get_factual_notes(run), + "note documentaire SPECIMEN")); + const GPtrArray *fields=identity_ocr_run_get_fields(run); + gboolean modified=FALSE,manual=FALSE; + for(guint index=0;indexlen;index++){ + IdentityFieldObservation *field=g_ptr_array_index( + (GPtrArray *)fields,index); + modified|=g_strcmp0( + identity_field_observation_get_corrected_value(field), + "PAGE 2 VALEUR FINALE")==0&& + g_strcmp0(identity_field_observation_get_origin(field), + "manual_override")==0; + manual|=g_strcmp0( + identity_field_observation_get_corrected_value(field), + "MANUEL SPECIMEN MODIFIÉ")==0&& + g_strcmp0(identity_field_observation_get_origin(field), + "manual_entry")==0; + } + g_assert_true(modified);g_assert_true(manual); + identity_ocr_run_free(run);g_ptr_array_unref(runs); + identity_ocr_dao_free(dao);database_close(database); +} +static void completed(CreatePersonDialogResult *result,gpointer data) +{ + OcrGtkContext *context=data; + if(result!=NULL){ + GError *error=NULL; + Database *database=database_open(context->database_path); + PersonCreationCoordinatorResult *created= + person_creation_coordinator_execute(database,context->root, + create_person_dialog_result_get_input(result), + create_person_dialog_result_get_evidence_selection(result), + create_person_dialog_result_get_ocr_runs(result), + NULL,&error); + g_assert_no_error(error);g_assert_nonnull(created); + person_creation_coordinator_result_free(created); + database_close(database); + create_person_dialog_result_free(result); + context->persisted=TRUE; + } + context->completion_count++; +} +static gboolean drive(gpointer data) +{ + OcrGtkContext *context=data; + GtkWidget *root=context->phase<4?GTK_WIDGET(context->dialog):NULL; + GtkWidget *button;char *text; + if(++context->guard>1000)g_error("Timeout du parcours OCR GTK"); + switch(context->phase){ + case 0: + { + static const int widths[]={1536,1366,1200,760}; + static const int heights[]={864,768,800,560}; + if(context->layout_indexlayout_resize_pending){ + gtk_window_set_default_size(context->dialog, + widths[context->layout_index], + heights[context->layout_index]); + context->layout_resize_pending=TRUE; + return G_SOURCE_CONTINUE; + } + int width=0,height=0; + gtk_window_get_default_size(context->dialog,&width,&height); + g_assert_cmpint(width,==,widths[context->layout_index]); + g_assert_cmpint(height,==,heights[context->layout_index]); + GtkWidget *paned=find_named(root, + "create-person-ocr-paned"); + GtkWidget *scroll=find_named(root, + "create-person-ocr-scroll"); + GtkWidget *actions=find_named(root, + "create-person-actions"); + g_assert_true(GTK_IS_PANED(paned)); + g_assert_true(GTK_IS_SCROLLED_WINDOW(scroll)); + GtkPolicyType horizontal_policy=GTK_POLICY_ALWAYS; + g_object_get(scroll,"hscrollbar-policy", + &horizontal_policy,NULL); + g_assert_cmpint(horizontal_policy,==,GTK_POLICY_NEVER); + g_assert_true(gtk_widget_get_mapped(actions)); + if(context->layout_index==0){ + int position=gtk_paned_get_position(GTK_PANED(paned)); + g_assert_cmpint(position,>=,600); + g_assert_cmpint(position,<=,680); + } + context->layout_index++; + context->layout_resize_pending=FALSE; + return G_SOURCE_CONTINUE; + } + GtkStack *transcription=GTK_STACK(find_named(root, + "create-person-ocr-transcription-stack")); + g_assert_true(GTK_IS_STACK(transcription)); + gtk_stack_set_visible_child_name(transcription,"corrected"); + g_assert_cmpstr(gtk_stack_get_visible_child_name(transcription), + ==,"corrected"); + gtk_stack_set_visible_child_name(transcription,"raw"); + g_assert_cmpstr(gtk_stack_get_visible_child_name(transcription), + ==,"raw"); + } + 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"); + context->phase++;break; + case 1: + if(find_label(root,"OCR terminé")==NULL)return G_SOURCE_CONTINUE; + text=text_view_contents(GTK_TEXT_VIEW(find_named( + root,"create-person-ocr-raw-text"))); + g_assert_nonnull(strstr(text,"NOM : SPECIMEN"));g_free(text); + g_assert_nonnull(find_button(root,"Voir la zone")); + g_assert_cmpuint(count_ocr_field_rows(root),>=,3); + GtkWidget *raw_row=find_ocr_field_row(root,"nationality",3); + diagnose_ocr_field(root,context->phase,"nationality",3,raw_row); + g_assert_nonnull(raw_row); + GtkWidget *raw_entry=g_object_get_data( + G_OBJECT(raw_row),"identity-value-entry"); + GtkWidget *raw_modify=g_object_get_data( + G_OBJECT(raw_row),"identity-modify-button"); + g_assert_nonnull(raw_entry);g_assert_nonnull(raw_modify); + g_assert_true(gtk_editable_get_editable(GTK_EDITABLE(raw_entry))); + gtk_editable_set_text(GTK_EDITABLE(raw_entry),"FRANÇAIS"); + g_signal_emit_by_name(raw_modify,"clicked"); + g_assert_nonnull(find_button(raw_row,"Modifiée")); + g_assert_false(gtk_editable_get_editable(GTK_EDITABLE(raw_entry))); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(raw_row),"identity-reedit-button"),"clicked"); + g_assert_true(gtk_editable_get_editable(GTK_EDITABLE(raw_entry))); + gtk_editable_set_text(GTK_EDITABLE(raw_entry),"FRANÇAIS CORRIGÉ"); + g_signal_emit_by_name(raw_modify,"clicked"); + g_assert_nonnull(find_label(raw_row, + "Valeur OCR brute : FRANCAIS")); + g_signal_emit_by_name(find_button(raw_row, + "Revenir à la valeur OCR"),"clicked"); + g_assert_cmpstr(gtk_editable_get_text(GTK_EDITABLE(raw_entry)),==, + "FRANCAIS"); + gtk_editable_set_text(GTK_EDITABLE(raw_entry),"FRANÇAIS FINAL"); + g_signal_emit_by_name(raw_modify,"clicked"); + g_signal_emit_by_name(find_button(raw_row,"Rejeter"),"clicked"); + g_signal_emit_by_name(find_button(raw_row, + "Modifier à nouveau"),"clicked"); + g_signal_emit_by_name(raw_modify,"clicked"); + GtkWidget *manual_entry=find_entry(root, + "Valeur réellement visible dans la preuve"); + g_assert_nonnull(manual_entry); + gtk_editable_set_text(GTK_EDITABLE(manual_entry), + "DOCUMENT VISIBLE SPECIMEN"); + g_signal_emit_by_name(find_button(root, + "Ajouter un champ manquant"),"clicked"); + GtkWidget *manual_row=find_ocr_field_row(root,"document_type",4); + diagnose_ocr_field(root,context->phase,"document_type",4,manual_row); + g_assert_nonnull(manual_row); + g_assert_nonnull(find_label(manual_row,"Origine : manual_entry")); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(manual_row),"identity-modify-button"),"clicked"); + GtkTextView *notes=GTK_TEXT_VIEW(find_named( + root,"create-person-ocr-factual-notes")); + g_assert_nonnull(notes); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(notes), + "document tronqué", -1); + g_signal_emit_by_name(find_button(root,"Voir la zone"),"clicked"); + g_assert_true(create_person_dialog_test_overlay_has_region( + context->dialog)); + button=find_button(root,"Accepter"); + g_signal_emit_by_name(button,"clicked"); + GtkWidget *second_row=find_ocr_field_row(root,"surname",1); + diagnose_ocr_field(root,context->phase,"surname",1,second_row); + g_assert_nonnull(second_row); + button=g_object_get_data( + G_OBJECT(second_row),"identity-modify-button"); + g_assert_nonnull(button); + GtkWidget *entry=g_object_get_data( + G_OBJECT(second_row),"identity-value-entry"); + g_assert_nonnull(entry); + gtk_editable_set_text(GTK_EDITABLE(entry),"ALICE MODIFIÉE"); + g_signal_emit_by_name(button,"clicked"); + GtkWidget *third_row=find_ocr_field_row(root,"surname",2); + diagnose_ocr_field(root,context->phase,"surname",2,third_row); + g_assert_nonnull(third_row); + button=g_object_get_data( + G_OBJECT(third_row),"identity-reject-button"); + g_assert_nonnull(button);g_signal_emit_by_name(button,"clicked"); + g_assert_null(find_button(root,"Tout accepter")); + g_assert_nonnull(find_button(root,"Acceptée")); + g_assert_nonnull(find_button(root,"Modifiée")); + g_assert_nonnull(find_button(root,"Rejetée")); + g_signal_emit_by_name(find_button(root,"Précédent"),"clicked"); + g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); + g_assert_nonnull(find_button(root,"Acceptée")); + guint64 generation=create_person_dialog_test_ocr_generation( + context->dialog); + button=find_button(root,"Analyser comme document d’identité"); + g_signal_emit_by_name(button,"clicked"); + g_signal_emit_by_name(button,"clicked"); + g_assert_cmpuint(create_person_dialog_test_ocr_generation( + context->dialog),==,generation+2); + context->phase++;break; + case 2: + if(find_label(root,"OCR terminé")==NULL)return G_SOURCE_CONTINUE; + g_signal_emit_by_name(find_button(root,"Précédent"),"clicked"); + g_signal_emit_by_name(find_button(root, + "Retirer"),"clicked"); + g_assert_false(create_person_dialog_test_overlay_has_region( + context->dialog)); + g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); + text=text_view_contents(GTK_TEXT_VIEW(find_named( + root,"create-person-ocr-raw-text"))); + g_assert_cmpstr(text,==,"");g_free(text); + g_signal_emit_by_name(find_button(root,"Précédent"),"clicked"); + GtkDropDown *evidence=find_evidence_dropdown(root); + g_assert_nonnull(evidence);gtk_drop_down_set_selected(evidence,2); + g_signal_emit_by_name(find_button(root, + "Ajouter à la sélection"),"clicked"); + g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); + GtkSpinButton *page=find_visible_spin(root);g_assert_nonnull(page); + gtk_spin_button_set_value(page,2); + g_signal_emit_by_name(find_button(root, + "Analyser comme document d’identité"),"clicked"); + context->phase++;break; + case 3: + if(find_label(root,"OCR terminé")==NULL)return G_SOURCE_CONTINUE; + { + GtkStack *transcription=GTK_STACK(find_named(root, + "create-person-ocr-transcription-stack")); + GtkTextView *corrected=GTK_TEXT_VIEW(find_named(root, + "identity-corrected-transcription")); + GtkTextView *raw=GTK_TEXT_VIEW(find_named(root, + "create-person-ocr-raw-text")); + g_assert_cmpstr(gtk_stack_get_visible_child_name(transcription), + ==,"corrected"); + g_assert_true(gtk_widget_get_mapped(GTK_WIDGET(corrected))); + g_assert_true(gtk_widget_get_sensitive(GTK_WIDGET(corrected))); + g_assert_true(gtk_text_view_get_editable(corrected)); + g_assert_false(gtk_text_view_get_editable(raw)); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(corrected), + "TRANSCRIPTION PERSONNE SPECIMEN CORRIGÉE",-1); + gtk_stack_set_visible_child_name(transcription,"raw"); + char *raw_text=text_view_contents(raw); + g_assert_nonnull(strstr(raw_text,"NOM : SPECIMEN")); + g_free(raw_text); + gtk_stack_set_visible_child_name(transcription,"corrected"); + char *corrected_text=text_view_contents(corrected); + g_assert_cmpstr(corrected_text,==, + "TRANSCRIPTION PERSONNE SPECIMEN CORRIGÉE"); + g_free(corrected_text); + } + button=find_button(root,"Accepter"); + g_assert_nonnull(button);g_signal_emit_by_name(button,"clicked"); + GtkWidget *pdf_second_row=find_ocr_field_row(root,"surname",1); + diagnose_ocr_field(root,context->phase,"surname",1,pdf_second_row); + g_assert_nonnull(pdf_second_row); + button=g_object_get_data( + G_OBJECT(pdf_second_row),"identity-modify-button"); + g_assert_nonnull(button); + GtkWidget *pdf_entry=g_object_get_data( + G_OBJECT(pdf_second_row),"identity-value-entry"); + g_assert_nonnull(pdf_entry); + g_assert_true(gtk_widget_get_sensitive(pdf_entry)); + g_assert_true(gtk_editable_get_editable(GTK_EDITABLE(pdf_entry))); + char *pdf_raw_value=g_strdup( + gtk_editable_get_text(GTK_EDITABLE(pdf_entry))); + gtk_editable_set_text(GTK_EDITABLE(pdf_entry),"PAGE 2 PREMIÈRE"); + g_signal_emit_by_name(button,"clicked"); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(pdf_second_row),"identity-reedit-button"),"clicked"); + g_assert_true(gtk_editable_get_editable(GTK_EDITABLE(pdf_entry))); + gtk_editable_set_text(GTK_EDITABLE(pdf_entry),"PAGE 2 DEUXIÈME"); + g_signal_emit_by_name(button,"clicked"); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(pdf_second_row),"identity-restore-button"),"clicked"); + g_assert_cmpstr(gtk_editable_get_text(GTK_EDITABLE(pdf_entry)), + ==,pdf_raw_value); + g_free(pdf_raw_value); + gtk_editable_set_text(GTK_EDITABLE(pdf_entry), + "PAGE 2 VALEUR FINALE"); + g_signal_emit_by_name(button,"clicked"); + GtkWidget *manual_input=find_entry(root, + "Valeur réellement visible dans la preuve"); + gtk_editable_set_text(GTK_EDITABLE(manual_input), + "MANUEL SPECIMEN INITIAL"); + g_signal_emit_by_name(find_button(root, + "Ajouter un champ manquant"),"clicked"); + GtkWidget *pdf_manual_row=find_ocr_field_row( + root,"document_type",4); + g_assert_nonnull(pdf_manual_row); + GtkWidget *manual_value=g_object_get_data( + G_OBJECT(pdf_manual_row),"identity-value-entry"); + g_assert_true(gtk_widget_get_sensitive(manual_value)); + g_assert_true(gtk_editable_get_editable(GTK_EDITABLE(manual_value))); + gtk_editable_set_text(GTK_EDITABLE(manual_value), + "MANUEL SPECIMEN MODIFIÉ"); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(pdf_manual_row),"identity-modify-button"),"clicked"); + GtkTextView *factual_notes=GTK_TEXT_VIEW(find_named(root, + "create-person-ocr-factual-notes")); + g_assert_true(gtk_text_view_get_editable(factual_notes)); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(factual_notes), + "note documentaire SPECIMEN — document incomplet",-1); + gtk_window_set_default_size(context->dialog,760,560); + g_assert_true(gtk_widget_get_mapped(GTK_WIDGET(factual_notes))); + GtkWidget *pdf_third_row=find_ocr_field_row(root,"surname",2); + diagnose_ocr_field(root,context->phase,"surname",2,pdf_third_row); + g_assert_nonnull(pdf_third_row); + button=g_object_get_data( + G_OBJECT(pdf_third_row),"identity-reject-button"); + g_assert_nonnull(button);g_signal_emit_by_name(button,"clicked"); + 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; + case 4: + if(context->completion_count!=1)return G_SOURCE_CONTINUE; + g_assert_true(context->persisted); + g_assert_cmpuint(table_count(context->database_path,"entites"),==,1); + g_assert_cmpuint(table_count(context->database_path, + "identity_ocr_runs"),==,1); + verify_persisted_review(context); + g_assert_true(gtk_widget_get_visible(GTK_WIDGET(context->main_window))); + GPtrArray *empty=g_ptr_array_new(); + g_assert_true(create_person_dialog_present(context->main_window,empty, + context->root,context->task_manager, + tool_registry_find(context->registry,"tesseract"),NULL,completed, + context,NULL)); + g_ptr_array_unref(empty); + GList *windows=gtk_application_get_windows(context->application); + context->dialog=windows->data==context->main_window + ?GTK_WINDOW(windows->next->data):GTK_WINDOW(windows->data); + g_signal_emit_by_name(find_button(GTK_WIDGET(context->dialog), + "Annuler"),"clicked"); + context->phase++;break; + case 5: + if(context->completion_count!=2)return G_SOURCE_CONTINUE; + g_assert_true(gtk_widget_get_visible(GTK_WIDGET(context->main_window))); + context->passed=TRUE; + g_application_quit(G_APPLICATION(context->application)); + return G_SOURCE_REMOVE; + default:break; + } + return G_SOURCE_CONTINUE; +} +static void write_png(const char *path) +{ + cairo_surface_t *surface=cairo_image_surface_create( + CAIRO_FORMAT_ARGB32,320,160); + 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,20,50);cairo_show_text(cr,"SPECIMEN OCR IDENTITE"); + g_assert_cmpint(cairo_surface_write_to_png(surface,path),==, + CAIRO_STATUS_SUCCESS);cairo_destroy(cr);cairo_surface_destroy(surface); +} +static void write_pdf(const char *path) +{ + cairo_surface_t *surface=cairo_pdf_surface_create(path,320,160); + cairo_t *cr=cairo_create(surface); + cairo_move_to(cr,20,50);cairo_show_text(cr,"SPECIMEN PAGE UNE"); + cairo_show_page(cr);cairo_move_to(cr,20,50); + cairo_show_text(cr,"SPECIMEN PAGE DEUX");cairo_show_page(cr); + cairo_destroy(cr);cairo_surface_destroy(surface); +} +static void activate(GtkApplication *application,gpointer data) +{ + OcrGtkContext *context=data;GError *error=NULL; + g_setenv("LABFY_FAKE_IDENTITY_OCR","1",TRUE); + char *database_directory=g_build_filename( + context->root,"00_BaseDeDonnees",NULL); + context->database_path=g_build_filename(database_directory, + "Enquete.sqlite",NULL); + g_assert_cmpint(g_mkdir_with_parents(database_directory,0700),==,0); + g_assert_true(database_initialize(context->database_path, + "Enquête SPECIMEN GTK OCR",context->root)); + char *evidence_directory=g_build_filename(context->root, + "01_Preuves_Originales","identity",NULL); + g_assert_cmpint(g_mkdir_with_parents(evidence_directory,0700),==,0); + char *png=g_build_filename(evidence_directory,"SPECIMEN.png",NULL); + char *pdf=g_build_filename(evidence_directory,"SPECIMEN-page-2.pdf",NULL); + write_png(png);write_pdf(pdf); + char *png_sha=NULL,*pdf_sha=NULL;guint64 png_size=0,pdf_size=0; + g_assert_true(file_hash_compute_sha256(png,NULL,&png_sha,&png_size,&error)); + g_assert_true(file_hash_compute_sha256(pdf,NULL,&pdf_sha,&pdf_size,&error)); + GPtrArray *records=g_ptr_array_new_with_free_func( + (GDestroyNotify)evidence_record_free); + EvidenceRecord *record=evidence_record_new( + "10000000-0000-4000-8000-000000000001","SPECIMEN.png", + "SPECIMEN.png","01_Preuves_Originales/identity/SPECIMEN.png", + "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=evidence_record_new( + "10000000-0000-4000-8000-000000000002","SPECIMEN-page-2.pdf", + "SPECIMEN-page-2.pdf", + "01_Preuves_Originales/identity/SPECIMEN-page-2.pdf", + "document",pdf_size,pdf_sha,"2026-07-28T10:00:00Z",NULL,NULL, + "PDF SPECIMEN multipage",EVIDENCE_INTEGRITY_STATUS_VALID,&error); + g_assert_nonnull(record);evidence_record_set_display_metadata( + record,"Document d’identité","application/pdf"); + g_ptr_array_add(records,record);g_assert_no_error(error); + Database *database=database_open(context->database_path); + EvidenceDao *evidence_dao=evidence_dao_new(database,&error); + g_assert_no_error(error);g_assert_nonnull(evidence_dao); + for(guint index=0;indexlen;index++) + g_assert_true(evidence_dao_insert(evidence_dao, + g_ptr_array_index(records,index),&error)); + g_assert_no_error(error); + evidence_dao_free(evidence_dao);database_close(database); + context->registry=tool_registry_new(); + char *tool=g_canonicalize_filename("tests/fake_document_tool",NULL); + g_assert_true(tool_registry_register(context->registry,"tesseract", + "Tesseract synthétique",tool,TOOL_REQUIREMENT_OPTIONAL,&error)); + g_assert_true(tool_registry_refresh(context->registry,&error)); + g_assert_true(tool_registry_set_version(context->registry,"tesseract", + "5.0.0 SPECIMEN",&error));g_assert_no_error(error); + context->main_window=GTK_WINDOW(gtk_application_window_new(application)); + gtk_window_present(context->main_window); + g_assert_true(create_person_dialog_present(context->main_window,records, + context->root,context->task_manager, + tool_registry_find(context->registry,"tesseract"),NULL,completed, + context,NULL)); + GList *windows=gtk_application_get_windows(application); + context->dialog=windows->data==context->main_window + ?GTK_WINDOW(windows->next->data):GTK_WINDOW(windows->data); + 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"); + g_signal_emit_by_name(find_button(GTK_WIDGET(context->dialog), + "Suivant"),"clicked"); + GtkWidget *role=find_button(GTK_WIDGET(context->dialog),"Victime"); + if(role!=NULL)g_signal_emit_by_name(role,"clicked"); + g_signal_emit_by_name(find_button(GTK_WIDGET(context->dialog), + "Suivant"),"clicked"); + GtkDropDown *evidence=find_evidence_dropdown(GTK_WIDGET(context->dialog)); + gtk_drop_down_set_selected(evidence,1); + g_signal_emit_by_name(find_button(GTK_WIDGET(context->dialog), + "Ajouter à la sélection"),"clicked"); + g_signal_emit_by_name(find_button(GTK_WIDGET(context->dialog), + "Suivant"),"clicked"); + g_timeout_add(10,drive,context); + g_ptr_array_unref(records);g_free(database_directory); + g_free(evidence_directory);g_free(png);g_free(pdf); + g_free(png_sha);g_free(pdf_sha);g_free(tool); +} +int main(int argc,char **argv) +{ + if(!gtk_init_check()){g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0;} + OcrGtkContext context={0}; + context.root=g_dir_make_tmp("labfy-ocr-gtk-XXXXXX",NULL); + context.application=gtk_application_new( + "org.labfy.Investigation.OcrDialogTest",G_APPLICATION_NON_UNIQUE); + context.task_manager=task_manager_new(); + g_signal_connect(context.application,"activate",G_CALLBACK(activate), + &context); + int status=g_application_run(G_APPLICATION(context.application),argc,argv); + g_assert_true(context.passed); + tool_registry_free(context.registry);task_manager_free(context.task_manager); + g_object_unref(context.application); + g_free(context.database_path);remove_tree(context.root);g_free(context.root); + return status; +} diff --git a/tests/test_database.c b/tests/test_database.c index 9f3ff10..1b92246 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -525,7 +525,7 @@ static void test_database_initialize_valid_database(void) "FROM investigation;" ); - assert(strcmp(schema_version, "14") == 0); + assert(strcmp(schema_version, "17") == 0); test_database_assert_table_exists(database, "person_role_assignments"); test_database_assert_table_exists(database, "bank_account_entities"); test_database_assert_table_exists(database, "relation_types"); @@ -993,7 +993,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "14" + "17" ) == 0 ); @@ -1448,7 +1448,7 @@ static void test_database_migrate_v12_to_v13_preserves_legacy_link(void) legacy_sources = test_database_read_single_text(sqlite_database, "SELECT COUNT(*) FROM preuve_entite_sources " "WHERE source_kind='legacy_manual';"); - assert(strcmp(version, "14") == 0); + assert(strcmp(version, "17") == 0); assert(strcmp(legacy_sources, "1") == 0); char *legacy_role = test_database_read_single_text(sqlite_database, "SELECT role_code || ':' || provenance_kind " diff --git a/tests/test_evidence_identity_import_gtk.c b/tests/test_evidence_identity_import_gtk.c new file mode 100644 index 0000000..e089feb --- /dev/null +++ b/tests/test_evidence_identity_import_gtk.c @@ -0,0 +1,803 @@ +#include "core/evidence_identity_import_task.h" +#include "core/file_hash.h" +#include "core/tool_registry.h" +#include "dao/entity_dao.h" +#include "dao/evidence_dao.h" +#include "dao/evidence_entity_dao.h" +#include "dao/identity_ocr_dao.h" +#include "database/database.h" +#include "database/schema.h" +#include "models/evidence_type.h" +#include "views/evidence_identity_ocr_dialog.h" +#include "views/evidence_import_dialog.h" +#include "views/main_window.h" +#include "widgets/workspace.h" +#include +#include +#include +#include + +typedef struct { + GtkApplication *application; + GtkWindow *main_window; + MainWindow *production_main_window; + TaskManager *manager; + ToolRegistry *registry; + char *root; + char *database_path; + char *file_path; + GPtrArray *persons; + EvidenceImportDialogResult *metadata; + BackgroundTask *import_task; + GtkWindow *active_ocr_dialog; + guint phase; + guint guard; + gboolean metadata_ready; + gboolean import_done; + gboolean cancellation_done; + gboolean layout_resize_pending; + gboolean passed; + gboolean revision_done; + char *final_evidence_identifier; + char *run_identifier; + guint revision_requests; + guint rerun_requests; + guint import_start_count; +} TestContext; + +static void remove_tree(const char *root) +{ + GDir *directory = g_dir_open(root, 0, NULL); + const char *name; + if (directory == NULL) return; + while ((name = g_dir_read_name(directory)) != NULL) { + char *path = g_build_filename(root, name, NULL); + if (g_file_test(path, G_FILE_TEST_IS_DIR)) remove_tree(path); + else g_unlink(path); + g_free(path); + } + g_dir_close(directory); + g_rmdir(root); +} + +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 gboolean is_descendant_of(GtkWidget *widget, GtkWidget *ancestor) +{ + for (GtkWidget *parent = widget; parent != NULL; + parent = gtk_widget_get_parent(parent)) + if (parent == ancestor) return TRUE; + return FALSE; +} + +static GtkWidget *find_button(GtkWidget *widget, const char *label) +{ + if (GTK_IS_BUTTON(widget) && + g_strcmp0(gtk_button_get_label(GTK_BUTTON(widget)), label) == 0) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *found = find_button(child, label); + if (found != NULL) return found; + } + return NULL; +} + +static GtkWidget *find_label(GtkWidget *widget, const char *text) +{ + if (GTK_IS_LABEL(widget) && + strstr(gtk_label_get_text(GTK_LABEL(widget)), text) != NULL) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *found = find_label(child, text); + if (found != NULL) return found; + } + return NULL; +} + +static GtkWindow *find_window(TestContext *context, const char *title) +{ + for (GList *item = gtk_application_get_windows(context->application); + item != NULL; item = item->next) + if (g_strcmp0(gtk_window_get_title(item->data), title) == 0) + return item->data; + return NULL; +} + +static GtkWidget *find_field_row(GtkWidget *widget, guint ordinal) +{ + if (g_object_get_data(G_OBJECT(widget), "identity-field-code") != NULL && + GPOINTER_TO_UINT(g_object_get_data( + G_OBJECT(widget), "identity-field-order")) == ordinal + 1) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *found = find_field_row(child, ordinal); + if (found != NULL) return found; + } + return NULL; +} + +static guint count_field_rows(GtkWidget *widget) +{ + guint count = g_object_get_data( + G_OBJECT(widget), "identity-field-code") != NULL ? 1 : 0; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) + count += count_field_rows(child); + return count; +} + +static GtkWidget *find_entry_placeholder(GtkWidget *widget, + const char *placeholder) +{ + if (GTK_IS_ENTRY(widget) && g_strcmp0( + gtk_entry_get_placeholder_text(GTK_ENTRY(widget)), + placeholder) == 0) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *found = find_entry_placeholder(child, placeholder); + if (found != NULL) return found; + } + return NULL; +} + +static char *text_view_contents(GtkTextView *view) +{ + GtkTextIter start; + GtkTextIter end; + GtkTextBuffer *buffer = gtk_text_view_get_buffer(view); + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} + +static void import_completed(BackgroundTask *task, gpointer data) +{ + TestContext *context = data; + context->import_done = TRUE; + g_assert_cmpint(background_task_get_state(task), ==, + BACKGROUND_TASK_STATE_COMPLETED); + evidence_identity_ocr_dialog_finish_import( + context->active_ocr_dialog, NULL); + g_clear_object(&context->active_ocr_dialog); +} + +static void ocr_completed(EvidenceIdentityOcrDialogResult *result, + gpointer data) +{ + TestContext *context = data; + GError *error = NULL; + g_assert_nonnull(result); + g_assert_true(evidence_identity_ocr_dialog_result_has_ocr(result)); + context->import_start_count++; + g_assert_cmpuint(context->import_start_count, ==, 1); + context->active_ocr_dialog = g_object_ref( + evidence_identity_ocr_dialog_result_get_dialog(result)); + IdentityOcrRun *run = + evidence_identity_ocr_dialog_result_steal_run(result); + EvidenceIdentityImportTaskRequest *request = + evidence_identity_import_task_request_new( + context->database_path, context->root, context->file_path, + evidence_identity_ocr_dialog_result_get_person_identifier(result), + evidence_import_dialog_result_get_type_identifier( + context->metadata), + evidence_import_dialog_result_get_collected_at(context->metadata), + evidence_import_dialog_result_get_source(context->metadata), + evidence_import_dialog_result_get_description(context->metadata), + run); + g_assert_nonnull(request); + context->import_task = evidence_identity_import_task_start( + context->manager, request, import_completed, context, NULL, &error); + g_assert_no_error(error); + g_assert_nonnull(context->import_task); + evidence_identity_import_task_request_free(request); + identity_ocr_run_free(run); + evidence_identity_ocr_dialog_result_free(result); +} + +static void cancellation_completed(EvidenceIdentityOcrDialogResult *result, + gpointer data) +{ + TestContext *context = data; + g_assert_null(result); + context->cancellation_done = TRUE; +} + +static void metadata_completed(EvidenceImportDialogResult *result, + gpointer data) +{ + TestContext *context = data; + GError *error = NULL; + g_assert_nonnull(result); + context->metadata = result; + context->metadata_ready = TRUE; + g_assert_true(evidence_identity_ocr_dialog_present( + context->main_window, context->file_path, context->persons, + NULL, tool_registry_find(context->registry, "tesseract"), + NULL, ocr_completed, context, NULL, &error)); + g_assert_no_error(error); +} + +static void verify_persistence(TestContext *context) +{ + GError *error = NULL; + Database *database = database_open(context->database_path); + g_assert_nonnull(database); + EvidenceDao *evidence_dao = evidence_dao_new(database, &error); + EvidenceEntityDao *link_dao = + evidence_entity_dao_new(database, &error); + IdentityOcrDao *ocr_dao = identity_ocr_dao_new(database); + g_assert_no_error(error); + GPtrArray *evidences = evidence_dao_list_all(evidence_dao, &error); + g_assert_no_error(error); + g_assert_cmpuint(evidences->len, ==, 1); + EvidenceRecord *evidence = g_ptr_array_index(evidences, 0); + const char *evidence_id = evidence_record_get_identifier(evidence); + gboolean link_exists = FALSE; + g_assert_nonnull(evidence_id); + if (context->final_evidence_identifier == NULL) + context->final_evidence_identifier = g_strdup(evidence_id); + g_assert_cmpstr(evidence_id, ==, context->final_evidence_identifier); + g_assert_true(evidence_entity_dao_exists( + link_dao, evidence_id, + "33333333-3333-4333-8333-333333333333", &link_exists, &error)); + g_assert_true(link_exists); + GPtrArray *runs = + identity_ocr_dao_list_runs_by_evidence(ocr_dao, evidence_id, &error); + g_assert_no_error(error); + g_assert_cmpuint(runs->len, ==, 1); + IdentityOcrRunRecord *run = g_ptr_array_index(runs, 0); + char *run_identifier = g_strdup(run->id); + if (context->run_identifier == NULL) + context->run_identifier = g_strdup(run->id); + g_assert_cmpstr(run->evidence_id, ==, + context->final_evidence_identifier); + g_assert_cmpstr(run->id, ==, context->run_identifier); + g_assert_nonnull(run->text_relative_path); + g_assert_nonnull(run->text_sha256); + g_assert_nonnull(run->tsv_relative_path); + g_assert_nonnull(run->tsv_sha256); + g_assert_true(run->transcription_is_human); + g_assert_cmpstr(run->transcription_origin, ==, "human"); + g_assert_cmpstr(run->corrected_transcription, ==, + context->revision_done ? "TRANSCRIPTION SPECIMEN RÉVISÉE" : + "ÉTAT CIVIL\nNée à Besançon — Côte-d’Or"); + GPtrArray *documents = identity_ocr_dao_list_documents_by_person( + ocr_dao, "33333333-3333-4333-8333-333333333333", &error); + g_assert_no_error(error); + g_assert_cmpuint(documents->len, ==, 1); + IdentityDocumentObservationRecord *document = + g_ptr_array_index(documents, 0); + g_assert_nonnull(strstr(document->factual_notes, + context->revision_done ? "révision" : "tronqué")); + GPtrArray *fields = identity_ocr_dao_list_fields_by_document( + ocr_dao, document->id, &error); + g_assert_no_error(error); + gboolean accepted = FALSE; + gboolean modified = FALSE; + gboolean manual = FALSE; + for (guint index = 0; index < fields->len; index++) { + IdentityFieldObservationRecord *field = + g_ptr_array_index(fields, index); + accepted |= g_strcmp0(field->review_status, "accepted") == 0; + modified |= g_strcmp0(field->review_status, "modified") == 0 && + g_strcmp0(field->origin, "manual_override") == 0; + manual |= g_strcmp0(field->origin, "manual_entry") == 0; + } + g_assert_true(accepted); + g_assert_true(modified); + g_assert_true(manual); + char *person_identifier = NULL; + IdentityOcrRun *loaded = identity_ocr_dao_load_run( + ocr_dao, context->root, run_identifier, &person_identifier, &error); + g_assert_no_error(error); + g_assert_nonnull(loaded); + g_assert_cmpstr(person_identifier, ==, + "33333333-3333-4333-8333-333333333333"); + char *raw_before = g_strdup(identity_ocr_run_get_raw_text(loaded)); + if (!context->revision_done) { + g_assert_true(identity_ocr_run_set_corrected_transcription( + loaded, "TRANSCRIPTION SPECIMEN RÉVISÉE", + "2026-07-29T12:00:00Z")); + identity_ocr_run_set_factual_notes( + loaded, "Document SPECIMEN incomplet — révision."); + g_assert_true(identity_ocr_dao_update_review( + ocr_dao, loaded, "2026-07-29T12:00:00Z", &error)); + g_assert_no_error(error); + context->revision_done = TRUE; + } + identity_ocr_run_free(loaded); + g_free(person_identifier); + g_ptr_array_unref(fields); + g_ptr_array_unref(documents); + g_ptr_array_unref(runs); + g_ptr_array_unref(evidences); + identity_ocr_dao_free(ocr_dao); + evidence_entity_dao_free(link_dao); + evidence_dao_free(evidence_dao); + database_close(database); + database = database_open(context->database_path); + g_assert_nonnull(database); + ocr_dao = identity_ocr_dao_new(database); + loaded = identity_ocr_dao_load_run( + ocr_dao, context->root, run_identifier, NULL, &error); + g_assert_no_error(error); + g_assert_nonnull(loaded); + g_assert_cmpstr(identity_ocr_run_get_raw_text(loaded), ==, raw_before); + g_assert_cmpstr(identity_ocr_run_get_corrected_transcription(loaded), + ==, "TRANSCRIPTION SPECIMEN RÉVISÉE"); + g_assert_nonnull(strstr(identity_ocr_run_get_factual_notes(loaded), + "révision")); + identity_ocr_run_free(loaded); + identity_ocr_dao_free(ocr_dao); + database_close(database); + g_free(raw_before); + g_free(run_identifier); +} + +static void workspace_identity_requested(const char *evidence_identifier, + gboolean revise_existing, const char *ocr_run_identifier, gpointer data) +{ + TestContext *context = data; + g_assert_cmpstr(evidence_identifier, ==, + context->final_evidence_identifier); + if (revise_existing) { + g_assert_cmpstr(ocr_run_identifier, ==, context->run_identifier); + context->revision_requests++; + } else { + g_assert_null(ocr_run_identifier); + context->rerun_requests++; + } +} + +static void load_workspace_from_reopened_database( + TestContext *context, gboolean trigger_actions) +{ + GError *error = NULL; + Database *database = database_open(context->database_path); + g_assert_nonnull(database); + EvidenceDao *evidence_dao = evidence_dao_new(database, &error); + g_assert_no_error(error); + GPtrArray *evidences = evidence_dao_list_all(evidence_dao, &error); + g_assert_no_error(error); + g_assert_cmpuint(evidences->len, ==, 1); + EvidenceRecord *evidence = g_ptr_array_index(evidences, 0); + g_assert_cmpstr(evidence_record_get_identifier(evidence), ==, + context->final_evidence_identifier); + + main_window_set_selected_evidence( + context->production_main_window, evidence); + IdentityOcrDao *ocr_dao = identity_ocr_dao_new(database); + GPtrArray *runs = identity_ocr_dao_list_runs_by_evidence( + ocr_dao, context->final_evidence_identifier, &error); + g_assert_no_error(error); + g_assert_cmpuint(runs->len, ==, 1); + GPtrArray *workspace_records = g_ptr_array_new_with_free_func( + (GDestroyNotify) workspace_identity_ocr_record_free); + for (guint index = 0; index < runs->len; index++) { + IdentityOcrRunRecord *record = g_ptr_array_index(runs, index); + char *person_identifier = NULL; + IdentityOcrRun *loaded = identity_ocr_dao_load_run( + ocr_dao, context->root, record->id, + &person_identifier, &error); + g_assert_no_error(error); + g_assert_nonnull(loaded); + g_assert_cmpstr(identity_ocr_run_get_evidence_id(loaded), + ==, context->final_evidence_identifier); + WorkspaceIdentityOcrRecord *workspace_record = + workspace_identity_ocr_record_new( + loaded, person_identifier, record->executed_at, + record->text_relative_path, record->text_sha256, + record->tsv_relative_path, record->tsv_sha256); + g_assert_nonnull(workspace_record); + g_ptr_array_add(workspace_records, workspace_record); + g_free(person_identifier); + } + main_window_set_identity_ocr_runs( + context->production_main_window, workspace_records); + g_ptr_array_unref(runs); + identity_ocr_dao_free(ocr_dao); + g_ptr_array_unref(evidences); + evidence_dao_free(evidence_dao); + database_close(database); + + GtkWidget *root = GTK_WIDGET(context->main_window); + GtkWidget *section = find_named( + root, "workspace-identity-ocr-section"); + GtkWidget *analyze = find_named(root, "workspace-analyze-identity"); + GtkDropDown *selector = GTK_DROP_DOWN(find_named( + root, "workspace-identity-ocr-selector")); + g_assert_nonnull(section); + g_assert_true(gtk_widget_get_visible(section)); + g_assert_nonnull(analyze); + g_assert_true(gtk_widget_get_sensitive(analyze)); + g_assert_nonnull(selector); + g_assert_cmpuint(g_list_model_get_n_items( + gtk_drop_down_get_model(selector)), ==, 1); + char *raw = text_view_contents(GTK_TEXT_VIEW(find_named( + root, "workspace-identity-ocr-raw"))); + char *corrected = text_view_contents(GTK_TEXT_VIEW(find_named( + root, "workspace-identity-ocr-corrected"))); + g_assert_nonnull(strstr(raw, "NOM : SPECIMEN")); + g_assert_cmpstr(corrected, ==, "TRANSCRIPTION SPECIMEN RÉVISÉE"); + g_free(raw); + g_free(corrected); + if (trigger_actions) { + g_signal_emit_by_name(find_named( + root, "workspace-revise-identity-ocr"), "clicked"); + g_signal_emit_by_name(find_named( + root, "workspace-rerun-identity-ocr"), "clicked"); + g_assert_cmpuint(context->revision_requests, ==, 1); + g_assert_cmpuint(context->rerun_requests, ==, 1); + } +} + +static gboolean drive(gpointer data) +{ + TestContext *context = data; + if (++context->guard > 1500) + g_error("Timeout du test GTK d’import OCR complet (phase %u)", + context->phase); + if (context->phase == 0) { + GtkWindow *dialog = find_window(context, "Importer une preuve"); + if (dialog == NULL) return G_SOURCE_CONTINUE; + GtkWidget *paned = + find_named(GTK_WIDGET(dialog), "evidence-import-paned"); + g_assert_true(GTK_IS_PANED(paned)); + g_assert_nonnull(gtk_paned_get_start_child(GTK_PANED(paned))); + g_assert_nonnull(gtk_paned_get_end_child(GTK_PANED(paned))); + g_signal_emit_by_name( + find_button(GTK_WIDGET(dialog), "Importer"), "clicked"); + context->phase++; + } else if (context->phase == 1) { + if (!context->metadata_ready) return G_SOURCE_CONTINUE; + GtkWindow *dialog = find_window( + context, "Import — OCR d’identité facultatif"); + if (dialog == NULL) return G_SOURCE_CONTINUE; + 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, ==, + context->layout_resize_pending ? 1000 : 1200); + g_assert_cmpint(default_height, ==, + context->layout_resize_pending ? 700 : 800); + GtkWidget *paned = find_named( + root, "evidence-identity-ocr-paned"); + GtkWidget *left = find_named(root, "identity-left-panel"); + GtkWidget *form_scroll = + find_named(root, "identity-form-scroll"); + GtkWidget *actions = + find_named(root, "identity-final-actions"); + g_assert_true(GTK_IS_PANED(paned)); + g_assert_nonnull(left); + g_assert_true(GTK_IS_SCROLLED_WINDOW(form_scroll)); + g_assert_nonnull(actions); + 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) + 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); + gtk_paned_set_position(GTK_PANED(paned), 650); + g_assert_cmpint( + gtk_paned_get_position(GTK_PANED(paned)), ==, 650); + gtk_window_set_default_size(dialog, 1000, 700); + context->layout_resize_pending = TRUE; + return G_SOURCE_CONTINUE; + } + g_assert_cmpint( + gtk_paned_get_position(GTK_PANED(paned)), ==, 650); + GtkDropDown *person = GTK_DROP_DOWN(find_named(root, + "identity-person")); + g_assert_nonnull(person); + g_assert_cmpuint(gtk_drop_down_get_selected(person), ==, 0); + gtk_drop_down_set_selected(person, 1); + gtk_drop_down_set_selected(GTK_DROP_DOWN(find_named( + root, "identity-document-type")), 0); + gtk_drop_down_set_selected(GTK_DROP_DOWN(find_named( + root, "identity-document-side")), 0); + GtkWidget *start = find_button( + root, "Analyser comme document d’identité"); + g_assert_nonnull(find_named(root, "identity-notes")); + g_assert_nonnull(find_entry_placeholder( + root, "Valeur visible mais omise par l’OCR")); + g_assert_true(gtk_widget_get_mapped( + find_button(root, "Annuler"))); + g_assert_true(gtk_widget_get_mapped( + find_button(root, "Importer sans OCR"))); + gtk_window_set_default_size(dialog, 760, 560); + gtk_window_get_default_size( + dialog, &default_width, &default_height); + g_assert_cmpint(default_width, ==, 760); + g_assert_cmpint(default_height, ==, 560); + g_assert_true(gtk_widget_get_sensitive(start)); + g_assert_cmpuint(count_field_rows(root), ==, 0); + g_signal_emit_by_name(start, "clicked"); + g_signal_emit_by_name(start, "clicked"); + context->phase++; + } else if (context->phase == 2) { + GtkWindow *dialog = find_window( + context, "Import — OCR d’identité facultatif"); + if (dialog == NULL) return G_SOURCE_CONTINUE; + GtkWidget *root = GTK_WIDGET(dialog); + int default_width = 0; + int default_height = 0; + gtk_window_get_default_size( + dialog, &default_width, &default_height); + g_assert_cmpint(default_width, ==, 760); + g_assert_cmpint(default_height, ==, 560); + GtkWidget *continue_button = + find_button(root, "Continuer l’import avec OCR"); + if (!gtk_widget_get_sensitive(continue_button)) + return G_SOURCE_CONTINUE; + GtkTextView *raw = GTK_TEXT_VIEW(find_named( + root, "identity-raw-text")); + g_assert_false(gtk_text_view_get_editable(raw)); + char *raw_text = text_view_contents(raw); + g_assert_nonnull(strstr(raw_text, "NOM : SPECIMEN")); + GtkTextView *corrected = GTK_TEXT_VIEW(find_named( + root, "identity-corrected-transcription")); + g_assert_true(gtk_text_view_get_editable(corrected)); + char *initial_corrected = text_view_contents(corrected); + g_assert_cmpstr(initial_corrected, ==, raw_text); + g_free(initial_corrected); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(corrected), + "ÉTAT CIVIL\nNée à Besançon — Côte-d'Or", -1); + g_signal_emit_by_name(find_button( + root, "Enregistrer la correction"), "clicked"); + g_assert_false(gtk_text_view_get_editable(corrected)); + g_signal_emit_by_name(find_named( + root, "identity-corrected-transcription-reedit"), "clicked"); + g_assert_true(gtk_text_view_get_editable(corrected)); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(corrected), + "ÉTAT CIVIL\nNée à Besançon — Côte-d’Or", -1); + g_signal_emit_by_name(find_button( + root, "Enregistrer la correction"), "clicked"); + g_free(raw_text); + g_assert_cmpuint(count_field_rows(root), >=, 3); + GtkWidget *form_scroll = + find_named(root, "identity-form-scroll"); + GtkAdjustment *adjustment = gtk_scrolled_window_get_vadjustment( + GTK_SCROLLED_WINDOW(form_scroll)); + double bottom = gtk_adjustment_get_upper(adjustment) - + gtk_adjustment_get_page_size(adjustment); + g_assert_cmpfloat(bottom, >, 0.0); + gtk_adjustment_set_value(adjustment, bottom); + g_assert_true(gtk_widget_get_mapped( + find_button(root, "Annuler"))); + g_assert_true(gtk_widget_get_mapped( + find_button(root, "Importer sans OCR"))); + g_assert_true(gtk_widget_get_mapped(continue_button)); + GtkWidget *left = find_named(root, "identity-left-panel"); + GtkWidget *actions = + find_named(root, "identity-final-actions"); + graphene_rect_t action_bounds; + g_assert_true(gtk_widget_compute_bounds( + actions, left, &action_bounds)); + g_assert_cmpfloat( + action_bounds.origin.y + action_bounds.size.height, <=, + (float) gtk_widget_get_height(left)); + GtkWidget *modified = find_field_row(root, 1); + g_assert_nonnull(modified); + GtkWidget *entry = g_object_get_data( + G_OBJECT(modified), "identity-value-entry"); + GtkWidget *modify = g_object_get_data( + G_OBJECT(modified), "identity-modify-button"); + gtk_editable_set_text(GTK_EDITABLE(entry), "SPECIMEN CORRIGÉ"); + g_signal_emit_by_name(modify, "clicked"); + modified = find_field_row(root, 1); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(modified), "identity-reedit-button"), "clicked"); + modified = find_field_row(root, 1); + entry = g_object_get_data( + G_OBJECT(modified), "identity-value-entry"); + modify = g_object_get_data( + G_OBJECT(modified), "identity-modify-button"); + g_assert_true(gtk_editable_get_editable(GTK_EDITABLE(entry))); + gtk_editable_set_text(GTK_EDITABLE(entry), "SPECIMEN FINAL"); + g_signal_emit_by_name(modify, "clicked"); + GtkWidget *accepted = find_field_row(root, 0); + g_assert_nonnull(accepted); + g_signal_emit_by_name( + find_button(accepted, "Accepter"), "clicked"); + GtkWidget *manual = find_entry_placeholder( + root, "Valeur visible mais omise par l’OCR"); + gtk_editable_set_text(GTK_EDITABLE(manual), "VISIBLE SPECIMEN"); + g_signal_emit_by_name( + find_button(root, "Ajouter un champ manquant"), "clicked"); + GtkWidget *manual_row = + find_field_row(root, count_field_rows(root) - 1); + g_assert_nonnull(manual_row); + g_signal_emit_by_name(g_object_get_data( + G_OBJECT(manual_row), "identity-modify-button"), "clicked"); + GtkTextView *notes = + GTK_TEXT_VIEW(find_named(root, "identity-notes")); + gtk_text_buffer_set_text(gtk_text_view_get_buffer(notes), + "Document SPECIMEN tronqué sur le bord droit.", -1); + gtk_adjustment_set_value(adjustment, 0.0); + GtkDropDown *person = GTK_DROP_DOWN( + find_named(root, "identity-person")); + g_assert_cmpuint(gtk_drop_down_get_selected(person), ==, 1); + g_signal_emit_by_name( + find_button(root, "Voir la zone"), "clicked"); + gtk_drop_down_set_selected(person, 0); + g_signal_emit_by_name(continue_button, "clicked"); + g_assert_nonnull(find_label( + root, "Sélectionnez une personne avant de poursuivre.")); + g_assert_true(gtk_widget_get_sensitive(continue_button)); + g_assert_cmpuint(context->import_start_count, ==, 0); + gtk_drop_down_set_selected(person, 1); + g_signal_emit_by_name(continue_button, "clicked"); + g_assert_false(gtk_widget_get_sensitive(continue_button)); + g_assert_nonnull(find_label(root, "Import OCR en cours")); + g_signal_emit_by_name(continue_button, "clicked"); + g_assert_cmpuint(context->import_start_count, ==, 1); + context->phase++; + } else if (context->phase == 3) { + if (!context->import_done) return G_SOURCE_CONTINUE; + g_assert_null(find_window( + context, "Import — OCR d’identité facultatif")); + verify_persistence(context); + load_workspace_from_reopened_database(context, TRUE); + GError *error = NULL; + g_assert_true(evidence_identity_ocr_dialog_present( + context->main_window, context->file_path, context->persons, + "33333333-3333-4333-8333-333333333333", + tool_registry_find(context->registry, "tesseract"), NULL, + cancellation_completed, context, NULL, &error)); + g_assert_no_error(error); + context->phase++; + } else if (context->phase == 4) { + GtkWindow *dialog = find_window( + context, "Import — OCR d’identité facultatif"); + if (dialog == NULL) return G_SOURCE_CONTINUE; + GtkWidget *start = find_button( + GTK_WIDGET(dialog), "Analyser comme document d’identité"); + if (!gtk_widget_get_sensitive(start)) return G_SOURCE_CONTINUE; + g_signal_emit_by_name(start, "clicked"); + g_signal_emit_by_name( + find_button(GTK_WIDGET(dialog), "Annuler"), "clicked"); + context->phase++; + } else if (context->phase == 5) { + if (!context->cancellation_done) return G_SOURCE_CONTINUE; + verify_persistence(context); + load_workspace_from_reopened_database(context, FALSE); + g_assert_true(gtk_widget_get_visible( + GTK_WIDGET(context->main_window))); + context->passed = TRUE; + g_application_quit(G_APPLICATION(context->application)); + return G_SOURCE_REMOVE; + } + return G_SOURCE_CONTINUE; +} + +static void write_png(const char *path) +{ + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, 480, 280); + 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, 24, 60); + cairo_show_text(cr, "SPECIMEN IDENTITE"); + g_assert_cmpint(cairo_surface_write_to_png(surface, path), ==, + CAIRO_STATUS_SUCCESS); + cairo_destroy(cr); + cairo_surface_destroy(surface); +} + +static void activate(GtkApplication *application, gpointer data) +{ + TestContext *context = data; + GError *error = NULL; + char *database_directory = + g_build_filename(context->root, "00_BaseDeDonnees", NULL); + g_assert_cmpint(g_mkdir_with_parents(database_directory, 0700), ==, 0); + context->database_path = + g_build_filename(database_directory, "Enquete.sqlite", NULL); + g_assert_true(database_initialize(context->database_path, + "Enquête SPECIMEN import OCR", context->root)); + write_png(context->file_path); + Database *database = database_open(context->database_path); + EntityDao *entity_dao = entity_dao_new(database, &error); + EntityRecord *person = entity_record_new( + "33333333-3333-4333-8333-333333333333", "person", + "PERSONNE SPECIMEN", "PERSONNE SPECIMEN", NULL, 0, + "2026-07-29T10:00:00Z", "2026-07-29T10:00:00Z", + ENTITY_STATUS_ACTIVE, &error); + g_assert_true(entity_dao_insert(entity_dao, person, &error)); + g_assert_no_error(error); + g_ptr_array_add(context->persons, person); + entity_dao_free(entity_dao); + database_close(database); + context->registry = tool_registry_new(); + char *tool = g_canonicalize_filename("tests/fake_document_tool", NULL); + g_assert_true(tool_registry_register(context->registry, "tesseract", + "Tesseract SPECIMEN", tool, TOOL_REQUIREMENT_OPTIONAL, &error)); + g_assert_true(tool_registry_refresh(context->registry, &error)); + g_assert_true(tool_registry_set_version(context->registry, "tesseract", + "5.0.0 SPECIMEN", &error)); + g_assert_no_error(error); + context->production_main_window = + main_window_new(application, context->manager); + g_assert_nonnull(context->production_main_window); + main_window_set_identity_ocr_callback( + context->production_main_window, + workspace_identity_requested, context); + context->main_window = + main_window_get_window(context->production_main_window); + main_window_present(context->production_main_window); + GPtrArray *types = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_type_free); + g_ptr_array_add(types, evidence_type_new( + 1, "document", "Document SPECIMEN", NULL, &error)); + g_assert_no_error(error); + g_assert_true(evidence_import_dialog_present( + context->main_window, context->file_path, types, + metadata_completed, context, &error)); + g_assert_no_error(error); + g_ptr_array_unref(types); + g_free(tool); + g_free(database_directory); + g_timeout_add(10, drive, context); +} + +int main(int argc, char **argv) +{ + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + g_setenv("LABFY_FAKE_IDENTITY_OCR", "1", TRUE); + TestContext context = {0}; + context.root = + g_dir_make_tmp("labfy-identity-import-gtk-XXXXXX", NULL); + context.file_path = + g_build_filename(context.root, "SPECIMEN-identite.png", NULL); + context.persons = g_ptr_array_new_with_free_func( + (GDestroyNotify) entity_record_free); + context.manager = task_manager_new(); + context.application = gtk_application_new( + "org.labfy.Investigation.IdentityImportTest", + G_APPLICATION_NON_UNIQUE); + g_signal_connect(context.application, "activate", + G_CALLBACK(activate), &context); + int status = g_application_run( + G_APPLICATION(context.application), argc, argv); + g_assert_true(context.passed); + background_task_unref(context.import_task); + evidence_import_dialog_result_free(context.metadata); + tool_registry_free(context.registry); + main_window_free(context.production_main_window); + task_manager_free(context.manager); + g_ptr_array_unref(context.persons); + g_object_unref(context.application); + g_free(context.database_path); + g_free(context.file_path); + g_free(context.final_evidence_identifier); + g_free(context.run_identifier); + remove_tree(context.root); + g_free(context.root); + return status; +} diff --git a/tests/test_evidence_metadata_dialog_gtk.c b/tests/test_evidence_metadata_dialog_gtk.c new file mode 100644 index 0000000..49ae2b5 --- /dev/null +++ b/tests/test_evidence_metadata_dialog_gtk.c @@ -0,0 +1,513 @@ +#include "views/evidence_metadata_dialog.h" +#include "dao/entity_dao.h" +#include "dao/evidence_dao.h" +#include "dao/identity_ocr_dao.h" +#include "database/database.h" +#include "models/entity_record.h" +#include "models/evidence_type.h" +#include "widgets/ocr_provenance_overlay.h" +#include +#include + +#define EVIDENCE_ID "11111111-1111-4111-8111-111111111111" +#define PERSON_A "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +#define PERSON_B "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +#define SHA_EVIDENCE "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +#define SHA_A "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +#define SHA_B "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +#define SHA_C "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + +typedef struct { + GtkApplication *application; + GtkWindow *main_window; + EvidenceRecord *record; + GPtrArray *types; + char *root; + char *database_path; + Database *database; + char *run_a; + char *run_b; + char *run_c; + guint phase; + guint close_cycles; + guint callbacks; + gboolean revision_requested; + gboolean relaunch_requested; + gboolean passed; +} TestContext; + +static gboolean drive(gpointer data); + +static void remove_tree(const char *path) +{ + GDir *directory = g_dir_open(path, 0, NULL); + if (directory != NULL) { + const char *name = NULL; + while ((name = g_dir_read_name(directory)) != NULL) { + char *child = g_build_filename(path, name, NULL); + if (g_file_test(child, G_FILE_TEST_IS_DIR)) remove_tree(child); + else g_remove(child); + g_free(child); + } + g_dir_close(directory); + } + g_rmdir(path); +} + +static GtkWindow *find_metadata_window(TestContext *context) +{ + for (GList *item = gtk_application_get_windows(context->application); + item != NULL; item = item->next) { + GtkWindow *window = item->data; + if (g_strcmp0(gtk_window_get_title(window), + "Modifier les métadonnées") == 0) return window; + } + return NULL; +} + +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 GtkWidget *find_button(GtkWidget *widget, const char *label) +{ + if (GTK_IS_BUTTON(widget) && + g_strcmp0(gtk_button_get_label(GTK_BUTTON(widget)), label) == 0) + return widget; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + GtkWidget *found = find_button(child, label); + if (found != NULL) return found; + } + return NULL; +} + +static char *text_view_text(GtkWidget *widget) +{ + GtkTextBuffer *buffer = + gtk_text_view_get_buffer(GTK_TEXT_VIEW(widget)); + GtkTextIter start; + GtkTextIter end; + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} + +static char *widget_text(GtkWidget *widget) +{ + GString *text = g_string_new(NULL); + if (GTK_IS_LABEL(widget)) + g_string_append(text, gtk_label_get_text(GTK_LABEL(widget))); + else if (GTK_IS_TEXT_VIEW(widget)) { + char *value = text_view_text(widget); + g_string_append(text, value); + g_free(value); + } + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) { + char *value = widget_text(child); + g_string_append_c(text, '\n'); + g_string_append(text, value); + g_free(value); + } + return g_string_free(text, FALSE); +} + +static IdentityOcrRun *new_run( + const char *type, const char *side, guint page, + const char *raw, const char *corrected, const char *note, + gboolean second) +{ + IdentityOcrRun *run = identity_ocr_run_new( + EVIDENCE_ID, SHA_EVIDENCE, type, side, page, + second ? "eng" : "fra", "none"); + identity_ocr_run_set_outputs(run, + second ? "tesseract 5 B" : "tesseract 5 A", + second ? "eng" : "fra", "SPECIMEN", raw, + second ? "TSV RUN B" : "TSV RUN A"); + g_assert_true(identity_ocr_run_set_corrected_transcription( + run, corrected, "2026-07-29T10:10:00Z")); + identity_ocr_run_set_factual_notes(run, note); + IdentitySourceBox box = { + .page = (gint) page, + .x = second ? 51 : 11, .y = second ? 52 : 12, + .width = 80, .height = 20, + .image_width = 640, .image_height = 400, .available = TRUE + }; + IdentityFieldObservation *field = identity_field_observation_new( + second ? "surname" : "document_number", + second ? "RUN B RAW FIELD" : "RUN A RAW FIELD", + second ? 82.0 : 91.0, &box, 0); + if (second) + g_assert_true(identity_field_observation_modify( + field, "RUN B MODIFIÉ", "SPECIMEN")); + else + g_assert_true(identity_field_observation_accept(field)); + identity_ocr_run_add_field(run, field); + IdentityFieldObservation *human = second + ? identity_field_observation_new_manual( + "given_names", "RUN B MANUEL", 1) + : identity_field_observation_new( + "surname", "RUN A OCR NOM", 70.0, &box, 1); + if (!second) + g_assert_true(identity_field_observation_modify( + human, "RUN A SURCHARGE", "SPECIMEN")); + identity_ocr_run_add_field(run, human); + return run; +} + +static void insert_run(TestContext *context, gboolean second) +{ + GError *error = NULL; + IdentityOcrRun *run = new_run( + second ? "passport" : "identity_card", + second ? "identity_page" : "front", second ? 2 : 1, + second ? "RUN B OCR BRUT" : "RUN A OCR BRUT", + second ? "RUN B CORRIGÉ" : "RUN A CORRIGÉ", + second ? "NOTE RUN B" : "NOTE RUN A", second); + const char *directory = second + ? "02_Preuves_Traitees/OCR/run-b" + : "02_Preuves_Traitees/OCR/run-a"; + char *absolute_directory = + g_build_filename(context->root, directory, NULL); + g_assert_cmpint(g_mkdir_with_parents( + absolute_directory, 0700), ==, 0); + char *text_relative = g_build_filename(directory, "ocr.txt", NULL); + char *tsv_relative = g_build_filename(directory, "ocr.tsv", NULL); + char *text_path = g_build_filename(context->root, + text_relative, NULL); + char *tsv_path = g_build_filename(context->root, + tsv_relative, NULL); + g_assert_true(g_file_set_contents(text_path, + second ? "RUN B OCR BRUT" : "RUN A OCR BRUT", -1, &error)); + g_assert_no_error(error); + g_assert_true(g_file_set_contents(tsv_path, + second ? "TSV RUN B" : "TSV RUN A", -1, &error)); + g_assert_no_error(error); + IdentityOcrDao *dao = identity_ocr_dao_new(context->database); + g_assert_true(identity_ocr_dao_insert(dao, + second ? PERSON_B : PERSON_A, EVIDENCE_ID, run, + text_relative, second ? SHA_B : SHA_A, + tsv_relative, second ? SHA_B : SHA_A, + second ? "2026-07-29T11:00:00Z" + : "2026-07-29T10:00:00Z", &error)); + g_assert_no_error(error); + if (second) context->run_b = + g_strdup(identity_ocr_run_get_identifier(run)); + else context->run_a = + g_strdup(identity_ocr_run_get_identifier(run)); + identity_ocr_dao_free(dao); + identity_ocr_run_free(run); + g_free(absolute_directory); + g_free(text_relative); + g_free(tsv_relative); + g_free(text_path); + g_free(tsv_path); +} + +static void insert_run_c(TestContext *context) +{ + GError *error = NULL; + IdentityOcrRun *run = new_run( + "passport", "identity_page", 3, "RUN C OCR BRUT", + "RUN C CORRIGÉ", "NOTE RUN C", TRUE); + char *directory = g_build_filename(context->root, + "02_Preuves_Traitees/OCR/run-c", NULL); + g_mkdir_with_parents(directory, 0700); + char *text_path = g_build_filename(directory, "ocr.txt", NULL); + char *tsv_path = g_build_filename(directory, "ocr.tsv", NULL); + g_file_set_contents(text_path, "RUN C OCR BRUT", -1, NULL); + g_file_set_contents(tsv_path, "TSV RUN C", -1, NULL); + IdentityOcrDao *dao = identity_ocr_dao_new(context->database); + g_assert_true(identity_ocr_dao_insert(dao, PERSON_B, EVIDENCE_ID, + run, "02_Preuves_Traitees/OCR/run-c/ocr.txt", SHA_C, + "02_Preuves_Traitees/OCR/run-c/ocr.tsv", SHA_C, + "2026-07-29T12:00:00Z", &error)); + g_assert_no_error(error); + context->run_c = g_strdup(identity_ocr_run_get_identifier(run)); + identity_ocr_dao_free(dao); + identity_ocr_run_free(run); + g_free(directory); + g_free(text_path); + g_free(tsv_path); +} + +static void completed(EvidenceMetadataDialogResult *result, gpointer data) +{ + TestContext *context = data; + g_assert_null(result); + context->callbacks++; + g_test_message("fiche fermée phase=%u callbacks=%u", + context->phase, context->callbacks); + g_idle_add(drive, context); +} + +static void analyze_requested( + GtkWindow *dialog, const char *evidence_identifier, + gboolean revise_existing, const char *ocr_run_identifier, + gpointer data) +{ + TestContext *context = data; + g_assert_true(GTK_IS_WINDOW(dialog)); + g_assert_cmpstr(evidence_identifier, ==, EVIDENCE_ID); + if (revise_existing) { + g_assert_cmpstr(ocr_run_identifier, ==, context->run_a); + context->revision_requested = TRUE; + IdentityOcrDao *dao = identity_ocr_dao_new(context->database); + GError *error = NULL; + char *person = NULL; + IdentityOcrRun *run = identity_ocr_dao_load_run( + dao, context->root, context->run_a, &person, &error); + g_assert_no_error(error); + g_assert_cmpstr(person, ==, PERSON_A); + g_assert_true(identity_ocr_run_set_corrected_transcription( + run, "RUN A RÉVISÉ", "2026-07-29T11:30:00Z")); + g_assert_true(identity_ocr_dao_update_review( + dao, run, "2026-07-29T11:30:00Z", &error)); + g_assert_no_error(error); + identity_ocr_run_free(run); + identity_ocr_dao_free(dao); + g_free(person); + } else { + g_assert_null(ocr_run_identifier); + context->relaunch_requested = TRUE; + insert_run_c(context); + } +} + +static void assert_run_content( + GtkWindow *dialog, guint selected, const char *present, + const char *absent, const char *run_identifier, + const char *person, const char *note, const char *origin, + const char *artifact_sha) +{ + GtkDropDown *selector = GTK_DROP_DOWN(find_named( + GTK_WIDGET(dialog), "evidence-ocr-run-selector")); + g_assert_nonnull(selector); + g_assert_cmpuint(g_list_model_get_n_items( + gtk_drop_down_get_model(selector)), >, selected); + gtk_drop_down_set_selected(selector, selected); + while (g_main_context_iteration(NULL, FALSE)) {} + GtkWidget *section = find_named( + GTK_WIDGET(dialog), "evidence-ocr-readonly"); + char *all = widget_text(section); + g_assert_nonnull(g_strstr_len(all, -1, present)); + g_assert_nonnull(g_strstr_len(all, -1, run_identifier)); + g_assert_nonnull(g_strstr_len(all, -1, person)); + g_assert_nonnull(g_strstr_len(all, -1, note)); + g_assert_nonnull(g_strstr_len(all, -1, origin)); + g_assert_nonnull(g_strstr_len(all, -1, artifact_sha)); + g_assert_null(g_strstr_len(all, -1, absent)); + g_free(all); +} + +static gboolean present_dialog(TestContext *context) +{ + return evidence_metadata_dialog_present_with_ocr( + context->main_window, context->record, context->types, + context->database, context->root, + analyze_requested, completed, context); +} + +static gboolean drive(gpointer data) +{ + TestContext *context = data; + GtkWindow *dialog = find_metadata_window(context); + g_test_message("drive phase=%u fenêtre=%s cycles=%u", + context->phase, dialog != NULL ? "oui" : "non", + context->close_cycles); + if (dialog == NULL) { + if (context->phase == 1) { + insert_run(context, FALSE); + context->phase = 2; + } else if (context->phase == 3) { + insert_run(context, TRUE); + context->phase = 4; + } else if (context->phase == 6) { + database_close(context->database); + context->database = database_open(context->database_path); + g_assert_nonnull(context->database); + } + g_assert_true(present_dialog(context)); + g_idle_add(drive, context); + return G_SOURCE_REMOVE; + } + if (!gtk_widget_get_visible(GTK_WIDGET(dialog))) + return G_SOURCE_REMOVE; + GtkDropDown *selector = GTK_DROP_DOWN(find_named( + GTK_WIDGET(dialog), "evidence-ocr-run-selector")); + if (context->phase == 0) { + g_assert_null(selector); + g_assert_null(find_named(GTK_WIDGET(dialog), + "revise-existing-identity-ocr")); + g_assert_nonnull(find_named(GTK_WIDGET(dialog), + "analyze-existing-identity")); + context->phase = 1; + gtk_window_close(dialog); + } else if (context->phase == 2) { + g_assert_nonnull(selector); + g_assert_cmpuint(g_list_model_get_n_items( + gtk_drop_down_get_model(selector)), ==, 1); + assert_run_content(dialog, 0, "RUN A OCR BRUT", + "RUN B OCR BRUT", context->run_a, PERSON_A, + "NOTE RUN A", "manual_override", SHA_A); + context->phase = 3; + gtk_window_close(dialog); + } else if (context->phase == 4) { + g_assert_nonnull(selector); + g_assert_cmpuint(g_list_model_get_n_items( + gtk_drop_down_get_model(selector)), ==, 2); + assert_run_content(dialog, 0, "RUN A OCR BRUT", + "RUN B OCR BRUT", context->run_a, PERSON_A, + "NOTE RUN A", "manual_override", SHA_A); + GtkWidget *show = find_button( + GTK_WIDGET(dialog), "Voir la zone"); + g_assert_nonnull(show); + g_signal_emit_by_name(show, "clicked"); + GtkWidget *section = find_named( + GTK_WIDGET(dialog), "evidence-ocr-readonly"); + OcrProvenanceOverlay *overlay = g_object_get_data( + G_OBJECT(section), "ocr-readonly-overlay"); + g_assert_true(ocr_provenance_overlay_has_region(overlay)); + for (guint index = 0; index < 20; index++) + gtk_drop_down_set_selected(selector, index % 2); + assert_run_content(dialog, 1, "RUN B OCR BRUT", + "RUN A OCR BRUT", context->run_b, PERSON_B, + "NOTE RUN B", "manual_entry", SHA_B); + assert_run_content(dialog, 0, "RUN A CORRIGÉ", + "RUN B CORRIGÉ", context->run_a, PERSON_A, + "NOTE RUN A", "manual_override", SHA_A); + context->phase = 5; + g_signal_emit_by_name(find_named(GTK_WIDGET(dialog), + "revise-existing-identity-ocr"), "clicked"); + } else if (context->phase == 5) { + g_assert_nonnull(selector); + g_assert_true(context->revision_requested); + assert_run_content(dialog, 0, "RUN A RÉVISÉ", + "RUN B CORRIGÉ", context->run_a, PERSON_A, + "NOTE RUN A", "manual_override", SHA_A); + assert_run_content(dialog, 1, "RUN B CORRIGÉ", + "RUN A RÉVISÉ", context->run_b, PERSON_B, + "NOTE RUN B", "manual_entry", SHA_B); + context->phase = 6; + g_signal_emit_by_name(find_named(GTK_WIDGET(dialog), + "analyze-existing-identity"), "clicked"); + } else if (context->phase == 6) { + g_assert_nonnull(selector); + g_assert_true(context->relaunch_requested); + g_assert_cmpuint(g_list_model_get_n_items( + gtk_drop_down_get_model(selector)), ==, 3); + g_assert_cmpuint(gtk_drop_down_get_selected(selector), ==, 2); + assert_run_content(dialog, 2, "RUN C OCR BRUT", + "RUN A OCR BRUT", context->run_c, PERSON_B, + "NOTE RUN C", "manual_entry", SHA_C); + assert_run_content(dialog, 0, "RUN A RÉVISÉ", + "RUN C OCR BRUT", context->run_a, PERSON_A, + "NOTE RUN A", "manual_override", SHA_A); + context->phase = 7; + gtk_window_close(dialog); + } else { + context->close_cycles++; + if (context->close_cycles < 20) { + gtk_window_close(dialog); + } else { + context->passed = TRUE; + gtk_window_close(dialog); + g_application_quit(G_APPLICATION(context->application)); + } + } + return G_SOURCE_REMOVE; +} + +static void activate(GtkApplication *application, gpointer data) +{ + TestContext *context = data; + GError *error = NULL; + char *database_directory = + g_build_filename(context->root, "00_BaseDeDonnees", NULL); + g_mkdir_with_parents(database_directory, 0700); + context->database_path = + g_build_filename(database_directory, "Enquete.sqlite", NULL); + g_assert_true(database_initialize(context->database_path, + "Enquête fiche OCR SPECIMEN", context->root)); + context->database = database_open(context->database_path); + g_assert_nonnull(context->database); + EntityDao *entity_dao = entity_dao_new(context->database, &error); + EntityRecord *person_a = entity_record_new( + PERSON_A, "person", "PERSONNE A SPECIMEN", "PERSONNE A SPECIMEN", + NULL, 0, "2026-07-29T09:00:00Z", "2026-07-29T09:00:00Z", + ENTITY_STATUS_ACTIVE, &error); + EntityRecord *person_b = entity_record_new( + PERSON_B, "person", "PERSONNE B SPECIMEN", "PERSONNE B SPECIMEN", + NULL, 0, "2026-07-29T09:00:00Z", "2026-07-29T09:00:00Z", + ENTITY_STATUS_ACTIVE, &error); + g_assert_true(entity_dao_insert(entity_dao, person_a, &error)); + g_assert_true(entity_dao_insert(entity_dao, person_b, &error)); + g_assert_no_error(error); + entity_record_free(person_a); + entity_record_free(person_b); + entity_dao_free(entity_dao); + context->record = evidence_record_new( + EVIDENCE_ID, "SPECIMEN.png", "SPECIMEN.png", + "01_Preuves_Originales/SPECIMEN.png", "document", 8, + SHA_EVIDENCE, "2026-07-29T09:00:00Z", + "2026-07-29T09:00:00Z", "SPECIMEN", + "Preuve multi-run SPECIMEN", + EVIDENCE_INTEGRITY_STATUS_VALID, &error); + EvidenceDao *evidence_dao = evidence_dao_new(context->database, &error); + gboolean evidence_inserted = evidence_dao_insert( + evidence_dao, context->record, &error); + if (!evidence_inserted) + g_test_message("Insertion preuve SPECIMEN: %s", + error != NULL ? error->message : "erreur inconnue"); + g_assert_no_error(error); + g_assert_true(evidence_inserted); + evidence_dao_free(evidence_dao); + context->main_window = + GTK_WINDOW(gtk_application_window_new(application)); + gtk_window_present(context->main_window); + g_assert_true(present_dialog(context)); + g_idle_add(drive, context); + g_free(database_directory); +} + +int main(int argc, char **argv) +{ + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + TestContext context = {0}; + context.root = g_dir_make_tmp("labfy-metadata-ocr-gtk-XXXXXX", NULL); + context.types = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_type_free); + GError *error = NULL; + g_ptr_array_add(context.types, + evidence_type_new(1, "document", "Document SPECIMEN", NULL, &error)); + g_assert_no_error(error); + context.application = gtk_application_new( + "com.labfytools.test.metadata.ocr", + G_APPLICATION_NON_UNIQUE); + g_signal_connect(context.application, "activate", + G_CALLBACK(activate), &context); + int status = g_application_run( + G_APPLICATION(context.application), argc, argv); + g_assert_true(context.passed); + database_close(context.database); + g_ptr_array_unref(context.types); + evidence_record_free(context.record); + g_object_unref(context.application); + g_free(context.database_path); + g_free(context.run_a); + g_free(context.run_b); + g_free(context.run_c); + remove_tree(context.root); + g_free(context.root); + return status; +} diff --git a/tests/test_evidence_preview_widget_gtk.c b/tests/test_evidence_preview_widget_gtk.c index 5656be1..c76d7d2 100644 --- a/tests/test_evidence_preview_widget_gtk.c +++ b/tests/test_evidence_preview_widget_gtk.c @@ -1,4 +1,6 @@ #include "widgets/evidence_preview_widget.h" +#include "core/file_hash.h" +#include #include static void drain_until(EvidencePreviewWidget *widget, const char *state) @@ -71,6 +73,132 @@ static void test_widget_lifecycle_and_eml(void) g_free(directory); } +static void write_image(const char *path, const char *format) +{ + GError *error = NULL; + GdkPixbuf *pixbuf = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, + 640, 360); + g_assert_nonnull(pixbuf); + gdk_pixbuf_fill(pixbuf, 0x2f6fa8ff); + g_assert_true(gdk_pixbuf_save(pixbuf, path, format, &error, NULL)); + g_assert_no_error(error); + g_object_unref(pixbuf); +} + +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); + cairo_destroy(cr); + cairo_surface_destroy(surface); +} + +static EvidencePreviewRequest *request_for(const char *directory, + const char *name, const char *identifier, const char *mime, + guint64 generation) +{ + GError *error = NULL; + char *path = g_build_filename(directory, name, NULL); + 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( + directory, identifier, name, sha256, mime, generation); + g_free(sha256); + g_free(path); + return request; +} + +static void test_paned_png_jpeg_pdf(void) +{ + GError *error = NULL; + char *directory = + g_dir_make_tmp("labfy-preview-formats-XXXXXX", &error); + g_assert_no_error(error); + char *png = g_build_filename(directory, "SPECIMEN.png", NULL); + char *jpeg = g_build_filename(directory, "SPECIMEN.jpg", NULL); + char *pdf = g_build_filename(directory, "SPECIMEN.pdf", NULL); + write_image(png, "png"); + write_image(jpeg, "jpeg"); + write_pdf(pdf); + TaskManager *manager = task_manager_new(); + EvidencePreviewWidget *widget = + evidence_preview_widget_new(manager, NULL, NULL); + GtkWidget *paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); + GtkWidget *list = gtk_list_box_new(); + gtk_widget_set_size_request(list, 320, -1); + gtk_list_box_append(GTK_LIST_BOX(list), gtk_label_new("SPECIMEN.png")); + gtk_list_box_append(GTK_LIST_BOX(list), gtk_label_new("SPECIMEN.jpg")); + gtk_list_box_append(GTK_LIST_BOX(list), gtk_label_new("SPECIMEN.pdf")); + gtk_paned_set_start_child(GTK_PANED(paned), list); + gtk_paned_set_end_child(GTK_PANED(paned), + evidence_preview_widget_get_widget(widget)); + gtk_paned_set_position(GTK_PANED(paned), 380); + gtk_paned_set_resize_start_child(GTK_PANED(paned), FALSE); + gtk_paned_set_resize_end_child(GTK_PANED(paned), TRUE); + GtkWindow *window = GTK_WINDOW(gtk_window_new()); + gtk_window_set_default_size(window, 1200, 800); + gtk_window_set_child(window, paned); + gtk_window_present(window); + while (g_main_context_iteration(NULL, FALSE)); + g_assert_true(gtk_paned_get_start_child(GTK_PANED(paned)) == list); + g_assert_true(gtk_paned_get_end_child(GTK_PANED(paned)) == + evidence_preview_widget_get_widget(widget)); + g_assert_cmpint(gtk_widget_get_width( + evidence_preview_widget_get_widget(widget)), >, + gtk_widget_get_width(list)); + EvidencePreviewRequest *png_request = request_for( + directory, "SPECIMEN.png", + "10000000-0000-4000-8000-000000000001", "image/png", 1); + evidence_preview_widget_show(widget, png_request, "PNG SPECIMEN"); + drain_until(widget, "image"); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "image"); + EvidencePreviewRequest *jpeg_request = request_for( + directory, "SPECIMEN.jpg", + "10000000-0000-4000-8000-000000000002", "image/jpeg", 2); + evidence_preview_widget_show(widget, jpeg_request, "JPEG SPECIMEN"); + drain_until(widget, "image"); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "image"); + EvidencePreviewRequest *pdf_request = request_for( + directory, "SPECIMEN.pdf", + "10000000-0000-4000-8000-000000000003", "application/pdf", 3); + evidence_preview_widget_show(widget, pdf_request, "PDF SPECIMEN"); + drain_until(widget, "pdf"); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "pdf"); + 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); + evidence_preview_widget_show(widget, png_request, "résultat ancien"); + evidence_preview_widget_show(widget, jpeg_request, "résultat courant"); + drain_until(widget, "image"); + g_assert_cmpstr(evidence_preview_widget_get_state(widget), ==, "image"); + evidence_preview_widget_show(widget, pdf_request, "fermeture"); + gtk_window_destroy(window); + evidence_preview_widget_cancel(widget); + evidence_preview_widget_free(widget); + task_manager_free(manager); + evidence_preview_request_free(png_request); + evidence_preview_request_free(jpeg_request); + evidence_preview_request_free(pdf_request); + g_unlink(png); + g_unlink(jpeg); + g_unlink(pdf); + g_rmdir(directory); + g_free(png); + g_free(jpeg); + g_free(pdf); + g_free(directory); +} + int main(int argc, char **argv) { if (!gtk_init_check()) { @@ -80,5 +208,7 @@ int main(int argc, char **argv) g_test_init(&argc, &argv, NULL); g_test_add_func("/evidence-preview-widget/eml-lifecycle", test_widget_lifecycle_and_eml); + g_test_add_func("/evidence-preview-widget/paned-png-jpeg-pdf", + test_paned_png_jpeg_pdf); return g_test_run(); } diff --git a/tests/test_identity_ocr.c b/tests/test_identity_ocr.c new file mode 100644 index 0000000..5ef79b1 --- /dev/null +++ b/tests/test_identity_ocr.c @@ -0,0 +1,119 @@ +#include "core/identity_field_extractor.h" +#include "core/mrz_parser.h" +#include "core/ocr_analysis.h" +#include "core/ocr_region_geometry.h" +#include "models/identity_ocr.h" +static void test_models_review(void) +{ + IdentitySourceBox box={.page=1,.x=10,.y=20,.width=80,.height=16, + .image_width=800,.image_height=500,.available=TRUE}; + IdentityFieldObservation*f=identity_field_observation_new("surname", + "SPÉCIMEN-D'ESSAI",87.5,&box,0);g_assert_nonnull(f); + g_assert_cmpint(identity_field_observation_get_status(f),==,IDENTITY_REVIEW_PROPOSED); + g_assert_true(identity_field_observation_accept(f)); + g_assert_true(identity_field_observation_modify(f,"SPÉCIMEN-D'ESSAI","revue")); + g_assert_cmpstr(identity_field_observation_get_raw_value(f),==,"SPÉCIMEN-D'ESSAI"); + g_assert_cmpint(identity_field_observation_get_status(f),==,IDENTITY_REVIEW_MODIFIED); + g_assert_true(identity_field_observation_modify(f,"SPÉCIMEN-D’ESSAI","seconde revue")); + g_assert_cmpstr(identity_field_observation_get_raw_value(f),==,"SPÉCIMEN-D'ESSAI"); + g_assert_true(identity_field_observation_restore_raw(f)); + g_assert_null(identity_field_observation_get_corrected_value(f)); + g_assert_cmpint(identity_field_observation_get_status(f),==,IDENTITY_REVIEW_PROPOSED); + g_assert_true(identity_field_observation_modify(f,"SPÉCIMEN-D’ESSAI","revue finale")); + IdentityFieldObservation*c=identity_field_observation_copy(f); + identity_field_observation_reject(c); + g_assert_cmpint(identity_field_observation_get_status(c),==,IDENTITY_REVIEW_REJECTED); + identity_field_observation_free(c);identity_field_observation_free(f); + IdentityFieldObservation *manual = + identity_field_observation_new_manual("nationality","FRANÇAISE",1); + g_assert_nonnull(manual); + g_assert_null(identity_field_observation_get_raw_value(manual)); + g_assert_cmpfloat(identity_field_observation_get_confidence(manual),==,-1.0); + g_assert_cmpstr(identity_field_observation_get_origin(manual),==,"manual_entry"); + g_assert_cmpint(identity_field_observation_get_status(manual),==, + IDENTITY_REVIEW_PROPOSED); + g_assert_true(identity_field_observation_modify( + manual,"FRANÇAISE","saisie manuelle contrôlée")); + g_assert_cmpstr(identity_field_observation_get_origin(manual),==,"manual_entry"); + identity_field_observation_free(manual); + IdentityOcrRun*r=identity_ocr_run_new("11111111-1111-4111-8111-111111111111", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identity_card","front",1,"fra+eng","0"); + g_assert_nonnull(r);identity_ocr_run_set_outputs(r,"5.5","fra\neng","params", + "NOM : SPECIMEN","tsv"); + identity_ocr_run_set_factual_notes(r, + "Document tronqué. Fragment visible : « publique française »."); + g_assert_true(identity_ocr_run_set_corrected_transcription(r, + "NOM : SPÉCIMEN CORRIGÉ","2026-07-29T10:00:00Z")); + g_assert_true(identity_ocr_run_has_human_transcription(r)); + IdentityOcrRun*copy=identity_ocr_run_copy(r); + g_assert_cmpstr(identity_ocr_run_get_raw_text(copy),==,"NOM : SPECIMEN"); + g_assert_cmpstr(identity_ocr_run_get_corrected_transcription(copy),==, + "NOM : SPÉCIMEN CORRIGÉ"); + g_assert_cmpstr(identity_ocr_run_get_transcription_corrected_at(copy),==, + "2026-07-29T10:00:00Z"); + g_assert_cmpstr(identity_ocr_run_get_factual_notes(copy),==, + "Document tronqué. Fragment visible : « publique française »."); + identity_ocr_run_reset_corrected_transcription(copy); + g_assert_false(identity_ocr_run_has_human_transcription(copy)); + g_assert_null(identity_ocr_run_get_corrected_transcription(copy)); + identity_ocr_run_free(copy);identity_ocr_run_free(r); +} +static void test_extractor(void) +{ + const char*text="NOM : SPÉCIMEN\nPRÉNOMS : ALICE TEST\n" + "DATE DE NAISSANCE : 01/01/1990\nNUMÉRO DU DOCUMENT : TEST000000\n"; + const char*tsv="level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n" + "5\t1\t1\t1\t1\t1\t120\t20\t100\t20\t91\tSPÉCIMEN\n"; + GPtrArray*a=identity_field_extractor_extract(text,tsv,800,500,NULL); + g_assert_cmpuint(a->len,==,4);IdentityFieldObservation*f=g_ptr_array_index(a,0); + g_assert_true(identity_field_observation_get_box(f)->available); + g_assert_cmpint(identity_field_observation_get_status(f),==,IDENTITY_REVIEW_PROPOSED); + g_ptr_array_unref(a); +} +static void test_mrz(void) +{ + g_assert_cmpint(mrz_parser_check_digit("L898902C3"),==,6); + MrzParseResult*r=mrz_parser_parse( + "Pstructure_valid);g_assert_true(r->check_digits_valid); + g_assert_cmpstr(r->document_number,==,"L898902C3"); + mrz_parse_result_free(r); +} +static void test_geometry(void) +{ + OcrDisplayRegion r; + g_assert_true(ocr_region_geometry_transform(1000,500,100,50,200,100, + 500,500,OCR_REGION_FIT_CONTAIN,&r)); + g_assert_cmpfloat_with_epsilon(r.x,50,0.01); + g_assert_cmpfloat_with_epsilon(r.y,150,0.01); + g_assert_cmpfloat_with_epsilon(r.width,100,0.01); + g_assert_true(ocr_region_geometry_transform(500,1000,-10,900,100,200, + 1000,500,OCR_REGION_FIT_CONTAIN,&r)); + g_assert_false(ocr_region_geometry_transform(0,100,0,0,1,1,10,10, + OCR_REGION_FIT_CONTAIN,&r)); + g_assert_false(ocr_region_geometry_transform(100,100,10,10,0,1,10,10, + OCR_REGION_FIT_CONTAIN,&r)); +} +static void test_languages(void) +{ + GPtrArray*parsed=ocr_analysis_parse_languages( + "List of available languages in /tmp (4):\neng\nfra\neng\nosd\n"); + g_assert_cmpuint(parsed->len,==,3); + GPtrArray*choices=ocr_analysis_build_language_choices(parsed); + g_assert_cmpuint(choices->len,==,4); + g_assert_cmpstr(g_ptr_array_index(choices,3),==,"fra+eng"); + g_ptr_array_unref(choices);g_ptr_array_unref(parsed); + parsed=ocr_analysis_parse_languages(NULL); + choices=ocr_analysis_build_language_choices(parsed); + g_assert_cmpuint(choices->len,==,0); + g_ptr_array_unref(choices);g_ptr_array_unref(parsed); +} +int main(int argc,char**argv){g_test_init(&argc,&argv,NULL); + g_test_add_func("/identity-ocr/models-review",test_models_review); + g_test_add_func("/identity-ocr/extractor",test_extractor); + g_test_add_func("/identity-ocr/mrz",test_mrz); + g_test_add_func("/identity-ocr/geometry",test_geometry); + g_test_add_func("/identity-ocr/languages",test_languages); + return g_test_run();} diff --git a/tests/test_identity_ocr_preprocessor.c b/tests/test_identity_ocr_preprocessor.c new file mode 100644 index 0000000..d231094 --- /dev/null +++ b/tests/test_identity_ocr_preprocessor.c @@ -0,0 +1,275 @@ +#include "core/identity_ocr_preprocessor.h" +#include "core/file_hash.h" +#include "core/ocr_analysis.h" +#include "core/tool_registry.h" +#include +#include +#include +#include +#include +#include +#include + +static void remove_fixture(char *root, char *path, char *sha) +{ + g_unlink(path); g_rmdir(root); g_free(path); g_free(root); g_free(sha); +} + +static void test_png_profiles_integrity_cleanup(void) +{ + GError *error = NULL; + char *root = g_dir_make_tmp("labfy-ocr-preprocess-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN.png", NULL); + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_RGB24, 80, 40); + 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, 4, 24); + cairo_show_text(cr, "SPECIMEN"); + g_assert_cmpint(cairo_surface_write_to_png(surface, path), ==, + CAIRO_STATUS_SUCCESS); + cairo_destroy(cr); cairo_surface_destroy(surface); + char *sha = NULL; guint64 size = 0; + g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); + for (guint profile = IDENTITY_OCR_PREPROCESS_NONE; + profile <= IDENTITY_OCR_PREPROCESS_UPSCALE; profile++) { + EvidencePreviewRequest *request = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000109", "SPECIMEN.png", sha, + "image/png", profile); + IdentityOcrWorkImage *image = identity_ocr_preprocessor_prepare( + request, 1, profile, NULL, &error); + g_assert_no_error(error); g_assert_nonnull(image); + g_assert_cmpuint(strlen(image->sha256), ==, 64); + char *work_path = g_strdup(image->path); + identity_ocr_work_image_free(image); + g_assert_false(g_file_test(work_path, G_FILE_TEST_EXISTS)); + g_free(work_path); evidence_preview_request_free(request); + } + char *after = NULL; guint64 after_size = 0; + g_assert_true(file_hash_compute_sha256(path, NULL, &after, &after_size, + &error)); + g_assert_cmpstr(after, ==, sha); g_free(after); + EvidencePreviewRequest *bad = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000109", "SPECIMEN.png", + "0000000000000000000000000000000000000000000000000000000000000000", + "image/png", 1); + g_assert_null(identity_ocr_preprocessor_prepare(bad, 1, + IDENTITY_OCR_PREPROCESS_NONE, NULL, &error)); + g_assert_nonnull(error); g_clear_error(&error); + evidence_preview_request_free(bad); + GCancellable *cancel = g_cancellable_new(); g_cancellable_cancel(cancel); + bad = evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000109", "SPECIMEN.png", sha, + "image/png", 1); + g_assert_null(identity_ocr_preprocessor_prepare(bad, 1, + IDENTITY_OCR_PREPROCESS_NONE, cancel, &error)); + g_assert_nonnull(error); + g_clear_error(&error); g_object_unref(cancel); + evidence_preview_request_free(bad); + remove_fixture(root, path, sha); +} + +static void test_missing(void) +{ + EvidencePreviewRequest *request = evidence_preview_request_new("/tmp", + "10000000-0000-4000-8000-000000000109", "ABSENT-SPECIMEN.png", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image/png", 1); + GError *error = NULL; + g_assert_null(identity_ocr_preprocessor_prepare(request, 1, + IDENTITY_OCR_PREPROCESS_NONE, NULL, &error)); + g_assert_nonnull(error); g_clear_error(&error); + evidence_preview_request_free(request); +} +static IdentityOcrWorkImage *prepare_path(const char*root,const char*name, + const char*mime,guint page,IdentityOcrPreprocessProfile profile,char**sha) +{ + GError*error=NULL;guint64 size=0;char*path=g_build_filename(root,name,NULL); + g_assert_true(file_hash_compute_sha256(path,NULL,sha,&size,&error)); + EvidencePreviewRequest*r=evidence_preview_request_new(root, + "10000000-0000-4000-8000-000000000119",name,*sha,mime,1); + IdentityOcrWorkImage*out=identity_ocr_preprocessor_prepare(r,page,profile, + NULL,&error);g_assert_no_error(error);g_assert_nonnull(out); + evidence_preview_request_free(r);g_free(path);return out; +} +static void write_jpeg(const char*path,guint orientation) +{ + FILE*out=fopen(path,"wb");struct jpeg_compress_struct c; + struct jpeg_error_mgr e;c.err=jpeg_std_error(&e);jpeg_create_compress(&c); + jpeg_stdio_dest(&c,out);c.image_width=96;c.image_height=48; + c.input_components=3;c.in_color_space=JCS_RGB;jpeg_set_defaults(&c); + jpeg_start_compress(&c,TRUE); + if(orientation>0){guint8 exif[]={ 'E','x','i','f',0,0,'I','I',42,0,8,0,0,0, + 1,0,0x12,1,3,0,1,0,0,0,0,0,0,0,0,0,0,0}; + exif[24]=(guint8)orientation;jpeg_write_marker(&c,JPEG_APP0+1,exif,sizeof(exif));} + JSAMPLE row[96*3];for(guint x=0;x<96;x++){row[x*3]=x<48?255:0; + row[x*3+1]=x<48?0:255;row[x*3+2]=32;} + while(c.next_scanlinewidth,==,48); + g_assert_cmpint(image->height,==,96);}else{g_assert_cmpint(image->width,==,96); + g_assert_cmpint(image->height,==,48);} + identity_ocr_work_image_free(image);g_unlink(path);g_free(sha);g_free(path);g_free(name);} + g_rmdir(root);g_free(root); +} +static gboolean write_heif(const char*path) +{ + struct heif_context*c=heif_context_alloc();struct heif_encoder*encoder=NULL; + struct heif_image*image=NULL;struct heif_image_handle*handle=NULL; + struct heif_error e=heif_context_get_encoder_for_format(c, + heif_compression_HEVC,&encoder);if(e.code!=heif_error_Ok){heif_context_free(c);return FALSE;} + e=heif_image_create(64,40,heif_colorspace_RGB,heif_chroma_interleaved_RGB,&image); + if(e.code==heif_error_Ok)e=heif_image_add_plane(image,heif_channel_interleaved,64,40,8); + if(e.code==heif_error_Ok){gint stride=0;guint8*p=heif_image_get_plane(image, + heif_channel_interleaved,&stride);memset(p,127,(gsize)stride*40); + e=heif_context_encode_image(c,image,encoder,NULL,&handle);} + if(e.code==heif_error_Ok)e=heif_context_write_to_file(c,path); + if(handle)heif_image_handle_release(handle); + if(image)heif_image_release(image); + heif_encoder_release(encoder);heif_context_free(c);return e.code==heif_error_Ok; +} +static void test_heic_heif(void) +{ + GError*error=NULL;char*root=g_dir_make_tmp("labfy-ocr-heif-XXXXXX",&error); + char*heic=g_build_filename(root,"SPECIMEN",NULL); + if(!write_heif(heic)){g_test_skip("Encodeur HEVC indisponible.");g_rmdir(root); + g_free(heic);g_free(root);return;} + char*contents=NULL;gsize length=0; + g_assert_true(g_file_get_contents(heic,&contents,&length,&error)); + g_assert_no_error(error); + char*sha=NULL;IdentityOcrWorkImage*image=prepare_path(root,"SPECIMEN",NULL,1, + IDENTITY_OCR_PREPROCESS_NONE,&sha);g_assert_cmpint(image->width,==,64); + identity_ocr_work_image_free(image);g_free(sha);sha=NULL; + for(gsize i=0;i+4<=length;i++)if(memcmp(contents+i,"heic",4)==0)memcpy(contents+i,"mif1",4); + char*heif=g_build_filename(root,"SPECIMEN.heif",NULL); + g_assert_true(g_file_set_contents(heif,contents,(gssize)length,&error)); + g_assert_no_error(error); + image=prepare_path(root,"SPECIMEN.heif",NULL,1, + IDENTITY_OCR_PREPROCESS_GRAYSCALE,&sha);g_assert_cmpint(image->height,==,40); + identity_ocr_work_image_free(image);g_free(sha);g_free(contents); + g_unlink(heic);g_unlink(heif);g_free(heic);g_free(heif);g_rmdir(root);g_free(root); +} +static void test_pdf_pages(void) +{ + GError*error=NULL;char*root=g_dir_make_tmp("labfy-ocr-pdf-XXXXXX",&error); + char*path=g_build_filename(root,"SPECIMEN.pdf",NULL); + cairo_surface_t*s=cairo_pdf_surface_create(path,320,200);cairo_t*cr=cairo_create(s); + cairo_set_source_rgb(cr,1,0,0);cairo_paint(cr);cairo_show_page(cr); + cairo_set_source_rgb(cr,0,0,1);cairo_paint(cr);cairo_show_page(cr); + cairo_destroy(cr);cairo_surface_destroy(s); + char*sha1=NULL,*sha2=NULL;IdentityOcrWorkImage*p1=prepare_path(root, + "SPECIMEN.pdf","application/pdf",1,IDENTITY_OCR_PREPROCESS_NONE,&sha1); + IdentityOcrWorkImage*p2=prepare_path(root,"SPECIMEN.pdf","application/pdf",2, + IDENTITY_OCR_PREPROCESS_UPSCALE,&sha2); + g_assert_cmpstr(p1->sha256,!=,p2->sha256); + identity_ocr_work_image_free(p1);identity_ocr_work_image_free(p2); + guint64 size=0;char*source_after=NULL;file_hash_compute_sha256(path,NULL, + &source_after,&size,&error);g_assert_cmpstr(source_after,==,sha1); + g_free(source_after);g_free(sha1);g_free(sha2);g_unlink(path);g_free(path); + g_rmdir(root);g_free(root); +} +static void test_direct_limits(void) +{ + g_assert_false(identity_ocr_preprocessor_dimensions_are_safe(12001,1, + IDENTITY_OCR_PREPROCESS_NONE)); + g_assert_false(identity_ocr_preprocessor_dimensions_are_safe(1,12001, + IDENTITY_OCR_PREPROCESS_NONE)); + g_assert_false(identity_ocr_preprocessor_dimensions_are_safe(8001,5000, + IDENTITY_OCR_PREPROCESS_NONE)); + g_assert_false(identity_ocr_preprocessor_dimensions_are_safe(0,1, + IDENTITY_OCR_PREPROCESS_NONE)); + g_assert_false(identity_ocr_preprocessor_dimensions_are_safe(-1,1, + IDENTITY_OCR_PREPROCESS_NONE)); + g_assert_false(identity_ocr_preprocessor_dimensions_are_safe(6001,1, + IDENTITY_OCR_PREPROCESS_UPSCALE)); + g_assert_true(identity_ocr_preprocessor_dimensions_are_safe(2000,1000, + IDENTITY_OCR_PREPROCESS_UPSCALE)); +} + +static void test_real_tesseract_optional(void) +{ + GError *error = NULL; + ToolRegistry *registry = tool_registry_new(); + g_assert_true(tool_registry_register(registry, "ocr.tesseract", + "Tesseract OCR", "tesseract", TOOL_REQUIREMENT_OPTIONAL, &error)); + g_assert_true(tool_registry_refresh(registry, &error)); + const ToolInfo *tool = tool_registry_find(registry, "ocr.tesseract"); + if (tool == NULL || tool_info_get_availability(tool) != + TOOL_AVAILABILITY_AVAILABLE) { + g_test_skip("Tesseract absent."); + tool_registry_free(registry); return; + } + char *raw = ocr_analysis_list_languages( + tool_info_get_resolved_path(tool), NULL, &error); + if (raw == NULL) { + g_test_skip("Tesseract ne peut pas lister ses langues."); + g_clear_error(&error); tool_registry_free(registry); return; + } + GPtrArray *parsed = ocr_analysis_parse_languages(raw); + const char *language = NULL; + for (guint i = 0; i < parsed->len; i++) { + const char *candidate = g_ptr_array_index(parsed, i); + if (g_str_equal(candidate, "eng") || g_str_equal(candidate, "fra")) { + language = candidate; break; + } + } + if (language == NULL) { + g_test_skip("Aucune langue eng/fra installée."); + g_ptr_array_unref(parsed); g_free(raw); + tool_registry_free(registry); return; + } + char *root = g_dir_make_tmp("labfy-real-tesseract-XXXXXX", &error); + char *path = g_build_filename(root, "SPECIMEN.png", NULL); + cairo_surface_t *surface = cairo_image_surface_create( + CAIRO_FORMAT_RGB24, 1000, 260); + cairo_t *cr = cairo_create(surface); + cairo_set_source_rgb(cr, 1, 1, 1); cairo_paint(cr); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, + CAIRO_FONT_WEIGHT_BOLD); + cairo_set_font_size(cr, 100); cairo_set_source_rgb(cr, 0, 0, 0); + cairo_move_to(cr, 40, 160); cairo_show_text(cr, "SPECIMEN 109"); + cairo_surface_write_to_png(surface, path); + cairo_destroy(cr); cairo_surface_destroy(surface); + char *sha = NULL; guint64 size = 0; + g_assert_true(file_hash_compute_sha256(path, NULL, &sha, &size, &error)); + OcrAnalysisResult *result = ocr_analysis_run( + tool_info_get_resolved_path(tool), path, language, NULL, &error); + if (result == NULL) { + g_test_skip("Tesseract ne peut pas initialiser la fixture."); + g_clear_error(&error); + } else { + g_assert_nonnull(result->text); g_assert_cmpuint(strlen(result->text),>,0); + g_assert_nonnull(result->tsv); g_assert_nonnull(strstr(result->tsv,"left")); + g_assert_nonnull(result->execution->version); + g_assert_cmpstr(result->requested_languages, ==, language); + g_assert_cmpuint(strlen(sha), ==, 64); + ocr_analysis_result_free(result); + } + g_unlink(path); g_rmdir(root); g_free(path); g_free(root); g_free(sha); + g_ptr_array_unref(parsed); g_free(raw); tool_registry_free(registry); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/identity-preprocessor/png-profiles-integrity-cleanup", + test_png_profiles_integrity_cleanup); + g_test_add_func("/identity-preprocessor/missing", test_missing); + g_test_add_func("/identity-preprocessor/jpeg-exif",test_jpeg_and_exif); + g_test_add_func("/identity-preprocessor/heic-heif",test_heic_heif); + g_test_add_func("/identity-preprocessor/pdf-pages",test_pdf_pages); + g_test_add_func("/identity-preprocessor/limits",test_direct_limits); + g_test_add_func("/identity-preprocessor/real-tesseract", + test_real_tesseract_optional); + return g_test_run(); +} diff --git a/tests/test_ocr_provenance_overlay_gtk.c b/tests/test_ocr_provenance_overlay_gtk.c new file mode 100644 index 0000000..8707e30 --- /dev/null +++ b/tests/test_ocr_provenance_overlay_gtk.c @@ -0,0 +1,31 @@ +#include "widgets/ocr_provenance_overlay.h" + +int main(int argc, char **argv) +{ + (void) argc; (void) argv; + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + OcrProvenanceOverlay *overlay = ocr_provenance_overlay_new(); + GtkWidget *widget = ocr_provenance_overlay_get_widget(overlay); + g_object_ref_sink(widget); + 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)); + IdentityFieldObservation *without = identity_field_observation_new( + "given_names", "ALICE", 70, NULL, 1); + ocr_provenance_overlay_set_field(overlay, without, 3); + 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); + identity_field_observation_free(field); + identity_field_observation_free(without); + ocr_provenance_overlay_free(overlay); + g_object_unref(widget); + return 0; +} diff --git a/tests/test_person_creation_coordinator.c b/tests/test_person_creation_coordinator.c index 9046dfe..756ddac 100644 --- a/tests/test_person_creation_coordinator.c +++ b/tests/test_person_creation_coordinator.c @@ -2,6 +2,9 @@ #include "core/person_creation_coordinator.h" #include "database/database.h" #include "database/statement.h" +#include "database/transaction.h" +#include "dao/identity_ocr_dao.h" +#include "dao/entity_dao.h" #include #include @@ -18,6 +21,14 @@ static guint64 count(Database *database, const char *table) g_free(sql); return (guint64) value; } +static void execute_sql(Database *database, const char *sql) +{ + DatabaseStatement *statement = database_statement_prepare(database, sql); + g_assert_nonnull(statement); + g_assert_cmpint(database_statement_step(statement), ==, + DATABASE_STATEMENT_STEP_DONE); + database_statement_finalize(statement); +} static void remove_tree(const char *root) { GDir *directory = g_dir_open(root, 0, NULL); @@ -32,6 +43,290 @@ static void remove_tree(const char *root) g_dir_close(directory); g_rmdir(root); } +typedef struct { + guint calls; +} SessionCounter; +static gboolean session_changes_before_commit(gpointer data) +{ + SessionCounter *counter = data; + return counter->calls++ == 0; +} +static void assert_empty_after_reopen(const char *database_path) +{ + static const char *const tables[] = { + "entites", "preuves", "preuve_entites", "preuve_entite_sources", + "person_role_assignments", "identity_ocr_runs", + "identity_document_observations", "identity_field_observations" + }; + Database *database = database_open(database_path); + g_assert_nonnull(database); + for (guint i = 0; i < G_N_ELEMENTS(tables); i++) + g_assert_cmpuint(count(database, tables[i]), ==, 0); + database_close(database); +} +static void test_failure_matrix(void) +{ + static const struct { + PersonCreationFailurePoint point; + guint occurrence; + } cases[] = { + {PERSON_CREATION_FAILURE_VALIDATE,0}, + {PERSON_CREATION_FAILURE_SESSION_BEFORE_START,0}, + {PERSON_CREATION_FAILURE_SOURCE_HASH,0}, + {PERSON_CREATION_FAILURE_CREATE_OCR_DIRECTORY,0}, + {PERSON_CREATION_FAILURE_COPY_OCR_TEXT,0}, + {PERSON_CREATION_FAILURE_HASH_OCR_TEXT,0}, + {PERSON_CREATION_FAILURE_COPY_OCR_TSV,0}, + {PERSON_CREATION_FAILURE_HASH_OCR_TSV,0}, + {PERSON_CREATION_FAILURE_CREATE_PERSON,0}, + {PERSON_CREATION_FAILURE_CREATE_ROLE,0}, + {PERSON_CREATION_FAILURE_CREATE_ROLE,1}, + {PERSON_CREATION_FAILURE_CREATE_ROLE,2}, + {PERSON_CREATION_FAILURE_IMPORT_EVIDENCE,0}, + {PERSON_CREATION_FAILURE_IMPORT_EVIDENCE,1}, + {PERSON_CREATION_FAILURE_IMPORT_EVIDENCE,2}, + {PERSON_CREATION_FAILURE_LINK_EVIDENCE,0}, + {PERSON_CREATION_FAILURE_LINK_EVIDENCE,1}, + {PERSON_CREATION_FAILURE_LINK_EVIDENCE,2}, + {PERSON_CREATION_FAILURE_INSERT_OCR_RUN,0}, + {PERSON_CREATION_FAILURE_INSERT_OCR_RUN,1}, + {PERSON_CREATION_FAILURE_INSERT_OCR_RUN,2}, + {PERSON_CREATION_FAILURE_INSERT_DOCUMENT_OBSERVATION,0}, + {PERSON_CREATION_FAILURE_INSERT_DOCUMENT_OBSERVATION,1}, + {PERSON_CREATION_FAILURE_INSERT_DOCUMENT_OBSERVATION,2}, + {PERSON_CREATION_FAILURE_INSERT_FIELD,0}, + {PERSON_CREATION_FAILURE_INSERT_FIELD,1}, + {PERSON_CREATION_FAILURE_INSERT_FIELD,2}, + {PERSON_CREATION_FAILURE_CREATE_SOURCE,0}, + {PERSON_CREATION_FAILURE_CREATE_SOURCE,1}, + {PERSON_CREATION_FAILURE_CREATE_SOURCE,2}, + {PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT,0}, + {PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED,0}, + {PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED,0}, + {PERSON_CREATION_FAILURE_COMMIT,0}, + {PERSON_CREATION_FAILURE_COMMIT,0} + }; + for (guint scenario = 0; scenario < G_N_ELEMENTS(cases); scenario++) { + char *root = g_dir_make_tmp("labfy-coordinator-matrix-XXXXXX", NULL); + char *database_directory = g_build_filename( + root, "00_BaseDeDonnees", NULL); + char *database_path = g_build_filename( + database_directory, "Enquete.sqlite", NULL); + g_assert_cmpint(g_mkdir_with_parents(database_directory,0700),==,0); + g_assert_true(database_initialize(database_path, + "Enquête SPECIMEN matrice", root)); + EvidenceStaging *staging = evidence_staging_new(NULL); + PersonEvidenceSelection *selection = person_evidence_selection_new(); + GPtrArray *prepared = g_ptr_array_new_with_free_func( + (GDestroyNotify) evidence_staging_result_free); + GPtrArray *runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + GPtrArray *roles = g_ptr_array_new_with_free_func( + (GDestroyNotify) person_role_assignment_input_free); + for (guint i = 0; i < 3; i++) { + char *name = g_strdup_printf("SPECIMEN-%u.txt", i); + char *source = g_build_filename(root, name, NULL); + char *contents = g_strdup_printf("SPECIMEN SOURCE %u", i); + g_assert_true(g_file_set_contents(source, contents, -1, NULL)); + EvidenceStagingResult *item = evidence_staging_prepare( + staging, source, NULL, NULL); + g_assert_nonnull(item); + g_ptr_array_add(prepared, item); + g_assert_true(person_evidence_selection_add_staged(selection, + item->source_path, item->staging_path, item->original_name, + item->mime_type, "text", item->size_bytes, item->sha256, + NULL, item->prepared_at, NULL)); + const PersonEvidenceSelectionItem *selected = + person_evidence_selection_get(selection, i); + IdentityOcrRun *run = identity_ocr_run_new( + person_evidence_selection_item_get_identifier(selected), + item->sha256, "identity_card", "front", i + 1, + "eng", "none"); + identity_ocr_run_set_outputs(run, "SPECIMEN", "eng", "params", + "NOM: SPECIMEN\nPRÉNOM: TEST\nNUMÉRO: ABC123", + "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\t" + "left\ttop\twidth\theight\tconf\ttext\n"); + IdentityFieldObservation *field = + identity_field_observation_new("surname", "SPECIMEN", + 90, NULL, i); + identity_field_observation_accept(field); + identity_ocr_run_add_field(run, field); + g_ptr_array_add(runs, run); + PersonRoleAssignmentInput role = { + .role_code = i == 0 ? "victim" : + i == 1 ? "witness" : "mentioned_person", + .provenance_kind = "manual" + }; + g_ptr_array_add(roles, + person_role_assignment_input_copy(&role)); + g_free(name); g_free(source); g_free(contents); + } + PersonEntityInput person = { + .designation = "Personne SPECIMEN matrice", + .identification_status = "suspected", + .confidence = 10, + .role_assignments = roles + }; + PersonCreationCoordinatorOptions options = { + .failure_point = cases[scenario].point, + .failure_occurrence = cases[scenario].occurrence, + .inject_compensation_failure = + scenario == G_N_ELEMENTS(cases) - 1 + }; + char *ocr_parent=g_build_filename(root,"02_Preuves_Traitees", + "OCR",NULL); + gboolean parent_preexisted=scenario%2==0; + char *parent_marker=g_build_filename(ocr_parent,"SPECIMEN.marker",NULL); + if(parent_preexisted){ + g_assert_cmpint(g_mkdir_with_parents(ocr_parent,0700),==,0); + g_assert_true(g_file_set_contents(parent_marker,"SPECIMEN",-1,NULL)); + } + Database *database = database_open(database_path); + GError *error = NULL; + PersonCreationCoordinatorResult *result = + person_creation_coordinator_execute_with_options(database, root, + &person, selection, runs, &options, NULL, &error); + g_assert_null(result); + g_assert_nonnull(error); + if (options.inject_compensation_failure) + g_assert_nonnull(strstr(error->message, + "Compensation incomplète")); + g_clear_error(&error); + database_close(database); + assert_empty_after_reopen(database_path); + guint residual_runs=0; + for(guint i=0;ilen;i++){ + IdentityOcrRun *run=g_ptr_array_index(runs,i); + char *run_directory=g_build_filename(ocr_parent, + identity_ocr_run_get_identifier(run),NULL); + if(g_file_test(run_directory,G_FILE_TEST_EXISTS)) + residual_runs++; + if(!options.inject_compensation_failure&& + g_file_test(run_directory,G_FILE_TEST_EXISTS)) + g_error("Résidu au scénario %u, point %u occurrence %u, " + "run %u",scenario,(guint)cases[scenario].point, + cases[scenario].occurrence,i); + g_free(run_directory); + } + g_assert_cmpuint(residual_runs,==, + options.inject_compensation_failure?1:0); + if(parent_preexisted){ + g_assert_true(g_file_test(parent_marker,G_FILE_TEST_IS_REGULAR)); + }else if(!options.inject_compensation_failure) + g_assert_false(g_file_test(ocr_parent,G_FILE_TEST_EXISTS)); + for (guint i = 0; i < prepared->len; i++) { + EvidenceStagingResult *item = g_ptr_array_index(prepared, i); + char *contents = NULL; + g_assert_true(g_file_get_contents(item->source_path, + &contents, NULL, NULL)); + g_assert_true(g_str_has_prefix(contents, "SPECIMEN SOURCE")); + g_free(contents); + } + g_ptr_array_unref(roles); + g_ptr_array_unref(runs); + person_evidence_selection_free(selection); + g_ptr_array_unref(prepared); + evidence_staging_free(staging); + g_free(ocr_parent);g_free(parent_marker); + g_free(database_directory); g_free(database_path); + remove_tree(root); g_free(root); + } +} +static void test_real_session_change_before_commit(void) +{ + char *root = g_dir_make_tmp("labfy-session-before-commit-XXXXXX", NULL); + char *database_directory = g_build_filename(root,"00_BaseDeDonnees",NULL); + char *database_path = g_build_filename(database_directory, + "Enquete.sqlite", NULL); + char *source = g_build_filename(root, "SPECIMEN.txt", NULL); + g_assert_cmpint(g_mkdir_with_parents(database_directory,0700),==,0); + g_assert_true(database_initialize(database_path,"SPECIMEN",root)); + g_assert_true(g_file_set_contents(source,"SPECIMEN",-1,NULL)); + EvidenceStaging *staging = evidence_staging_new(NULL); + EvidenceStagingResult *prepared = + evidence_staging_prepare(staging,source,NULL,NULL); + PersonEvidenceSelection *selection = person_evidence_selection_new(); + g_assert_true(person_evidence_selection_add_staged(selection, + prepared->source_path,prepared->staging_path,prepared->original_name, + prepared->mime_type,"text",prepared->size_bytes,prepared->sha256, + NULL,prepared->prepared_at,NULL)); + PersonEntityInput person={.designation="SPECIMEN", + .identification_status="unknown"}; + SessionCounter counter={0}; + PersonCreationCoordinatorOptions options={ + .session_check=session_changes_before_commit, + .session_check_data=&counter}; + Database *database=database_open(database_path); + GError *error=NULL; + g_assert_null(person_creation_coordinator_execute_with_options(database, + root,&person,selection,NULL,&options,NULL,&error)); + g_assert_nonnull(error);g_clear_error(&error);database_close(database); + assert_empty_after_reopen(database_path); + person_evidence_selection_free(selection); + evidence_staging_result_free(prepared);evidence_staging_free(staging); + g_free(database_directory);g_free(database_path);g_free(source); + remove_tree(root);g_free(root); +} +static void test_existing_person_session_change_compensates(void) +{ + char *root=g_dir_make_tmp("labfy-existing-session-XXXXXX",NULL); + char *database_directory=g_build_filename(root,"00_BaseDeDonnees",NULL); + char *database_path=g_build_filename(database_directory,"Enquete.sqlite",NULL); + char *source=g_build_filename(root,"SPECIMEN-IDENTITE.txt",NULL); + g_assert_cmpint(g_mkdir_with_parents(database_directory,0700),==,0); + g_assert_true(database_initialize(database_path,"SPECIMEN",root)); + g_assert_true(g_file_set_contents(source,"SPECIMEN IDENTITE",-1,NULL)); + Database *database=database_open(database_path);GError *error=NULL; + EntityDao *entity_dao=entity_dao_new(database,&error); + EntityRecord *person=entity_record_new( + "22222222-2222-4222-8222-222222222222","person", + "PERSONNE SPECIMEN","PERSONNE SPECIMEN",NULL,0, + "2026-01-01T00:00:00Z","2026-01-01T00:00:00Z", + ENTITY_STATUS_ACTIVE,&error); + g_assert_true(entity_dao_insert(entity_dao,person,&error)); + entity_record_free(person);entity_dao_free(entity_dao); + EvidenceStaging *staging=evidence_staging_new(&error); + EvidenceStagingResult *prepared=evidence_staging_prepare( + staging,source,NULL,&error); + PersonEvidenceSelection *selection=person_evidence_selection_new(); + g_assert_true(person_evidence_selection_add_staged(selection, + prepared->source_path,prepared->staging_path,prepared->original_name, + prepared->mime_type,"document",prepared->size_bytes,prepared->sha256, + "SPECIMEN",prepared->prepared_at,&error)); + const PersonEvidenceSelectionItem *item= + person_evidence_selection_get(selection,0); + IdentityOcrRun *run=identity_ocr_run_new( + person_evidence_selection_item_get_identifier(item),prepared->sha256, + "identity_card","front",1,"fra","none"); + identity_ocr_run_set_outputs(run,"SPECIMEN","fra","SPECIMEN", + "NOM SPECIMEN","TSV SPECIMEN"); + IdentityFieldObservation *field=identity_field_observation_new( + "surname","SPECIMEN",99,NULL,0); + identity_field_observation_accept(field); + identity_ocr_run_add_field(run,field); + GPtrArray *runs=g_ptr_array_new_with_free_func( + (GDestroyNotify)identity_ocr_run_free); + g_ptr_array_add(runs,run); + SessionCounter counter={0}; + PersonCreationCoordinatorOptions options={ + .session_check=session_changes_before_commit, + .session_check_data=&counter}; + g_assert_null(person_creation_coordinator_attach_to_existing_person( + database,root,"22222222-2222-4222-8222-222222222222", + selection,runs,NULL,&options,NULL,&error)); + g_assert_nonnull(error);g_clear_error(&error); + g_assert_cmpuint(count(database,"entites"),==,1); + g_assert_cmpuint(count(database,"preuves"),==,0); + g_assert_cmpuint(count(database,"preuve_entites"),==,0); + g_assert_cmpuint(count(database,"identity_ocr_runs"),==,0); + char *ocr_root=g_build_filename(root,"02_Preuves_Traitees","OCR",NULL); + g_assert_false(g_file_test(ocr_root,G_FILE_TEST_EXISTS)); + g_free(ocr_root);g_ptr_array_unref(runs); + person_evidence_selection_free(selection); + evidence_staging_result_free(prepared);evidence_staging_free(staging); + database_close(database);g_free(source);g_free(database_directory); + g_free(database_path);remove_tree(root);g_free(root); +} static void test_success_and_rollback(void) { char *root = g_dir_make_tmp("labfy-coordinator-XXXXXX", NULL); @@ -71,9 +366,35 @@ static void test_success_and_rollback(void) prepared_one->original_name, prepared_one->mime_type, "text", prepared_one->size_bytes, prepared_one->sha256, NULL, prepared_one->prepared_at, &error)); + GPtrArray *coordinator_runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + IdentityOcrRun *coordinator_run = identity_ocr_run_new( + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 0)), + prepared_one->sha256, "identity_card", "front", 1, "eng", "none"); + identity_ocr_run_set_outputs(coordinator_run, "tesseract SPECIMEN", "eng", + "paramètres SPECIMEN", "NOM SPECIMEN", "TSV SPECIMEN"); + IdentityFieldObservation *coordinator_field = + identity_field_observation_new("surname", "SPECIMEN", 90, NULL, 0); + identity_field_observation_accept(coordinator_field); + identity_ocr_run_add_field(coordinator_run, coordinator_field); + IdentityFieldObservation *manual_field = + identity_field_observation_new_manual( + "nationality", "FRANÇAISE", 1); + g_assert_nonnull(manual_field); + g_assert_true(identity_field_observation_modify(manual_field, + "FRANÇAISE", "saisie manuelle contrôlée")); + identity_ocr_run_add_field(coordinator_run, manual_field); + identity_ocr_run_set_factual_notes(coordinator_run, + "Document tronqué ; bord gauche coupé. " + "Fragment visible : « publique française »."); + g_ptr_array_add(coordinator_runs, coordinator_run); + PersonEvidenceSelection *task_selection = + person_evidence_selection_copy(selection); database = database_open(database_path); result = person_creation_coordinator_execute(database, root, &person, - selection, NULL, &error); + task_selection, coordinator_runs, NULL, &error); + person_evidence_selection_free(task_selection); g_assert_no_error(error); g_assert_nonnull(result); g_assert_cmpuint(result->evidence_identifiers->len, ==, 1); @@ -81,28 +402,181 @@ static void test_success_and_rollback(void) g_assert_cmpuint(count(database, "preuves"), ==, 1); g_assert_cmpuint(count(database, "preuve_entites"), ==, 1); g_assert_cmpuint(count(database, "preuve_entite_sources"), ==, 1); + g_assert_cmpuint(count(database, "identity_ocr_runs"), ==, 1); + g_assert_cmpuint(count(database, "identity_document_observations"), ==, 1); + g_assert_cmpuint(count(database, "identity_field_observations"), ==, 2); + char *ocr_root = g_build_filename(root, "02_Preuves_Traitees", "OCR", + identity_ocr_run_get_identifier(coordinator_run), NULL); + char *ocr_text = g_build_filename(ocr_root, "ocr.txt", NULL); + char *ocr_tsv = g_build_filename(ocr_root, "ocr.tsv", NULL); + g_assert_true(g_file_test(ocr_text, G_FILE_TEST_IS_REGULAR)); + g_assert_true(g_file_test(ocr_tsv, G_FILE_TEST_IS_REGULAR)); + IdentityOcrRun *ocr = identity_ocr_run_new( + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 0)), + prepared_one->sha256, "identity_card", "front", 1, "eng", + "none"); + identity_ocr_run_set_outputs(ocr, "tesseract 5 SPECIMEN", "eng", + "paramètres SPECIMEN", "SURNAME SPECIMEN", "TSV SPECIMEN"); + IdentitySourceBox box = {.page=1,.x=1,.y=2,.width=30,.height=10, + .image_width=80,.image_height=40,.available=TRUE}; + IdentityFieldObservation *accepted = + identity_field_observation_new("surname", "SPECIMEN", 91, &box, 0); + IdentityFieldObservation *modified = + identity_field_observation_new("given_names", "ALICE", 80, &box, 1); + IdentityFieldObservation *rejected = + identity_field_observation_new("birth_place", "TEST", 70, NULL, 2); + IdentityFieldObservation *mrz = + identity_field_observation_new("document_number", "MRZSPECIMEN", 99, + &box, 3); + IdentityFieldObservation *conflict = + identity_field_observation_new("birth_date", "01/01/1990", 50, + NULL, 4); + identity_field_observation_accept(accepted); + identity_field_observation_modify(modified, "ALICE SPECIMEN", "revue"); + identity_field_observation_reject(rejected); + identity_field_observation_accept(mrz); + g_assert_true(identity_field_observation_set_origin(mrz, "mrz")); + identity_field_observation_mark_conflict(conflict); + identity_ocr_run_add_field(ocr, accepted); + identity_ocr_run_add_field(ocr, modified); + identity_ocr_run_add_field(ocr, rejected); + identity_ocr_run_add_field(ocr, mrz); + identity_ocr_run_add_field(ocr, conflict); + IdentityOcrDao *ocr_dao = identity_ocr_dao_new(database); + const char *artifact_sha = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + g_assert_true(database_transaction_begin(database)); + g_assert_true(identity_ocr_dao_insert(ocr_dao, + result->person_identifier, + g_ptr_array_index(result->evidence_identifiers, 0), ocr, + "02_Preuves_Traitees/OCR/run/ocr.txt", artifact_sha, + "02_Preuves_Traitees/OCR/run/ocr.tsv", artifact_sha, + "2026-07-28T12:00:00Z", &error)); + g_assert_true(database_transaction_rollback(database)); + g_assert_cmpuint(count(database, "identity_ocr_runs"), ==, 1); + g_assert_true(database_transaction_begin(database)); + g_assert_true(identity_ocr_dao_insert(ocr_dao, + result->person_identifier, + g_ptr_array_index(result->evidence_identifiers, 0), ocr, + "02_Preuves_Traitees/OCR/run/ocr.txt", artifact_sha, + "02_Preuves_Traitees/OCR/run/ocr.tsv", artifact_sha, + "2026-07-28T12:00:00Z", &error)); + g_assert_true(database_transaction_commit(database)); + g_assert_cmpuint(count(database, "identity_ocr_runs"), ==, 2); + g_assert_cmpuint(count(database, "identity_document_observations"), ==, 2); + g_assert_cmpuint(count(database, "identity_field_observations"), ==, 7); + GPtrArray *read_runs = identity_ocr_dao_list_runs_by_evidence(ocr_dao, + g_ptr_array_index(result->evidence_identifiers, 0), &error); + g_assert_no_error(error); g_assert_cmpuint(read_runs->len, ==, 2); + IdentityOcrRunRecord *read_run = g_ptr_array_index(read_runs, 1); + g_assert_cmpstr(read_run->engine, ==, "tesseract"); + g_assert_cmpstr(read_run->engine_version, ==, "tesseract 5 SPECIMEN"); + g_assert_cmpstr(read_run->requested_languages, ==, "eng"); + g_assert_cmpstr(read_run->preprocessing_profile, ==, "none"); + g_assert_null(read_run->error_message); + IdentityOcrRunRecord *found_run = identity_ocr_dao_find_run(ocr_dao, + read_run->id, &error); + g_assert_no_error(error); g_assert_cmpstr(found_run->id, ==, read_run->id); + identity_ocr_run_record_free(found_run); + GPtrArray *documents = identity_ocr_dao_list_documents_by_person(ocr_dao, + result->person_identifier, &error); + g_assert_no_error(error); g_assert_cmpuint(documents->len, ==, 2); + IdentityDocumentObservationRecord *document = + g_ptr_array_index(documents, 1); + g_assert_cmpstr(document->person_id, ==, result->person_identifier); + g_assert_cmpstr(document->document_side, ==, "front"); + g_assert_null(document->issuing_country_declared); + GPtrArray *read_fields = identity_ocr_dao_list_fields_by_document(ocr_dao, + document->id, &error); + g_assert_no_error(error); g_assert_cmpuint(read_fields->len, ==, 5); + IdentityFieldObservationRecord *field0=g_ptr_array_index(read_fields,0); + IdentityFieldObservationRecord *field1=g_ptr_array_index(read_fields,1); + IdentityFieldObservationRecord *field2=g_ptr_array_index(read_fields,2); + IdentityFieldObservationRecord *field3=g_ptr_array_index(read_fields,3); + IdentityFieldObservationRecord *field4=g_ptr_array_index(read_fields,4); + g_assert_cmpstr(field0->origin, ==, "ocr"); + g_assert_cmpstr(field1->origin, ==, "manual_override"); + g_assert_cmpstr(field1->raw_value, ==, "ALICE"); + g_assert_cmpstr(field1->corrected_value, ==, "ALICE SPECIMEN"); + g_assert_cmpstr(field2->review_status, ==, "rejected"); + g_assert_cmpstr(field3->origin, ==, "mrz"); + g_assert_cmpstr(field4->review_status, ==, "conflict"); + g_assert_true(field0->has_source_box); + g_assert_null(identity_ocr_dao_find_field(ocr_dao, + "00000000-0000-4000-8000-000000000000", &error)); + g_assert_no_error(error); + g_ptr_array_unref(read_fields);g_ptr_array_unref(documents); + g_ptr_array_unref(read_runs); + identity_ocr_dao_free(ocr_dao); + identity_ocr_run_free(ocr); + char *persisted_person = g_strdup(result->person_identifier); + char *persisted_evidence = g_strdup( + g_ptr_array_index(result->evidence_identifiers, 0)); person_creation_coordinator_result_free(result); person_evidence_selection_free(selection); selection = person_evidence_selection_new(); + g_assert_true(person_evidence_selection_add_staged(selection, + prepared_two->source_path, prepared_two->staging_path, + prepared_two->original_name, prepared_two->mime_type, "text", + prepared_two->size_bytes, prepared_two->sha256, NULL, + prepared_two->prepared_at, &error)); + GPtrArray *failed_runs = g_ptr_array_new_with_free_func( + (GDestroyNotify) identity_ocr_run_free); + IdentityOcrRun *failed_run = identity_ocr_run_new( + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 0)), + prepared_two->sha256, "identity_card", "back", 1, "eng", "none"); + identity_ocr_run_set_outputs(failed_run, "SPECIMEN", "eng", "params", + "SPECIMEN", "TSV"); + g_ptr_array_add(failed_runs, failed_run); + char *failed_directory = g_build_filename(root, "02_Preuves_Traitees", + "OCR", identity_ocr_run_get_identifier(failed_run), NULL); + execute_sql(database, "CREATE TEMP TRIGGER fail_identity_ocr " + "BEFORE INSERT ON identity_ocr_runs BEGIN SELECT RAISE(ABORT," + "'SPECIMEN injected failure'); END;"); + result = person_creation_coordinator_execute(database, root, &person, + selection, failed_runs, NULL, &error); + g_assert_null(result); g_assert_nonnull(error); g_clear_error(&error); + g_assert_false(g_file_test(failed_directory, G_FILE_TEST_EXISTS)); + g_assert_cmpuint(count(database, "entites"), ==, 1); + execute_sql(database, "DROP TRIGGER fail_identity_ocr;"); + g_free(failed_directory); g_ptr_array_unref(failed_runs); + person_evidence_selection_free(selection); + selection = person_evidence_selection_new(); g_assert_true(person_evidence_selection_add_staged(selection, prepared_two->source_path, prepared_two->staging_path, prepared_two->original_name, prepared_two->mime_type, "missing_type", prepared_two->size_bytes, prepared_two->sha256, NULL, prepared_two->prepared_at, &error)); result = person_creation_coordinator_execute(database, root, &person, - selection, NULL, &error); + selection, NULL, NULL, &error); g_assert_null(result); g_assert_nonnull(error); g_clear_error(&error); g_assert_cmpuint(count(database, "entites"), ==, 1); g_assert_cmpuint(count(database, "preuves"), ==, 1); database_close(database); + database = database_open(database_path); + g_assert_nonnull(database); + ocr_dao = identity_ocr_dao_new(database); + read_runs = identity_ocr_dao_list_runs_by_evidence(ocr_dao, + persisted_evidence, &error); + g_assert_no_error(error); g_assert_cmpuint(read_runs->len, ==, 2); + documents = identity_ocr_dao_list_documents_by_person(ocr_dao, + persisted_person, &error); + g_assert_no_error(error); g_assert_cmpuint(documents->len, ==, 2); + g_ptr_array_unref(read_runs); g_ptr_array_unref(documents); + identity_ocr_dao_free(ocr_dao); database_close(database); + g_free(persisted_person); g_free(persisted_evidence); + g_ptr_array_unref(coordinator_runs); person_evidence_selection_free(selection); evidence_staging_result_free(prepared_one); evidence_staging_result_free(prepared_two); evidence_staging_free(staging); g_free(database_directory); g_free(database_path); g_free(source_one); g_free(source_two); + g_free(ocr_root); g_free(ocr_text); g_free(ocr_tsv); remove_tree(root); g_free(root); } int main(int argc, char **argv) @@ -110,5 +584,11 @@ int main(int argc, char **argv) g_test_init(&argc, &argv, NULL); g_test_add_func("/person-coordinator/success-rollback", test_success_and_rollback); + g_test_add_func("/person-coordinator/failure-matrix", + test_failure_matrix); + g_test_add_func("/person-coordinator/session-before-commit", + test_real_session_change_before_commit); + g_test_add_func("/person-coordinator/existing-session-compensation", + test_existing_person_session_change_compensates); return g_test_run(); } diff --git a/tests/test_person_evidence_selection.c b/tests/test_person_evidence_selection.c index fc0b783..7107bd8 100644 --- a/tests/test_person_evidence_selection.c +++ b/tests/test_person_evidence_selection.c @@ -106,6 +106,38 @@ static void test_deep_copy_survives_sources(void) person_evidence_selection_item_get_identifier(item))); person_evidence_selection_free(copy); } +static void test_staged_copy_keeps_selection_identity(void) +{ + PersonEvidenceSelection *source = person_evidence_selection_new(); + g_assert_true(person_evidence_selection_add_staged(source, + "/tmp/SPECIMEN-source.png", "/tmp/SPECIMEN-stage.png", + "SPECIMEN.png", "image/png", "identity", 24, + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "SPECIMEN synthétique", "2026-07-28T10:00:00Z", NULL)); + const PersonEvidenceSelectionItem *source_item = + person_evidence_selection_get(source, 0); + char *selection_identifier = g_strdup( + person_evidence_selection_item_get_identifier(source_item)); + g_assert_true(person_evidence_selection_set_active( + source, selection_identifier)); + + PersonEvidenceSelection *copy = person_evidence_selection_copy(source); + const PersonEvidenceSelectionItem *copied_item = + person_evidence_selection_get(copy, 0); + g_assert_cmpstr(person_evidence_selection_item_get_identifier(copied_item), + ==, selection_identifier); + g_assert_cmpstr(person_evidence_selection_item_get_identifier( + person_evidence_selection_get_active(copy)), ==, + selection_identifier); + g_assert_cmpint(person_evidence_selection_item_get_origin(copied_item), + ==, PERSON_EVIDENCE_ORIGIN_STAGED); + g_assert_cmpint(person_evidence_selection_item_get_state(copied_item), + ==, PERSON_EVIDENCE_STATE_READY); + + g_free(selection_identifier); + person_evidence_selection_free(copy); + person_evidence_selection_free(source); +} int main(int argc, char **argv) { g_test_init(&argc, &argv, NULL); @@ -113,5 +145,7 @@ int main(int argc, char **argv) g_test_add_func("/person-evidence/duplicates", test_duplicates); g_test_add_func("/person-evidence/deep-copy", test_deep_copy_survives_sources); + g_test_add_func("/person-evidence/staged-copy-selection-identity", + test_staged_copy_keeps_selection_identity); return g_test_run(); } diff --git a/tests/test_workspace_identity_ocr_gtk.c b/tests/test_workspace_identity_ocr_gtk.c new file mode 100644 index 0000000..0cc3ef7 --- /dev/null +++ b/tests/test_workspace_identity_ocr_gtk.c @@ -0,0 +1,288 @@ +#include "views/main_window.h" +#include "views/evidence_identity_ocr_dialog.h" +#include "widgets/workspace.h" +#include "models/evidence_record.h" +#include "models/entity_record.h" +#include "core/task_manager.h" +#include +#include +#include + +#define EVIDENCE_ID "11111111-1111-4111-8111-111111111111" +#define SHA "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + +typedef struct { + GtkApplication *application; + MainWindow *main_window; + TaskManager *manager; + EvidenceRecord *evidence; + char *run_a; + char *run_b; + char *fixture_path; + IdentityOcrRun *review_run; + GtkWindow *review_dialog; + guint revisions; + guint reruns; + gboolean passed; +} TestContext; + +static gboolean session_current(gpointer data) +{ + return data != NULL; +} + +static void review_saved( + EvidenceIdentityOcrDialogResult *result, gpointer data); + +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 char *text_view_text(GtkWidget *widget) +{ + GtkTextBuffer *buffer = + gtk_text_view_get_buffer(GTK_TEXT_VIEW(widget)); + GtkTextIter start; + GtkTextIter end; + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} + +static IdentityOcrRun *make_run( + const char *raw, const char *corrected, guint page) +{ + IdentityOcrRun *run = identity_ocr_run_new( + EVIDENCE_ID, SHA, page == 1 ? "identity_card" : "passport", + page == 1 ? "front" : "identity_page", page, "fra", "none"); + identity_ocr_run_set_outputs(run, "tesseract SPECIMEN", "fra", + "SPECIMEN", raw, "TSV SPECIMEN"); + identity_ocr_run_set_corrected_transcription( + run, corrected, "2026-07-29T10:00:00Z"); + identity_ocr_run_set_factual_notes(run, + page == 1 ? "NOTE WORKSPACE A" : "NOTE WORKSPACE B"); + IdentitySourceBox box = { + .page = (gint) page, .x = 10, .y = 20, + .width = 100, .height = 30, + .image_width = 640, .image_height = 400, .available = TRUE + }; + IdentityFieldObservation *field = identity_field_observation_new( + "surname", page == 1 ? "BRUT A" : "BRUT B", 90, &box, 0); + identity_field_observation_accept(field); + identity_ocr_run_add_field(run, field); + return run; +} + +static void identity_requested( + const char *evidence_identifier, gboolean revise, + const char *run_identifier, gpointer data) +{ + TestContext *context = data; + g_assert_cmpstr(evidence_identifier, ==, EVIDENCE_ID); + if (revise) { + g_assert_cmpstr(run_identifier, ==, context->run_a); + context->revisions++; + GPtrArray *persons = g_ptr_array_new_with_free_func( + (GDestroyNotify) entity_record_free); + GError *error = NULL; + EntityRecord *person = entity_record_new( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "person", + "PERSONNE SPECIMEN", "PERSONNE SPECIMEN", NULL, 0, + "2026-07-29T10:00:00Z", "2026-07-29T10:00:00Z", + ENTITY_STATUS_ACTIVE, &error); + g_assert_no_error(error); + g_ptr_array_add(persons, person); + g_assert_true(evidence_identity_ocr_dialog_present_review( + main_window_get_window(context->main_window), + context->fixture_path, persons, + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + context->review_run, session_current, + review_saved, + context, NULL, &error)); + g_assert_no_error(error); + g_ptr_array_unref(persons); + } else { + g_assert_null(run_identifier); + context->reruns++; + } +} + +static GtkWindow *find_review_dialog(void) +{ + GListModel *windows = gtk_window_get_toplevels(); + for (guint index = 0; index < g_list_model_get_n_items(windows); + index++) { + GtkWindow *window = g_list_model_get_item(windows, index); + gboolean matches = g_strcmp0(gtk_window_get_title(window), + "Réviser l’analyse OCR") == 0; + if (matches) return window; + g_object_unref(window); + } + return NULL; +} + +static gboolean finish_review(gpointer data) +{ + TestContext *context = data; + evidence_identity_ocr_dialog_finish_import( + context->review_dialog, NULL); + g_clear_object(&context->review_dialog); + return G_SOURCE_REMOVE; +} + +static void review_saved( + EvidenceIdentityOcrDialogResult *result, gpointer data) +{ + TestContext *context = data; + GtkWindow *dialog = + evidence_identity_ocr_dialog_result_get_dialog(result); + IdentityOcrRun *run = + evidence_identity_ocr_dialog_result_steal_run(result); + GtkWidget *save = find_named(GTK_WIDGET(dialog), + "evidence-identity-continue-with-ocr"); + GtkWidget *status = find_named(GTK_WIDGET(dialog), + "evidence-identity-ocr-status"); + g_assert_false(gtk_widget_get_sensitive(save)); + g_assert_nonnull(g_strstr_len( + gtk_label_get_text(GTK_LABEL(status)), -1, + "Enregistrement de la révision")); + g_assert_cmpstr(identity_ocr_run_get_identifier(run), + ==, context->run_a); + context->review_dialog = g_object_ref(dialog); + identity_ocr_run_free(run); + evidence_identity_ocr_dialog_result_free(result); + g_idle_add(finish_review, context); +} + +static gboolean drive(gpointer data) +{ + TestContext *context = data; + GtkWidget *root = GTK_WIDGET( + main_window_get_window(context->main_window)); + GtkWidget *analyze = find_named(root, + "workspace-analyze-identity"); + GtkWidget *section = find_named(root, + "workspace-identity-ocr-section"); + GtkDropDown *selector = GTK_DROP_DOWN(find_named(root, + "workspace-identity-ocr-selector")); + g_assert_nonnull(analyze); + g_assert_true(gtk_widget_get_sensitive(analyze)); + g_assert_true(gtk_widget_get_visible(section)); + g_assert_cmpuint(g_list_model_get_n_items( + gtk_drop_down_get_model(selector)), ==, 2); + if (context->revisions == 0) { + g_assert_cmpuint(gtk_drop_down_get_selected(selector), ==, 1); + char *raw = text_view_text(find_named(root, + "workspace-identity-ocr-raw")); + g_assert_cmpstr(raw, ==, "RUN B OCR BRUT"); + g_free(raw); + gtk_drop_down_set_selected(selector, 0); + while (g_main_context_iteration(NULL, FALSE)) {} + raw = text_view_text(find_named(root, + "workspace-identity-ocr-raw")); + g_assert_cmpstr(raw, ==, "RUN A OCR BRUT"); + g_free(raw); + g_signal_emit_by_name(find_named(root, + "workspace-revise-identity-ocr"), "clicked"); + GtkWindow *dialog = find_review_dialog(); + g_assert_nonnull(dialog); + GtkWidget *save = find_named(GTK_WIDGET(dialog), + "evidence-identity-continue-with-ocr"); + GtkWidget *corrected = find_named(GTK_WIDGET(dialog), + "identity-corrected-transcription"); + gtk_text_buffer_set_text(gtk_text_view_get_buffer( + GTK_TEXT_VIEW(corrected)), "RUN A RÉVISION SPECIMEN", -1); + g_signal_emit_by_name(save, "clicked"); + g_signal_emit_by_name(save, "clicked"); + g_object_unref(dialog); + g_timeout_add(20, drive, context); + return G_SOURCE_REMOVE; + } + GtkWindow *pending_dialog = find_review_dialog(); + if (pending_dialog != NULL) { + g_object_unref(pending_dialog); + g_timeout_add(20, drive, context); + return G_SOURCE_REMOVE; + } + g_signal_emit_by_name(find_named(root, + "workspace-rerun-identity-ocr"), "clicked"); + g_assert_cmpuint(context->revisions, ==, 1); + g_assert_cmpuint(context->reruns, ==, 1); + context->passed = TRUE; + g_application_quit(G_APPLICATION(context->application)); + return G_SOURCE_REMOVE; +} + +static void activate(GtkApplication *application, gpointer data) +{ + TestContext *context = data; + GError *error = NULL; + context->main_window = main_window_new(application, context->manager); + main_window_set_identity_ocr_callback( + context->main_window, identity_requested, context); + context->evidence = evidence_record_new( + EVIDENCE_ID, "IDENTITE-SPECIMEN.jpeg", + "IDENTITE-SPECIMEN.jpeg", "01_Preuves_Originales/specimen.jpeg", + "document", 32, SHA, "2026-07-29T10:00:00Z", NULL, + NULL, NULL, EVIDENCE_INTEGRITY_STATUS_VALID, &error); + g_assert_no_error(error); + main_window_set_selected_evidence( + context->main_window, context->evidence); + GPtrArray *records = g_ptr_array_new_with_free_func( + (GDestroyNotify) workspace_identity_ocr_record_free); + IdentityOcrRun *run_a = make_run( + "RUN A OCR BRUT", "RUN A CORRIGÉ", 1); + IdentityOcrRun *run_b = make_run( + "RUN B OCR BRUT", "RUN B CORRIGÉ", 2); + context->run_a = g_strdup(identity_ocr_run_get_identifier(run_a)); + context->review_run = identity_ocr_run_copy(run_a); + context->run_b = g_strdup(identity_ocr_run_get_identifier(run_b)); + g_ptr_array_add(records, workspace_identity_ocr_record_new( + run_a, "PERSONNE A SPECIMEN", "2026-07-29T10:00:00Z", + "OCR/A.txt", SHA, "OCR/A.tsv", SHA)); + g_ptr_array_add(records, workspace_identity_ocr_record_new( + run_b, "PERSONNE B SPECIMEN", "2026-07-29T11:00:00Z", + "OCR/B.txt", SHA, "OCR/B.tsv", SHA)); + main_window_set_identity_ocr_runs(context->main_window, records); + main_window_present(context->main_window); + g_idle_add(drive, context); +} + +int main(int argc, char **argv) +{ + if (!gtk_init_check()) { + g_print("SKIP: aucun affichage GTK disponible.\n"); + return 0; + } + TestContext context = {0}; + context.fixture_path = g_strdup_printf( + "%s/labfy-workspace-identity-%u.jpeg", + g_get_tmp_dir(), g_random_int()); + g_assert_true(g_file_set_contents(context.fixture_path, + "SPECIMEN JPEG", -1, NULL)); + context.manager = task_manager_new(); + context.application = gtk_application_new( + "com.labfytools.test.workspace.identity", + G_APPLICATION_NON_UNIQUE); + g_signal_connect(context.application, "activate", + G_CALLBACK(activate), &context); + int status = g_application_run( + G_APPLICATION(context.application), argc, argv); + g_assert_true(context.passed); + evidence_record_free(context.evidence); + main_window_free(context.main_window); + task_manager_free(context.manager); + g_object_unref(context.application); + g_free(context.run_a); + g_free(context.run_b); + identity_ocr_run_free(context.review_run); + g_remove(context.fixture_path); + g_free(context.fixture_path); + return status; +}