#!/usr/bin/env python3 """Génère la fiche produit Pseudonymisation en DOCX (deux versions).""" from docx import Document from docx.shared import Pt, Cm, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml.ns import qn import os # ── Helpers ────────────────────────────────────────────────────────── def set_cell_shading(cell, color_hex: str): """Applique une couleur de fond à une cellule.""" shading = cell._element.get_or_add_tcPr() shading_elem = shading.makeelement(qn('w:shd'), { qn('w:val'): 'clear', qn('w:color'): 'auto', qn('w:fill'): color_hex, }) shading.append(shading_elem) def add_heading_styled(doc, text, level=1, color=RGBColor(0x1A, 0x56, 0x8E)): h = doc.add_heading(text, level=level) for run in h.runs: run.font.color.rgb = color return h def add_bullet(doc, text, bold_prefix=None): p = doc.add_paragraph(style='List Bullet') if bold_prefix: run = p.add_run(bold_prefix) run.bold = True p.add_run(text) else: p.add_run(text) return p def add_para(doc, text, bold=False, italic=False, space_after=Pt(6)): p = doc.add_paragraph() run = p.add_run(text) run.bold = bold run.italic = italic p.paragraph_format.space_after = space_after return p def add_table_row(table, cells_data, header=False, header_color='1A568E'): row = table.add_row() for i, text in enumerate(cells_data): cell = row.cells[i] cell.text = '' p = cell.paragraphs[0] run = p.add_run(text) run.font.size = Pt(10) if header: run.bold = True run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) set_cell_shading(cell, header_color) p.paragraph_format.space_before = Pt(4) p.paragraph_format.space_after = Pt(4) def set_table_style(table): """Style sobre pour les tableaux.""" table.alignment = WD_TABLE_ALIGNMENT.CENTER for row in table.rows: for cell in row.cells: for p in cell.paragraphs: p.paragraph_format.space_before = Pt(3) p.paragraph_format.space_after = Pt(3) def add_section_break(doc): doc.add_page_break() # ── Couleurs ───────────────────────────────────────────────────────── BLUE_DARK = RGBColor(0x1A, 0x56, 0x8E) BLUE_LIGHT = 'D6E8F7' GRAY_LIGHT = 'F2F2F2' GREEN = RGBColor(0x2E, 0x7D, 0x32) # ══════════════════════════════════════════════════════════════════════ # VERSION 1 — Synthétique (1 page recto, orientation portrait) # ══════════════════════════════════════════════════════════════════════ def build_version_1(doc): # ── Titre ── title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER for run in title.runs: run.font.color.rgb = BLUE_DARK run.font.size = Pt(22) # Sous-titre sub = doc.add_paragraph() sub.alignment = WD_ALIGN_PARAGRAPH.CENTER run = sub.add_run('Fiche produit — Version synthétique') run.font.size = Pt(12) run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) run.italic = True sub.paragraph_format.space_after = Pt(16) # ── En bref ── add_heading_styled(doc, 'En bref', level=2) add_para(doc, 'Logiciel d\'anonymisation automatique de documents médicaux, ' 'fonctionnant 100% en local sur un poste Windows. ' 'Aucune donnée ne sort de l\'établissement. ' 'Un fichier entre, un fichier anonymisé sort.', space_after=Pt(10)) # ── Tableau comparatif ── add_heading_styled(doc, 'Pourquoi cet outil ?', level=2) table = doc.add_table(rows=0, cols=2) table.style = 'Table Grid' add_table_row(table, ['Situation actuelle', 'Avec l\'outil'], header=True) comparisons = [ ('Anonymisation manuelle, chronophage\net source d\'erreurs', 'Traitement automatique\nen quelques minutes'), ('Risque d\'oubli de données\npersonnelles (RGPD)', 'Taux de détection > 99%\nvalidé sur corpus réel'), ('Mobilisation de personnel qualifié\nsur une tâche répétitive', 'L\'équipe TIM vérifie,\nelle ne produit plus à la main'), ] for left, right in comparisons: add_table_row(table, [left, right]) set_table_style(table) doc.add_paragraph() # espace # ── Points clés ── add_heading_styled(doc, 'Points clés', level=2) points = [ ('100% hors-ligne — ', 'aucune donnée ne transite par internet'), ('Un seul exécutable — ', 'pas d\'installation, pas de serveur, pas de GPU'), ('14 formats acceptés — ', 'PDF, Word, ODT, RTF, images, HTML...'), ('4 moteurs d\'IA — ', 'spécialisés en français clinique, fonctionnent en parallèle'), ('Paramétrable — ', 'whitelist et blacklist modifiables par l\'établissement'), ('Irréversible — ', 'masquage par rectangles noirs, pas de calque amovible'), ] for bold_part, normal_part in points: add_bullet(doc, normal_part, bold_prefix=bold_part) # ── Ce qui est masqué / préservé ── add_heading_styled(doc, 'Ce qui est masqué / préservé', level=2) table2 = doc.add_table(rows=0, cols=2) table2.style = 'Table Grid' add_table_row(table2, ['Masqué automatiquement', 'Préservé intégralement'], header=True) add_table_row(table2, [ 'Noms, prénoms, adresses,\ntéléphones, n° de sécu,\ndates de naissance,\nnoms d\'établissements,\ncodes-barres patients', 'Diagnostics, traitements,\nposologies, actes médicaux,\nrésultats d\'examens,\ndates de séjour,\ncodage CIM / CCAM' ]) set_table_style(table2) doc.add_paragraph() # ── Cas d'usage ── add_heading_styled(doc, 'Cas d\'usage', level=2) usages = [ ('Contrôle T2A — ', 'anonymiser les pièces justificatives avant transmission'), ('Recherche clinique — ', 'constituer des corpus anonymisés'), ('Audits qualité / HAS — ', 'partager des dossiers sans exposition de données'), ('Formation — ', 'utiliser des cas réels anonymisés pour l\'enseignement'), ] for bold_part, normal_part in usages: add_bullet(doc, normal_part, bold_prefix=bold_part) # ══════════════════════════════════════════════════════════════════════ # VERSION 2 — Détaillée (multi-pages, par audience) # ══════════════════════════════════════════════════════════════════════ def build_version_2(doc): # ── Page de titre ── for _ in range(4): doc.add_paragraph() title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER for run in title.runs: run.font.color.rgb = BLUE_DARK run.font.size = Pt(26) doc.add_paragraph() sub = doc.add_paragraph() sub.alignment = WD_ALIGN_PARAGRAPH.CENTER run = sub.add_run('Fiche produit détaillée') run.font.size = Pt(14) run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) doc.add_paragraph() audiences = doc.add_paragraph() audiences.alignment = WD_ALIGN_PARAGRAPH.CENTER run = audiences.add_run('Direction générale · DSI · Médecins · Équipe TIM') run.font.size = Pt(11) run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) run.italic = True add_section_break(doc) # ── 1. Le problème ── add_heading_styled(doc, 'Le problème', level=1) add_para(doc, 'Les établissements de santé manipulent quotidiennement des documents ' 'contenant des données personnelles de patients : comptes-rendus opératoires, ' 'résultats d\'examens, courriers médicaux, rapports d\'anatomo-pathologie...') add_para(doc, 'Lors des contrôles T2A, audits qualité, recherches cliniques ou échanges ' 'inter-établissements, ces documents doivent être anonymisés conformément ' 'au RGPD et au cadre réglementaire santé.') add_para(doc, 'L\'anonymisation manuelle est lente, coûteuse, et faillible : ' 'un oubli suffit à exposer l\'identité d\'un patient.', bold=True, space_after=Pt(12)) # ── 2. La solution ── add_heading_styled(doc, 'La solution', level=1) add_para(doc, 'Un logiciel autonome, fonctionnant intégralement en local, qui anonymise ' 'automatiquement vos documents en quelques minutes :') bullets = [ 'Détecte et masque les noms, prénoms, adresses, téléphones, numéros de sécurité sociale, dates de naissance, noms d\'établissements', 'Préserve le contenu médical utile : diagnostics, traitements, actes, résultats, codage', 'Produit un PDF anonymisé prêt à transmettre, avec masquage irréversible', ] for b in bullets: add_bullet(doc, b) doc.add_paragraph() # ── Tableau comparatif ── add_heading_styled(doc, 'Avant / Après', level=2) table = doc.add_table(rows=0, cols=2) table.style = 'Table Grid' add_table_row(table, ['Situation actuelle', 'Avec l\'outil'], header=True) comparisons = [ ('Anonymisation manuelle, chronophage et source d\'erreurs', 'Traitement automatique en quelques minutes'), ('Risque d\'oubli de données personnelles (RGPD, AI Act)', 'Taux de détection > 99% validé sur corpus réel de contrôle T2A'), ('Mobilisation de personnel qualifié sur une tâche répétitive', 'L\'équipe TIM se concentre sur la vérification, pas la production'), ('Dépendance à des outils cloud ou des prestataires externes', '100% local, aucune donnée ne quitte l\'établissement'), ] for left, right in comparisons: add_table_row(table, [left, right]) set_table_style(table) add_section_break(doc) # ── 3. Conformité et sécurité (DG + DSI) ── add_heading_styled(doc, 'Conformité et sécurité', level=1) tag = doc.add_paragraph() run = tag.add_run('→ Direction générale, DSI') run.italic = True run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) tag.paragraph_format.space_after = Pt(8) security_points = [ ('Aucune connexion réseau', 'Le logiciel fonctionne entièrement hors-ligne. Aucun appel à un serveur externe, aucune API cloud, aucune télémétrie.'), ('Aucune donnée exfiltrée', 'Le traitement se fait intégralement en mémoire locale. Les documents source ne sont ni copiés ni transmis.'), ('Conformité RGPD / AI Act', 'L\'intelligence artificielle est embarquée dans l\'exécutable. Pas de sous-traitance, pas de transfert de données à un tiers.'), ('Masquage irréversible', 'Les zones anonymisées sont remplacées par des rectangles noirs directement dans le PDF. Il ne s\'agit pas d\'un calque amovible : l\'information est définitivement supprimée.'), ('Traçabilité', 'Les fichiers anonymisés sont nommés avec le préfixe "ANON_" pour identification immédiate.'), ] for title_text, desc in security_points: p = doc.add_paragraph() run_title = p.add_run(title_text + ' — ') run_title.bold = True p.add_run(desc) p.paragraph_format.space_after = Pt(6) add_section_break(doc) # ── 4. Déploiement (DSI) ── add_heading_styled(doc, 'Déploiement et maintenance', level=1) tag = doc.add_paragraph() run = tag.add_run('→ DSI') run.italic = True run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) tag.paragraph_format.space_after = Pt(8) add_heading_styled(doc, 'Prérequis', level=2) prereqs = [ 'Windows 10 ou 11 (64 bits)', 'Poste bureautique standard — pas de GPU dédié, pas de serveur requis', 'Pas de droits administrateur nécessaires pour l\'exécution', 'Pas de connexion internet requise', ] for pr in prereqs: add_bullet(doc, pr) add_heading_styled(doc, 'Installation', level=2) install_steps = [ 'Copier l\'exécutable (un seul fichier .exe) sur le poste', 'Au premier lancement, le fichier de configuration est créé automatiquement à côté de l\'exécutable', 'C\'est prêt — aucune autre manipulation nécessaire', ] for i, step in enumerate(install_steps, 1): p = doc.add_paragraph() run = p.add_run(f'{i}. ') run.bold = True p.add_run(step) add_heading_styled(doc, 'Mise à jour', level=2) add_para(doc, 'Remplacer l\'exécutable par la nouvelle version. La configuration personnalisée de l\'établissement est conservée (fichier séparé).') add_heading_styled(doc, 'Configuration inter-établissements', level=2) add_para(doc, 'Les paramètres (whitelist, blacklist) sont exportables au format JSON depuis l\'interface. ' 'Un établissement peut envoyer sa configuration par email. ' 'Le service central fusionne les configurations et renvoie un fichier YAML consolidé.') add_section_break(doc) # ── 5. Utilisation quotidienne (TIM) ── add_heading_styled(doc, 'Utilisation quotidienne', level=1) tag = doc.add_paragraph() run = tag.add_run('→ Équipe TIM, médecins') run.italic = True run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) tag.paragraph_format.space_after = Pt(8) add_heading_styled(doc, 'En 3 étapes', level=2) steps = [ ('Sélectionner', 'Choisir un fichier ou un dossier entier depuis l\'interface'), ('Lancer', 'Cliquer sur "Lancer l\'anonymisation"'), ('Récupérer', 'Les documents anonymisés sont créés dans le dossier de sortie, prêts à transmettre'), ] for title_text, desc in steps: p = doc.add_paragraph() run_title = p.add_run(f'{title_text} — ') run_title.bold = True run_title.font.color.rgb = BLUE_DARK p.add_run(desc) p.paragraph_format.space_after = Pt(6) add_heading_styled(doc, 'Formats acceptés (14)', level=2) table_fmt = doc.add_table(rows=0, cols=2) table_fmt.style = 'Table Grid' add_table_row(table_fmt, ['Type', 'Formats'], header=True) formats = [ ('Documents', 'PDF, Word (.docx), ODT, RTF, TXT, HTML'), ('Images', 'JPEG, PNG, TIFF, BMP'), ] for type_name, fmt_list in formats: add_table_row(table_fmt, [type_name, fmt_list]) set_table_style(table_fmt) doc.add_paragraph() add_para(doc, 'Les images et documents scannés sont traités par reconnaissance optique de caractères (OCR) ' 'intégrée — pas de logiciel tiers nécessaire.', italic=True) add_heading_styled(doc, 'Paramétrage', level=2) params = [ ('Whitelist — ', 'liste de termes à ne jamais masquer (noms de services, sigles internes, noms de logiciels...). Modifiable directement dans l\'interface.'), ('Blacklist — ', 'liste de termes à toujours masquer (noms de praticiens spécifiques...). Modifiable directement dans l\'interface.'), ('Export/Import — ', 'les paramètres sont échangeables entre établissements par simple fichier JSON envoyé par email.'), ] for bold_part, normal_part in params: add_bullet(doc, normal_part, bold_prefix=bold_part) add_section_break(doc) # ── 6. Fiabilité (Médecins + TIM) ── add_heading_styled(doc, 'Fiabilité de la détection', level=1) tag = doc.add_paragraph() run = tag.add_run('→ Médecins, équipe TIM') run.italic = True run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) tag.paragraph_format.space_after = Pt(8) add_para(doc, 'Le logiciel combine 4 moteurs d\'intelligence artificielle spécialisés en français clinique ' 'qui fonctionnent simultanément et se recoupent pour maximiser la détection :') engines = [ ('Modèle CamemBERT médical', 'entraîné spécifiquement sur des documents cliniques français'), ('Modèle de détection d\'entités', 'reconnaissance zero-shot de données personnelles'), ('Modèle spécialisé', 'fine-tuné sur plus de 1 000 documents médicaux réels'), ('Bases de référence nationales', '219 000 noms de famille, 36 000 prénoms, 7 300 médicaments, 108 000 établissements de santé'), ] for title_text, desc in engines: add_bullet(doc, ' — ' + desc, bold_prefix=title_text) doc.add_paragraph() add_para(doc, 'Principe de précaution : en cas de doute, le logiciel masque. ' 'Il est préférable de masquer un terme médical par excès ' 'plutôt que de laisser visible un nom de patient.', bold=True, space_after=Pt(8)) add_para(doc, 'Taux de détection validé à plus de 99% sur un corpus réel de documents ' 'de contrôle T2A : comptes-rendus opératoires, anatomo-pathologie, bactériologie, ' 'anesthésie, courriers médicaux, comptes-rendus d\'hospitalisation.') add_section_break(doc) # ── 7. Cas d'usage ── add_heading_styled(doc, 'Cas d\'usage', level=1) use_cases = [ ('Contrôle T2A', 'Anonymiser les pièces justificatives (comptes-rendus, résultats, courriers) ' 'avant transmission à l\'organisme de contrôle.'), ('Recherche clinique', 'Constituer des jeux de données anonymisés à partir de dossiers patients réels, ' 'sans risque d\'identification.'), ('Audits qualité et certification HAS', 'Partager des cas cliniques lors des visites de certification ' 'sans exposer l\'identité des patients.'), ('Échanges inter-établissements', 'Transmettre des comptes-rendus médicaux anonymisés dans le cadre ' 'de parcours de soins partagés.'), ('Formation et enseignement', 'Utiliser des cas cliniques réels anonymisés pour la formation ' 'des internes et des équipes soignantes.'), ] for title_text, desc in use_cases: p = doc.add_paragraph() run_title = p.add_run(title_text + '\n') run_title.bold = True run_title.font.size = Pt(11) run_desc = p.add_run(desc) run_desc.font.size = Pt(10) p.paragraph_format.space_after = Pt(10) # ── 8. Questions fréquentes ── add_heading_styled(doc, 'Questions fréquentes', level=1) faq = [ ('Combien de temps prend le traitement d\'un document ?', 'En moyenne quelques dizaines de secondes par document. ' 'Un lot de 20 à 30 documents se traite en 10 à 15 minutes sur un poste standard.'), ('Que faire si un nom n\'a pas été détecté ?', 'Ajouter le terme dans la blacklist via l\'interface. ' 'Il sera systématiquement masqué lors des prochains traitements.'), ('Que faire si un terme médical a été masqué par erreur ?', 'Ajouter le terme dans la whitelist via l\'interface. ' 'Il ne sera plus jamais masqué.'), ('Faut-il une connexion internet ?', 'Non. Le logiciel fonctionne intégralement hors-ligne. ' 'L\'intelligence artificielle est embarquée dans l\'exécutable.'), ('Les documents originaux sont-ils modifiés ?', 'Non. Le logiciel crée une copie anonymisée. ' 'Les documents originaux restent intacts.'), ('Peut-on traiter un dossier entier d\'un coup ?', 'Oui. Il suffit de sélectionner un dossier au lieu d\'un fichier. ' 'Tous les documents du dossier seront traités séquentiellement.'), ] for question, answer in faq: p = doc.add_paragraph() run_q = p.add_run(question + '\n') run_q.bold = True run_q.font.size = Pt(10) run_a = p.add_run(answer) run_a.font.size = Pt(10) p.paragraph_format.space_after = Pt(8) # ══════════════════════════════════════════════════════════════════════ # Construction du document final # ══════════════════════════════════════════════════════════════════════ def main(): doc = Document() # ── Style par défaut ── style = doc.styles['Normal'] style.font.name = 'Calibri' style.font.size = Pt(10) style.paragraph_format.space_after = Pt(4) # Marges for section in doc.sections: section.top_margin = Cm(1.5) section.bottom_margin = Cm(1.5) section.left_margin = Cm(2) section.right_margin = Cm(2) # ════════════════════════════════════════════ # VERSION 1 — Synthétique # ════════════════════════════════════════════ build_version_1(doc) # ── Séparation ── add_section_break(doc) sep = doc.add_paragraph() sep.alignment = WD_ALIGN_PARAGRAPH.CENTER for _ in range(6): doc.add_paragraph() sep2 = doc.add_paragraph() sep2.alignment = WD_ALIGN_PARAGRAPH.CENTER run = sep2.add_run('— Version détaillée ci-après —') run.font.size = Pt(14) run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) run.italic = True add_section_break(doc) # ════════════════════════════════════════════ # VERSION 2 — Détaillée # ════════════════════════════════════════════ build_version_2(doc) # ── Sauvegarde ── output_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'Fiche_Produit_Pseudonymisation.docx') doc.save(output_path) print(f'Fiche produit générée : {output_path}') if __name__ == '__main__': main()