doc-ai-runtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: doc-ai-runtime
3
+ Version: 0.1.0
4
+ Summary: Modular Document AI Runtime for RAG and Markdown exports.
5
+ Author-email: Alain Kalombo <alain.kalombo@anztech.dev>
6
+ License: MIT
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: python-docx
10
+ Requires-Dist: python-pptx
11
+ Requires-Dist: openpyxl
12
+ Requires-Dist: pytesseract
13
+ Requires-Dist: Pillow
14
+ Requires-Dist: PyMuPDF
15
+ Requires-Dist: surya-ocr
16
+ Requires-Dist: ollama
17
+ Dynamic: license-file
18
+
19
+ # 📄 Document AI Runtime
20
+
21
+ Un pipeline modulaire et extensible pour l'extraction de documents (OCR et Bureautique), conçu pour transformer des PDF, images et Word en formats structurés (Markdown et RAG).
22
+
23
+ Inspiré des capacités de solutions comme ABBYY, ce runtime repose sur une architecture de plugins indépendants manipulant un modÚle de données canonique (DOM).
24
+
25
+ ## 🚀 FonctionnalitĂ©s
26
+
27
+ * **Multi-Formats natifs et OCR :**
28
+ * **Bureautique (Path B)** : DOCX, XLSX (Excel), PPTX (PowerPoint) extraits nativement.
29
+ * **PDF Hybride** : Extrait le calque texte natif, et bascule automatiquement sur l'OCR (Surya + Tesseract) pour les pages scannées.
30
+ * **Images (Path A)** : JPG, PNG traités via IA (Surya Layout + Tesseract OCR).
31
+ * **Moteur XY-Cut** : Algorithme récursif de coupe verticale/horizontale pour reconstruire l'ordre de lecture des documents multi-colonnes complexes.
32
+ * **Exports spécialisés :** Markdown (structuré) et RAG (JSON chunké avec métadonnées).
33
+
34
+ ## 📩 Installation
35
+
36
+ Installer la bibliothÚque et ses dépendances :
37
+
38
+ ```bash
39
+ pip install -e .
40
+
41
+ ## 🛠 Architecture
42
+
43
+ Le projet est construit autour de 4 piliers :
44
+ 1. **Le DOM (Document Object Model) :** Des classes Python (`Document`, `Page`, `Region`, `Block`, `Table`, `Cell`) qui modélisent le document de bout en bout.
45
+ 2. **Les Interfaces :** `IDocumentLoader` et `IDocumentPlugin` garantissent que tous les modules (Surya, Tesseract, python-docx) communiquent avec le mĂȘme langage.
46
+ 3. **Les Plugins :** Chaque étape (chargement, layout, OCR) est un module interchangeable dans `src/plugins/`. *Remplacer Surya par un autre moteur de layout n'impacte pas le reste du code.*
47
+ 4. **Le Pipeline Runner :** L'orchestrateur qui détecte le type de fichier et exécute le bon chemin (A ou B) automatiquement.
48
+
49
+ ## 📩 Installation
50
+
51
+ ### 1. Prérequis Python
52
+ Clonez le dépÎt et installez les dépendances Python :
53
+ ```bash
54
+ pip install python-docx pytesseract Pillow PyMuPDF surya-ocr
55
+
56
+ ### 2. Dépendances SystÚme
57
+ Tesseract OCR :
58
+ Windows : Téléchargez l'installeur sur UB-Mannheim et ajoutez-le au PATH.
59
+ Linux : sudo apt install tesseract-ocr
60
+ Surya OCR (Optionnel) : Nécessite llama.cpp pour fonctionner. Le runner peut fonctionner avec Tesseract seul si Surya n'est pas configuré.
61
+
62
+ 🏃 Utilisation
63
+
64
+ Le runtime s'utilise via le script test_runtime.py (ou votre propre script) qui appelle le PipelineRunner.
65
+
66
+ from src.core.pipeline.runner import PipelineRunner
67
+
68
+ runner = PipelineRunner()
69
+
70
+ # Pour un PDF ou une Image -> Export Markdown
71
+
72
+ result_md = runner.process("mon_document.pdf", export_mode="markdown")
73
+ print(result_md)
74
+
75
+ # Pour un Word -> Export RAG
76
+
77
+ result_rag = runner.process("mon_document.docx", export_mode="rag")
78
+ print(result_rag)
79
+
80
+ 🏃 Utilisation (CLI)
81
+
82
+ python main.py --file mon_document.pdf --export markdown
83
+
84
+ 🗂 Structure du dĂ©pĂŽt
85
+
86
+ doc-ai-runtime/
87
+ ├── src/
88
+ │ ├── core/ # Le cƓur (DOM, Interfaces, Runner)
89
+ │ │ ├── dom/ # ModĂšles de donnĂ©es (Document, Page, Block...)
90
+ │ │ └── pipeline/ # Orchestrateur (runner.py)
91
+ │ ├── plugins/ # Les briques interchangeables
92
+ │ │ ├── loaders/ # ImageLoader, PdfLoader, DocxLoader
93
+ │ │ ├── layout/ # SuryaLayoutPlugin
94
+ │ │ └── ocr/ # TesseractOCRPlugin
95
+ │ └── exporters/ # MarkdownExporter, RagExporter
96
+ ├── test_runtime.py # Script de dĂ©monstration
97
+ └── README.md
98
+
99
+ đŸ—ș Feuille de route (Roadmap)
100
+
101
+ Document Object Model (DOM)
102
+ Loaders (DOCX, Images, PDF)
103
+ Layout Analysis (Surya) & OCR (Tesseract)
104
+ Exporters Markdown et RAG
105
+ Image Quality Analyzer (pré-traitement des scans)
106
+ Amélioration du moteur d'ordre de lecture (Reading Order Engine)
@@ -0,0 +1,20 @@
1
+ doc_ai_runtime-0.1.0.dist-info/licenses/LICENSE,sha256=tL9eo6BFrOVRvdUfn_h1vai0pGZjWcqKS_uiux8SZcc,1093
2
+ src/core/config.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ src/core/interfaces.py,sha256=XXeVNfhq4mjdcgkndN5xseomPd-2rwqE26JCM5Iacsw,1290
4
+ src/core/dom/models.py,sha256=k3yunJwLEKUONEpFOOi8FYq6rMfQ3zlaDkzKjrvt_4I,4037
5
+ src/core/pipeline/runner.py,sha256=KY6_Ze62snjR956zRCkcSrDRkjwDTcIeCZZIG3dRmU8,3342
6
+ src/exporters/markdown.py,sha256=GoKNdBv-OkhOnmk_SB0qkAUALP7GICdkaE7G1Kfb_98,2669
7
+ src/exporters/rag.py,sha256=ZQ0uagGCeJwam9w4cSp7Y3S6QBCT5P5FU4dWYqSJtQM,3372
8
+ src/plugins/layout/surya_layout.py,sha256=-yCSGxhkSj_KkYw4UjIXVzVyot2VTAyyhz1BYVougmw,3082
9
+ src/plugins/loaders/docx_loader.py,sha256=69jfe1xzNYAfBtVAQlAJfKRUw3t_AbXKvE213HaOTRg,3687
10
+ src/plugins/loaders/image_loader.py,sha256=qxg76hhCE66tCzjRE97jkwPwcBblKv0k-z6IxOD4QFE,1431
11
+ src/plugins/loaders/pdf_loader.py,sha256=xC5H5ejmXFnH4syIwvnEKiGK5w5uJYn79lBFlmLRBbY,4054
12
+ src/plugins/loaders/pptx_loader.py,sha256=4t78i7-bxdhZJlSXUcrjTXyPETRpthgqzxMR7NXP0k0,2546
13
+ src/plugins/loaders/xlsx_loader.py,sha256=w4mmwF4ptEj-dLqmSIg3X3cw9a7SP0UXlq2Dph4kiFk,1805
14
+ src/plugins/ocr/qwen_ocr.py,sha256=JHcUbRiudisyrEXIhqmSrVyYerC2vR6saliCJ4Ul-t8,3251
15
+ src/plugins/ocr/tesseract_ocr.py,sha256=4EE9cPCnAiTwjExf9kx9t9dRtGNEti1kt-x9JQQv8Rs,3953
16
+ src/plugins/reading_order/reading_order_engine.py,sha256=YvEuk_XtqCpT3-9yBZmciQD7FxzpqcICjoxn5-Nesx8,4053
17
+ doc_ai_runtime-0.1.0.dist-info/METADATA,sha256=QCx2ZNmbvbbRQomdk-0S_jolBqHVGpcyELK4uE7DTfU,4412
18
+ doc_ai_runtime-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
19
+ doc_ai_runtime-0.1.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
20
+ doc_ai_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alain2022kalo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1 @@
1
+ src
src/core/config.py ADDED
File without changes
src/core/dom/models.py ADDED
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Optional, Dict, Any
4
+ from enum import Enum
5
+
6
+ class RegionType(Enum):
7
+ TITLE = "title"
8
+ TEXT = "text"
9
+ TABLE = "table"
10
+ IMAGE = "image"
11
+ LIST = "list"
12
+ CAPTION = "caption"
13
+ HEADER = "header"
14
+ FOOTER = "footer"
15
+
16
+ class BlockType(Enum):
17
+ TEXT = "text"
18
+ TABLE_CELL = "table_cell"
19
+ IMAGE = "image"
20
+ EMPTY = "empty"
21
+
22
+ @dataclass
23
+ class BoundingBox:
24
+ """Représente les coordonnées spatiales d'un élément sur la page."""
25
+ x: float
26
+ y: float
27
+ width: float
28
+ height: float
29
+ confidence: float = 1.0
30
+
31
+ @dataclass
32
+ class Style:
33
+ """Définit le style typographique d'un bloc de texte."""
34
+ font_name: Optional[str] = None
35
+ font_size: Optional[float] = None
36
+ bold: bool = False
37
+ italic: bool = False
38
+ underline: bool = False
39
+ color: Optional[str] = None # Format hexadécimal, ex: '#000000'
40
+
41
+ @dataclass
42
+ class Cell:
43
+ """Représente une cellule d'un tableau."""
44
+ bbox: BoundingBox
45
+ text: str = ""
46
+ row_span: int = 1
47
+ col_span: int = 1
48
+ is_header: bool = False
49
+ confidence: float = 1.0
50
+
51
+ @dataclass
52
+ class Table:
53
+ """Représente un tableau structuré."""
54
+ bbox: BoundingBox
55
+ rows: int = 0
56
+ cols: int = 0
57
+ cells: List[Cell] = field(default_factory=list)
58
+ reading_order: int = 0
59
+
60
+ def get_markdown(self) -> str:
61
+ """Méthode utilitaire pour un export Markdown rapide (utile plus tard)."""
62
+ if not self.cells:
63
+ return ""
64
+ # Logique de génération Markdown basique à implémenter
65
+ return "[Tableau Markdown]"
66
+
67
+ @dataclass
68
+ class Image:
69
+ """Représente une image détectée dans le document."""
70
+ bbox: BoundingBox
71
+ image_data: Optional[bytes] = None # Stockage binaire ou base64
72
+ image_path: Optional[str] = None # Chemin si sauvegardée sur disque
73
+ caption: Optional[str] = None
74
+ reading_order: int = 0
75
+
76
+ @dataclass
77
+ class Block:
78
+ """L'unité de base de contenu (texte, image, etc.)."""
79
+ bbox: BoundingBox
80
+ block_type: BlockType = BlockType.TEXT
81
+ text: str = ""
82
+ style: Style = field(default_factory=Style)
83
+ reading_order: int = 0 # Indispensable pour le RAG et le Markdown
84
+ confidence: float = 1.0
85
+ heading_level: Optional[int] = None # <-- AJOUT : None si ce n'est pas un titre, 1 pour H1, 2 pour H2, etc.
86
+
87
+ @dataclass
88
+ class Region:
89
+ """Une zone logique de la page regroupant des blocs."""
90
+ bbox: BoundingBox
91
+ region_type: RegionType = RegionType.TEXT
92
+ blocks: List[Block] = field(default_factory=list)
93
+ tables: List[Table] = field(default_factory=list)
94
+ images: List[Image] = field(default_factory=list)
95
+ reading_order: int = 0
96
+
97
+ @dataclass
98
+ class Page:
99
+ """Représente une page physique du document."""
100
+ page_number: int
101
+ width: float
102
+ height: float
103
+ regions: List[Region] = field(default_factory=list)
104
+ image_path: Optional[str] = None
105
+
106
+ def get_all_blocks_in_reading_order(self) -> List[Block]:
107
+ """Retourne tous les blocs de la page triés par ordre de lecture."""
108
+ blocks = []
109
+ for region in sorted(self.regions, key=lambda r: r.reading_order):
110
+ blocks.extend(sorted(region.blocks, key=lambda b: b.reading_order))
111
+ return blocks
112
+
113
+ @dataclass
114
+ class Document:
115
+ """Le modÚle racine représentant le document complet."""
116
+ source_file: str
117
+ pages: List[Page] = field(default_factory=list)
118
+ metadata: Dict[str, Any] = field(default_factory=dict) # Auteur, date, titre, etc.
119
+
120
+ def get_text(self) -> str:
121
+ """Extrait tout le texte du document dans l'ordre de lecture (base pour le RAG)."""
122
+ full_text = []
123
+ for page in self.pages:
124
+ for block in page.get_all_blocks_in_reading_order():
125
+ if block.text:
126
+ full_text.append(block.text)
127
+ return "\n\n".join(full_text)
src/core/interfaces.py ADDED
@@ -0,0 +1,41 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+ from src.core.dom.models import Document
4
+
5
+ class IDocumentLoader(ABC):
6
+ """
7
+ Interface pour les plugins de chargement (Étape 1 du pipeline).
8
+ Prend un fichier brut en entrée et génÚre l'objet Document initial.
9
+ """
10
+ @property
11
+ @abstractmethod
12
+ def name(self) -> str:
13
+ """Retourne le nom du chargeur (ex: 'PdfLoader', 'DocxLoader')."""
14
+ pass
15
+
16
+ @abstractmethod
17
+ def load(self, file_path: str) -> Document:
18
+ """
19
+ Charge un fichier et retourne un objet Document partiellement ou
20
+ totalement rempli (selon si c'est une image ou un doc natif).
21
+ """
22
+ pass
23
+
24
+ class IDocumentPlugin(ABC):
25
+ """
26
+ Interface de base pour toutes les autres étapes du pipeline
27
+ (Layout, OCR, Reconstruction, Exporters).
28
+ """
29
+ @property
30
+ @abstractmethod
31
+ def name(self) -> str:
32
+ """Retourne le nom du plugin (ex: 'SuryaLayoutAnalyzer')."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def run(self, document: Document) -> Document:
37
+ """
38
+ Reçoit l'objet Document, effectue un traitement (ajout de blocs,
39
+ extraction de texte, etc.) et retourne l'objet Document mis Ă  jour.
40
+ """
41
+ pass
@@ -0,0 +1,83 @@
1
+ import os
2
+ import platform
3
+
4
+ if platform.system() == "Windows":
5
+ # Chemin pour ta machine Windows
6
+ os.environ["LLAMA_CPP_BINARY"] = r"c:\llama.cpp\llama-server.exe"
7
+ else:
8
+ # Chemin pour ton serveur Ubuntu (Linux)
9
+ os.environ["LLAMA_CPP_BINARY"] = "/home/linuxbrew/.linuxbrew/bin/llama-server"
10
+
11
+ os.environ["SURYA_INFERENCE_BACKEND"] = "llamacpp"
12
+ os.environ["SURYA_GUIDED_LAYOUT"] = "False"
13
+
14
+ from src.plugins.loaders.image_loader import ImageLoader
15
+ from src.plugins.loaders.docx_loader import DocxLoader
16
+ from src.plugins.loaders.pdf_loader import PdfLoader
17
+ from src.plugins.reading_order.reading_order_engine import ReadingOrderPlugin
18
+
19
+ from src.plugins.loaders.xlsx_loader import XlsxLoader
20
+ from src.plugins.loaders.pptx_loader import PptxLoader
21
+ from src.plugins.ocr.qwen_ocr import QwenOCRPlugin
22
+
23
+ from src.plugins.layout.surya_layout import SuryaLayoutPlugin
24
+ from src.plugins.ocr.tesseract_ocr import TesseractOCRPlugin
25
+ from src.exporters.markdown import MarkdownExporter
26
+ from src.exporters.rag import RagExporter
27
+
28
+ class PipelineRunner:
29
+ def __init__(self):
30
+ self.markdown_exporter = MarkdownExporter()
31
+ self.rag_exporter = RagExporter()
32
+
33
+ def process(self, file_path: str, export_mode: str = "markdown") -> str:
34
+ _, ext = os.path.splitext(file_path)
35
+ ext = ext.lower()
36
+ document = None
37
+
38
+ # --- PATH B : PDF (Hybride Natif + OCR) ---
39
+ if ext == ".pdf":
40
+ print("-> Fichier PDF détecté. Analyse (Hybride)...")
41
+ loader = PdfLoader()
42
+ document = loader.load(file_path)
43
+
44
+ elif ext == ".docx":
45
+ print("-> Fichier DOCX détecté. Extraction native.")
46
+ loader = DocxLoader()
47
+ document = loader.load(file_path)
48
+
49
+ elif ext == ".xlsx":
50
+ print("-> Fichier XLSX détecté. Extraction native (Tableaux).")
51
+ loader = XlsxLoader()
52
+ document = loader.load(file_path)
53
+
54
+ elif ext == ".pptx":
55
+ print("-> Fichier PPTX détecté. Extraction native (Slides).")
56
+ loader = PptxLoader()
57
+ document = loader.load(file_path)
58
+
59
+ # --- PATH A : Images pures (OCR) ---
60
+ elif ext in [".jpg", ".jpeg", ".png", ".tiff", ".bmp"]:
61
+ print("-> Fichier Image détecté. Utilisation OCR.")
62
+ loader = ImageLoader()
63
+ document = loader.load(file_path)
64
+
65
+ print(" ... Calcul de l'ordre de lecture")
66
+ reading_order_plugin = ReadingOrderPlugin()
67
+ document = reading_order_plugin.run(document)
68
+
69
+ # 4. OCR (Qwen2.5-VL lit dans les boĂźtes de Surya)
70
+ print(" ... Extraction du texte (Qwen2.5-VL via Ollama)")
71
+ # ocr_plugin = TesseractOCRPlugin() <-- On commente Tesseract
72
+ ocr_plugin = QwenOCRPlugin()
73
+ document = ocr_plugin.run(document)
74
+
75
+ else:
76
+ raise ValueError(f"Format non supporté : {ext}")
77
+
78
+ print(f"-> Export demandé : {export_mode}")
79
+ if export_mode == "rag":
80
+ return self.rag_exporter.export(document, mode="semantic_chunks")
81
+ else:
82
+ return self.markdown_exporter.export(document)
83
+
@@ -0,0 +1,56 @@
1
+ from src.core.dom.models import Document, Block, Table, RegionType
2
+
3
+ class MarkdownExporter:
4
+ def export(self, document: Document) -> str:
5
+ md_lines = []
6
+
7
+ for page in document.pages:
8
+ for region in page.regions:
9
+ # On rassemble tout dans une liste pour la trier par ordre de lecture
10
+ elements = []
11
+ for block in region.blocks:
12
+ # On passe le type de la région au bloc de texte pour l'export
13
+ elements.append((block.reading_order, "text", block, region.region_type))
14
+ for table in region.tables:
15
+ elements.append((table.reading_order, "table", table, None))
16
+
17
+ elements.sort(key=lambda x: x[0])
18
+
19
+ for _, el_type, el_data, region_type in elements:
20
+ if el_type == "text":
21
+ # Si le bloc vient du DOCX (heading_level)
22
+ if el_data.heading_level:
23
+ prefix = "#" * el_data.heading_level
24
+ md_lines.append(f"{prefix} {el_data.text}\n")
25
+ # Si le bloc vient de Surya et que la région est un TITRE
26
+ elif region_type == RegionType.TITLE:
27
+ md_lines.append(f"# {el_data.text}\n")
28
+ # Sinon c'est un paragraphe normal
29
+ else:
30
+ md_lines.append(f"{el_data.text}\n")
31
+
32
+ elif el_type == "table":
33
+ md_lines.append(self.table_to_markdown(el_data) + "\n")
34
+
35
+ return "\n".join(md_lines)
36
+
37
+ def table_to_markdown(self, table: Table) -> str:
38
+ # (Garde la méthode table_to_markdown exactement comme elle était)
39
+ if not table.cells or table.cols == 0:
40
+ return ""
41
+
42
+ grid = [["" for _ in range(table.cols)] for _ in range(table.rows)]
43
+ cell_idx = 0
44
+ for r in range(table.rows):
45
+ for c in range(table.cols):
46
+ if cell_idx < len(table.cells):
47
+ grid[r][c] = table.cells[cell_idx].text.replace('\n', ' ').replace('|', '/')
48
+ cell_idx += 1
49
+
50
+ md_table = []
51
+ md_table.append("| " + " | ".join(grid[0]) + " |")
52
+ md_table.append("|" + "|".join(["---"] * table.cols) + "|")
53
+ for r in range(1, table.rows):
54
+ md_table.append("| " + " | ".join(grid[r]) + " |")
55
+
56
+ return "\n".join(md_table)
src/exporters/rag.py ADDED
@@ -0,0 +1,78 @@
1
+ import json
2
+ from src.core.dom.models import Document
3
+ from src.exporters.markdown import MarkdownExporter
4
+
5
+ class RagExporter:
6
+ def __init__(self):
7
+ self.md_exporter = MarkdownExporter()
8
+
9
+ def export(self, document: Document, mode: str = "semantic_chunks") -> str:
10
+ """
11
+ Exporte le document pour le RAG.
12
+ :param mode:
13
+ - "semantic_chunks" : Découpe par bloc (paragraphe/tableau) avec métadonnées riches.
14
+ - "full_document" : Renvoie un seul gros chunk Markdown pour que le runtime de l'agent gÚre le découpage.
15
+ """
16
+ if mode == "full_document":
17
+ return self._export_full_document(document)
18
+ else:
19
+ return self._export_semantic_chunks(document)
20
+
21
+ def _export_full_document(self, document: Document) -> str:
22
+ """Mode 1 : Laisse l'agent runtime découper le texte."""
23
+ markdown_text = self.md_exporter.export(document)
24
+
25
+ payload = [{
26
+ "text": markdown_text,
27
+ "metadata": {
28
+ "source": document.source_file,
29
+ "total_pages": len(document.pages),
30
+ "type": "full_document",
31
+ "note": "Ready for external Markdown/Token splitter"
32
+ }
33
+ }]
34
+ return json.dumps(payload, indent=2, ensure_ascii=False)
35
+
36
+ def _export_semantic_chunks(self, document: Document) -> str:
37
+ """Mode 2 : On impose notre propre découpage logique."""
38
+ chunks = []
39
+ current_heading = "Document Root"
40
+
41
+ for page in document.pages:
42
+ for region in page.regions:
43
+ elements = []
44
+ for block in region.blocks:
45
+ elements.append((block.reading_order, "text", block))
46
+ for table in region.tables:
47
+ elements.append((table.reading_order, "table", table))
48
+
49
+ elements.sort(key=lambda x: x[0])
50
+
51
+ for _, el_type, el_data in elements:
52
+ if el_type == "text" and el_data.heading_level:
53
+ current_heading = el_data.text
54
+
55
+ if el_type == "text" and el_data.text:
56
+ chunks.append({
57
+ "text": el_data.text,
58
+ "metadata": {
59
+ "source": document.source_file,
60
+ "page": page.page_number,
61
+ "section": current_heading,
62
+ "type": "paragraph"
63
+ }
64
+ })
65
+
66
+ elif el_type == "table":
67
+ table_md = self.md_exporter.table_to_markdown(el_data)
68
+ chunks.append({
69
+ "text": table_md,
70
+ "metadata": {
71
+ "source": document.source_file,
72
+ "page": page.page_number,
73
+ "section": current_heading,
74
+ "type": "table"
75
+ }
76
+ })
77
+
78
+ return json.dumps(chunks, indent=2, ensure_ascii=False)
@@ -0,0 +1,76 @@
1
+ from PIL import Image
2
+ from surya.layout import LayoutPredictor
3
+ from src.core.interfaces import IDocumentPlugin
4
+ from src.core.dom.models import Document, Region, BoundingBox, RegionType
5
+
6
+ class SuryaLayoutPlugin(IDocumentPlugin):
7
+ """
8
+ Utilise Surya pour détecter les zones (Layout) sur les images du document.
9
+ """
10
+ def __init__(self):
11
+ # Initialisation paresseuse : on ne charge le modĂšle PyTorch que si on en a besoin
12
+ self._model = None
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "SuryaLayoutAnalyzer"
17
+
18
+ @property
19
+ def model(self):
20
+ if self._model is None:
21
+ print("Chargement du modĂšle Surya Layout... (la premiĂšre fois peut ĂȘtre longue)")
22
+ self._model = LayoutPredictor()
23
+ return self._model
24
+
25
+ def run(self, document: Document) -> Document:
26
+ for page in document.pages:
27
+ if not page.image_path:
28
+ continue
29
+
30
+ # 1. Charger l'image liée à la page
31
+ img = Image.open(page.image_path)
32
+
33
+ # 2. Prédiction du layout avec Surya
34
+ predictions = self.model([img])
35
+
36
+ # 3. On vide la région "globale" initiale fournie par l'ImageLoader
37
+ page.regions = []
38
+
39
+ # 4. Mapper les résultats de Surya vers notre DOM
40
+ surya_result = predictions[0]
41
+
42
+ sorted_bboxes = sorted(
43
+ surya_result.bboxes,
44
+ key=lambda b: (b.polygon[0][1], b.polygon[0][0])
45
+ )
46
+
47
+ for i, bbox in enumerate(sorted_bboxes):
48
+ # Surya fournit des polygones. On extrait le rectangle englobant (x, y, w, h)
49
+ # Surya utilise généralement [x_min, y_min, x_max, y_max] dans ses propriétés
50
+ x = bbox.polygon[0][0]
51
+ y = bbox.polygon[0][1]
52
+ w = bbox.polygon[1][0] - x
53
+ h = bbox.polygon[2][1] - y
54
+
55
+ # Mapping du label Surya (Text, Table, Image, Title...) vers notre Enum
56
+ label = bbox.label.upper()
57
+ region_type = RegionType.TEXT # Valeur par défaut
58
+ if "TABLE" in label: region_type = RegionType.TABLE
59
+ elif "IMAGE" in label: region_type = RegionType.IMAGE
60
+ elif "TITLE" in label or "HEADER" in label: region_type = RegionType.TITLE
61
+
62
+ # Création de la région dans notre DOM
63
+ region = Region(
64
+ bbox=BoundingBox(
65
+ x=float(x),
66
+ y=float(y),
67
+ width=float(w),
68
+ height=float(h),
69
+ confidence=float(bbox.confidence)
70
+ ),
71
+ region_type=region_type,
72
+ reading_order=i # Ordre de lecture provisoire
73
+ )
74
+ page.regions.append(region)
75
+
76
+ return document
@@ -0,0 +1,89 @@
1
+ from docx import Document as DocxDocument
2
+ from docx.text.paragraph import Paragraph
3
+ from docx.table import Table as DocxTable
4
+ from docx.oxml.ns import qn
5
+ from src.core.interfaces import IDocumentLoader
6
+ from src.core.dom.models import (
7
+ Document, Page, Region, Block, Table, Cell,
8
+ BoundingBox, BlockType, RegionType, Style
9
+ )
10
+
11
+ class DocxLoader(IDocumentLoader):
12
+ """
13
+ Chargeur pour les documents Word natifs (.docx).
14
+ Extrait directement le texte et les tableaux sans passer par l'OCR.
15
+ """
16
+ @property
17
+ def name(self) -> str:
18
+ return "DocxLoader"
19
+
20
+ def load(self, file_path: str) -> Document:
21
+ docx_doc = DocxDocument(file_path)
22
+
23
+ dom_doc = Document(source_file=file_path)
24
+ # Un document Word est un flux continu, on crïżœe une "Page logique" unique
25
+ page = Page(page_number=1, width=0, height=0)
26
+ region = Region(
27
+ bbox=BoundingBox(0, 0, 0, 0),
28
+ region_type=RegionType.TEXT,
29
+ reading_order=0
30
+ )
31
+
32
+ reading_order_counter = 0
33
+
34
+ # On lit l'arbre XML du document pour garder l'ordre rïżœel (texte puis tableau, puis texte, etc.)
35
+ body = docx_doc.element.body
36
+ for child in body.iterchildren():
37
+ # Si l'ïżœlïżœment est un paragraphe
38
+ if child.tag == qn('w:p'):
39
+ para = Paragraph(child, docx_doc)
40
+ text = para.text.strip()
41
+ if text:
42
+ is_bold = any(run.bold for run in para.runs if run.bold is not None)
43
+
44
+ # Dïżœtection du niveau de titre
45
+ heading_level = None
46
+ style_name = para.style.name if para.style else ""
47
+ if style_name.startswith("Heading"):
48
+ try:
49
+ heading_level = int(style_name.split()[-1])
50
+ except ValueError:
51
+ heading_level = 1 # Fallback si le nom du style est bizarre
52
+
53
+ block = Block(
54
+ bbox=BoundingBox(0, 0, 0, 0),
55
+ block_type=BlockType.TEXT,
56
+ text=text,
57
+ style=Style(bold=is_bold or (heading_level is not None)),
58
+ reading_order=reading_order_counter,
59
+ heading_level=heading_level # <-- AJOUT
60
+ )
61
+ region.blocks.append(block)
62
+ reading_order_counter += 1
63
+
64
+ # Si l'ïżœlïżœment est un tableau
65
+ elif child.tag == qn('w:tbl'):
66
+ docx_table = DocxTable(child, docx_doc)
67
+ dom_table = Table(
68
+ bbox=BoundingBox(0, 0, 0, 0),
69
+ rows=len(docx_table.rows),
70
+ cols=len(docx_table.columns),
71
+ reading_order=reading_order_counter # On assigne l'ordre au tableau
72
+ )
73
+
74
+ for r_idx, row in enumerate(docx_table.rows):
75
+ for c_idx, cell in enumerate(row.cells):
76
+ dom_cell = Cell(
77
+ bbox=BoundingBox(0, 0, 0, 0),
78
+ text=cell.text.strip(),
79
+ is_header=(r_idx == 0)
80
+ )
81
+ dom_table.cells.append(dom_cell)
82
+
83
+ region.tables.append(dom_table)
84
+ reading_order_counter += 1
85
+
86
+ page.regions.append(region)
87
+ dom_doc.pages.append(page)
88
+
89
+ return dom_doc
@@ -0,0 +1,41 @@
1
+ from PIL import Image
2
+ from src.core.interfaces import IDocumentLoader
3
+ from src.core.dom.models import Document, Page, Region, BoundingBox, RegionType
4
+
5
+ class ImageLoader(IDocumentLoader):
6
+ """
7
+ Chargeur pour les fichiers images (JPG, PNG, TIFF).
8
+ Prépare le DOM avec les dimensions physiques de l'image.
9
+ """
10
+ @property
11
+ def name(self) -> str:
12
+ return "ImageLoader"
13
+
14
+ def load(self, file_path: str) -> Document:
15
+ # 1. Lecture des dimensions physiques de l'image
16
+ with Image.open(file_path) as img:
17
+ width, height = img.size
18
+
19
+ # 2. Initialisation du DOM
20
+ doc = Document(source_file=file_path)
21
+
22
+ # 3. Création de la page avec les vraies dimensions et le chemin de l'image
23
+ page = Page(
24
+ page_number=1,
25
+ width=float(width),
26
+ height=float(height),
27
+ image_path=file_path # <-- Crucial pour les futurs plugins d'OCR
28
+ )
29
+
30
+ # 4. Création d'une région globale vierge.
31
+ # Le plugin de Layout Analysis (Surya) viendra diviser cette région plus tard.
32
+ region = Region(
33
+ bbox=BoundingBox(0, 0, float(width), float(height)),
34
+ region_type=RegionType.TEXT,
35
+ reading_order=0
36
+ )
37
+
38
+ page.regions.append(region)
39
+ doc.pages.append(page)
40
+
41
+ return doc
@@ -0,0 +1,93 @@
1
+ import os
2
+ import fitz # PyMuPDF
3
+ from src.core.interfaces import IDocumentLoader
4
+ from src.core.dom.models import Document, Page, Region, Block, BoundingBox, BlockType, RegionType
5
+
6
+ class PdfLoader(IDocumentLoader):
7
+ """
8
+ Chargeur PDF Hybride.
9
+ Extrait le texte natif si présent, sinon rasterise la page en image pour l'OCR.
10
+ """
11
+ @property
12
+ def name(self) -> str:
13
+ return "PdfLoader (Hybrid)"
14
+
15
+ def load(self, file_path: str) -> Document:
16
+ doc = Document(source_file=file_path)
17
+ pdf_document = fitz.open(file_path)
18
+
19
+ base_name = os.path.splitext(os.path.basename(file_path))[0]
20
+ temp_dir = f"temp_{base_name}"
21
+
22
+ for page_num in range(len(pdf_document)):
23
+ page = pdf_document[page_num]
24
+ rect = page.rect
25
+
26
+ # On vérifie s'il y a du texte natif sur la page
27
+ raw_text = page.get_text("text").strip()
28
+
29
+ dom_page = Page(
30
+ page_number=page_num + 1,
31
+ width=float(rect.width),
32
+ height=float(rect.height),
33
+ image_path=None # Par défaut, pas d'image
34
+ )
35
+
36
+ # --- CAS 1 : PAGE NATIVE (Texte présent) ---
37
+ if len(raw_text) > 10:
38
+ text_dict = page.get_text("dict")
39
+ region_order = 0
40
+ block_order = 0
41
+
42
+ for block in text_dict["blocks"]:
43
+ if block["type"] == 0:
44
+ b_box = block["bbox"]
45
+ region = Region(
46
+ bbox=BoundingBox(
47
+ x=float(b_box[0]), y=float(b_box[1]),
48
+ width=float(b_box[2] - b_box[0]),
49
+ height=float(b_box[3] - b_box[1])
50
+ ),
51
+ region_type=RegionType.TEXT,
52
+ reading_order=region_order
53
+ )
54
+ region_order += 1
55
+
56
+ for line in block["lines"]:
57
+ line_box = line["bbox"]
58
+ line_text = "".join([span["text"] for span in line["spans"]])
59
+ if line_text.strip():
60
+ dom_block = Block(
61
+ bbox=BoundingBox(
62
+ x=float(line_box[0]), y=float(line_box[1]),
63
+ width=float(line_box[2] - line_box[0]),
64
+ height=float(line_box[3] - line_box[1])
65
+ ),
66
+ block_type=BlockType.TEXT,
67
+ text=line_text.strip(),
68
+ reading_order=block_order
69
+ )
70
+ region.blocks.append(dom_block)
71
+ block_order += 1
72
+ if region.blocks:
73
+ dom_page.regions.append(region)
74
+
75
+ # --- CAS 2 : PAGE SCANNÉE (Pas de texte, on rasterise) ---
76
+ else:
77
+ if not os.path.exists(temp_dir):
78
+ os.makedirs(temp_dir)
79
+
80
+ zoom = 2.0
81
+ mat = fitz.Matrix(zoom, zoom)
82
+ pix = page.get_pixmap(matrix=mat)
83
+ img_path = os.path.abspath(os.path.join(temp_dir, f"page_{page_num + 1}.png"))
84
+ pix.save(img_path)
85
+
86
+ dom_page.width = float(pix.width)
87
+ dom_page.height = float(pix.height)
88
+ dom_page.image_path = img_path # On indique qu'il faut faire l'OCR sur cette page
89
+
90
+ doc.pages.append(dom_page)
91
+
92
+ pdf_document.close()
93
+ return doc
@@ -0,0 +1,60 @@
1
+ from pptx import Presentation
2
+ from src.core.interfaces import IDocumentLoader
3
+ from src.core.dom.models import Document, Page, Region, Block, Table, Cell, BoundingBox, BlockType, RegionType
4
+
5
+ class PptxLoader(IDocumentLoader):
6
+ @property
7
+ def name(self) -> str:
8
+ return "PptxLoader (Native)"
9
+
10
+ def load(self, file_path: str) -> Document:
11
+ doc = Document(source_file=file_path)
12
+ prs = Presentation(file_path)
13
+
14
+ for slide_idx, slide in enumerate(prs.slides):
15
+ page = Page(page_number=slide_idx + 1, width=float(prs.slide_width), height=float(prs.slide_height))
16
+ region = Region(
17
+ bbox=BoundingBox(0, 0, page.width, page.height),
18
+ region_type=RegionType.TEXT,
19
+ reading_order=0
20
+ )
21
+
22
+ reading_order = 0
23
+
24
+ for shape in slide.shapes:
25
+ # 1. Extraction du texte
26
+ if shape.has_text_frame:
27
+ for para in shape.text_frame.paragraphs:
28
+ text = para.text.strip()
29
+ if text:
30
+ region.blocks.append(Block(
31
+ bbox=BoundingBox(0, 0, 0, 0),
32
+ block_type=BlockType.TEXT,
33
+ text=text,
34
+ reading_order=reading_order
35
+ ))
36
+ reading_order += 1
37
+
38
+ # 2. Extraction des tableaux
39
+ if shape.has_table:
40
+ tbl = shape.table
41
+ dom_table = Table(
42
+ bbox=BoundingBox(0, 0, 0, 0),
43
+ rows=len(tbl.rows),
44
+ cols=len(tbl.columns),
45
+ reading_order=reading_order
46
+ )
47
+ for r_idx, row in enumerate(tbl.rows):
48
+ for c_idx, cell in enumerate(row.cells):
49
+ dom_table.cells.append(Cell(
50
+ bbox=BoundingBox(0, 0, 0, 0),
51
+ text=cell.text.strip(),
52
+ is_header=(r_idx == 0)
53
+ ))
54
+ region.tables.append(dom_table)
55
+ reading_order += 1
56
+
57
+ page.regions.append(region)
58
+ doc.pages.append(page)
59
+
60
+ return doc
@@ -0,0 +1,48 @@
1
+ import openpyxl
2
+ from src.core.interfaces import IDocumentLoader
3
+ from src.core.dom.models import Document, Page, Region, Table, Cell, BoundingBox, RegionType
4
+
5
+ class XlsxLoader(IDocumentLoader):
6
+ @property
7
+ def name(self) -> str:
8
+ return "XlsxLoader (Native)"
9
+
10
+ def load(self, file_path: str) -> Document:
11
+ doc = Document(source_file=file_path)
12
+ wb = openpyxl.load_workbook(file_path, data_only=True)
13
+
14
+ for sheet_idx, sheet_name in enumerate(wb.sheetnames):
15
+ sheet = wb[sheet_name]
16
+
17
+ # Chaque feuille est une Page logique
18
+ page = Page(page_number=sheet_idx + 1, width=0, height=0)
19
+ region = Region(
20
+ bbox=BoundingBox(0, 0, 0, 0),
21
+ region_type=RegionType.TABLE,
22
+ reading_order=0
23
+ )
24
+
25
+ # On crïżœe un tableau unique pour toute la feuille
26
+ rows = sheet.max_row
27
+ cols = sheet.max_column
28
+ dom_table = Table(
29
+ bbox=BoundingBox(0, 0, 0, 0),
30
+ rows=rows,
31
+ cols=cols,
32
+ reading_order=0
33
+ )
34
+
35
+ for r_idx, row in enumerate(sheet.iter_rows(values_only=True)):
36
+ for c_idx, val in enumerate(row):
37
+ cell_text = str(val) if val is not None else ""
38
+ dom_table.cells.append(Cell(
39
+ bbox=BoundingBox(0, 0, 0, 0),
40
+ text=cell_text.strip(),
41
+ is_header=(r_idx == 0)
42
+ ))
43
+
44
+ region.tables.append(dom_table)
45
+ page.regions.append(region)
46
+ doc.pages.append(page)
47
+
48
+ return doc
@@ -0,0 +1,74 @@
1
+ import ollama
2
+ import io
3
+ import base64
4
+ from PIL import Image
5
+ from src.core.interfaces import IDocumentPlugin
6
+ from src.core.dom.models import Document, Region, Block, BoundingBox, BlockType, RegionType
7
+
8
+ class QwenOCRPlugin(IDocumentPlugin):
9
+ """
10
+ Utilise Qwen2.5-VL via Ollama pour lire le texte dans les images.
11
+ Remplace Tesseract. N'a pas besoin de pré-traitement d'image.
12
+ """
13
+ @property
14
+ def name(self) -> str:
15
+ return "Qwen2.5-VL OCR"
16
+
17
+ def run(self, document: Document) -> Document:
18
+ for page in document.pages:
19
+ if not page.image_path:
20
+ continue
21
+
22
+ full_img = Image.open(page.image_path)
23
+
24
+ # Si Surya n'a rien trouvé, on traite l'image entiÚre
25
+ if not page.regions:
26
+ page.regions.append(Region(
27
+ bbox=BoundingBox(0, 0, page.width, page.height),
28
+ region_type=RegionType.TEXT,
29
+ reading_order=0
30
+ ))
31
+
32
+ for region in page.regions:
33
+ # On ne fait pas d'OCR sur les régions identifiées comme des images/photos
34
+ if region.region_type == RegionType.IMAGE:
35
+ continue
36
+
37
+ # 1. Découper l'image selon la région de Surya
38
+ x, y, w, h = region.bbox.x, region.bbox.y, region.bbox.width, region.bbox.height
39
+ crop = full_img.crop((int(x), int(y), int(x + w), int(y + h)))
40
+
41
+ # 2. Convertir l'image découpée en Base64 pour Ollama
42
+ buffered = io.BytesIO()
43
+ crop.save(buffered, format="PNG")
44
+ img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
45
+
46
+ # 3. Envoyer Ă  Qwen2.5-VL
47
+ try:
48
+ response = ollama.chat(
49
+ model='qwen2.5vl:7b', # Change le nom si tu as une autre version
50
+ messages=[{
51
+ 'role': 'user',
52
+ 'content': 'Transcris tout le texte visible sur cette image de maniĂšre exacte. Ne renvoie que le texte brut, sans commentaires ni markdown.',
53
+ 'images': [img_base64]
54
+ }]
55
+ )
56
+ extracted_text = response['message']['content'].strip()
57
+ except Exception as e:
58
+ print(f"Erreur Ollama sur la région : {e}")
59
+ extracted_text = ""
60
+
61
+ # 4. Stocker dans notre DOM
62
+ if extracted_text:
63
+ block = Block(
64
+ bbox=BoundingBox(
65
+ x=float(x), y=float(y),
66
+ width=float(w), height=float(h)
67
+ ),
68
+ block_type=BlockType.TEXT,
69
+ text=extracted_text,
70
+ reading_order=0 # L'ordre global est déjà géré par le ReadingOrderEngine
71
+ )
72
+ region.blocks.append(block)
73
+
74
+ return document
@@ -0,0 +1,75 @@
1
+ import pytesseract
2
+ from PIL import Image
3
+ from src.core.interfaces import IDocumentPlugin
4
+ from src.core.dom.models import Document, Region, Block, BoundingBox, BlockType, RegionType
5
+
6
+ class TesseractOCRPlugin(IDocumentPlugin):
7
+ @property
8
+ def name(self) -> str:
9
+ return "TesseractOCR"
10
+
11
+ def run(self, document: Document) -> Document:
12
+ for page in document.pages:
13
+ if not page.image_path:
14
+ continue
15
+
16
+ full_img = Image.open(page.image_path)
17
+
18
+ # Si Surya a créé des régions, on les utilise. Sinon, on crée une région globale.
19
+ if not page.regions:
20
+ page.regions.append(Region(
21
+ bbox=BoundingBox(0, 0, page.width, page.height),
22
+ region_type=RegionType.TEXT,
23
+ reading_order=0
24
+ ))
25
+
26
+ for region in page.regions:
27
+ # 1. On découpe l'image selon la BoundingBox de la région
28
+ x, y, w, h = region.bbox.x, region.bbox.y, region.bbox.width, region.bbox.height
29
+ cropped_img = full_img.crop((int(x), int(y), int(x + w), int(y + h)))
30
+
31
+ # 2. OCR sur la portion découpée
32
+ data = pytesseract.image_to_data(cropped_img, output_type=pytesseract.Output.DICT)
33
+
34
+ # 3. On regroupe les mots par ligne (comme on l'avait fait)
35
+ lines_data = {}
36
+ for i in range(len(data['text'])):
37
+ text = data['text'][i].strip()
38
+ if text and int(data['conf'][i]) > 0:
39
+ line_key = (data['block_num'][i], data['par_num'][i], data['line_num'][i])
40
+
41
+ if line_key not in lines_data:
42
+ lines_data[line_key] = {
43
+ 'words': [],
44
+ 'left': data['left'][i],
45
+ 'top': data['top'][i],
46
+ 'right': data['left'][i] + data['width'][i],
47
+ 'bottom': data['top'][i] + data['height'][i]
48
+ }
49
+ lines_data[line_key]['words'].append(text)
50
+ lines_data[line_key]['left'] = min(lines_data[line_key]['left'], data['left'][i])
51
+ lines_data[line_key]['top'] = min(lines_data[line_key]['top'], data['top'][i])
52
+ lines_data[line_key]['right'] = max(lines_data[line_key]['right'], data['left'][i] + data['width'][i])
53
+ lines_data[line_key]['bottom'] = max(lines_data[line_key]['bottom'], data['top'][i] + data['height'][i])
54
+
55
+ # 4. On crée les blocs dans la région (en ajustant les coordonnées par rapport à l'image pleine)
56
+ reading_order_counter = 0
57
+ sorted_lines = sorted(lines_data.values(), key=lambda x: x['top'])
58
+
59
+ for line in sorted_lines:
60
+ line_text = " ".join(line['words'])
61
+ block = Block(
62
+ bbox=BoundingBox(
63
+ x=float(x + line['left']), # Coordonnée absolue sur la page
64
+ y=float(y + line['top']), # Coordonnée absolue sur la page
65
+ width=float(line['right'] - line['left']),
66
+ height=float(line['bottom'] - line['top'])
67
+ ),
68
+ block_type=BlockType.TEXT,
69
+ text=line_text,
70
+ reading_order=reading_order_counter
71
+ )
72
+ region.blocks.append(block)
73
+ reading_order_counter += 1
74
+
75
+ return document
@@ -0,0 +1,99 @@
1
+ from src.core.interfaces import IDocumentPlugin
2
+ from src.core.dom.models import Document, Region
3
+
4
+ class ReadingOrderPlugin(IDocumentPlugin):
5
+ """
6
+ Moteur d'ordre de lecture utilisant l'algorithme XY-Cut.
7
+ Coupe récursivement la page en colonnes et blocs pour trouver l'ordre logique.
8
+ """
9
+ @property
10
+ def name(self) -> str:
11
+ return "ReadingOrderEngine (XY-Cut)"
12
+
13
+ def run(self, document: Document) -> Document:
14
+ for page in document.pages:
15
+ if not page.regions:
16
+ continue
17
+
18
+ # On lance l'algorithme récursif sur les régions de la page
19
+ ordered_regions = self._xy_cut_recursive(page.regions, page.width, page.height)
20
+
21
+ # On assigne l'ordre final
22
+ for i, region in enumerate(ordered_regions):
23
+ region.reading_order = i
24
+
25
+ # On met à jour la liste des régions de la page dans le bon ordre
26
+ page.regions = ordered_regions
27
+
28
+ return document
29
+
30
+ def _xy_cut_recursive(self, regions, page_width, page_height):
31
+ """Algorithme récursif de coupe XY."""
32
+ if len(regions) <= 1:
33
+ return regions
34
+
35
+ # 1. On tente une coupe Verticale (pour séparer les colonnes)
36
+ v_cut = self._find_largest_gap(regions, axis='x', page_size=page_width)
37
+ if v_cut is not None:
38
+ left = [r for r in regions if (r.bbox.x + r.bbox.width / 2) < v_cut]
39
+ right = [r for r in regions if (r.bbox.x + r.bbox.width / 2) >= v_cut]
40
+
41
+ if left and right:
42
+ # On lit la gauche, puis la droite
43
+ return self._xy_cut_recursive(left, page_width, page_height) + \
44
+ self._xy_cut_recursive(right, page_width, page_height)
45
+
46
+ # 2. Si pas de coupe verticale, on tente une coupe Horizontale (paragraphes)
47
+ h_cut = self._find_largest_gap(regions, axis='y', page_size=page_height)
48
+ if h_cut is not None:
49
+ top = [r for r in regions if (r.bbox.y + r.bbox.height / 2) < h_cut]
50
+ bottom = [r for r in regions if (r.bbox.y + r.bbox.height / 2) >= h_cut]
51
+
52
+ if top and bottom:
53
+ # On lit le haut, puis le bas
54
+ return self._xy_cut_recursive(top, page_width, page_height) + \
55
+ self._xy_cut_recursive(bottom, page_width, page_height)
56
+
57
+ # 3. Fallback : si aucune coupe trouvée, on trie par Y puis X
58
+ return sorted(regions, key=lambda r: (r.bbox.y, r.bbox.x))
59
+
60
+ def _find_largest_gap(self, regions, axis, page_size):
61
+ """Trouve le plus grand espace blanc sur un axe (x ou y)."""
62
+ intervals = []
63
+ for r in regions:
64
+ if axis == 'x':
65
+ intervals.append((r.bbox.x, r.bbox.x + r.bbox.width))
66
+ else:
67
+ intervals.append((r.bbox.y, r.bbox.y + r.bbox.height))
68
+
69
+ # Trier par coordonnée de début
70
+ intervals.sort(key=lambda x: x[0])
71
+
72
+ # Fusionner les intervalles qui se chevauchent
73
+ merged = []
74
+ for start, end in intervals:
75
+ if not merged or start > merged[-1][1]:
76
+ merged.append([start, end])
77
+ else:
78
+ merged[-1][1] = max(merged[-1][1], end)
79
+
80
+ if len(merged) < 2:
81
+ return None # Pas d'espace possible
82
+
83
+ # Trouver le plus grand espace entre deux blocs fusionnés
84
+ max_gap = 0
85
+ best_cut = None
86
+ for i in range(len(merged) - 1):
87
+ gap_start = merged[i][1]
88
+ gap_end = merged[i+1][0]
89
+ gap = gap_end - gap_start
90
+
91
+ if gap > max_gap:
92
+ max_gap = gap
93
+ best_cut = (gap_start + gap_end) / 2.0
94
+
95
+ # On ne coupe que si l'espace blanc est significatif (ex: > 5% de la page)
96
+ if max_gap > (page_size * 0.05):
97
+ return best_cut
98
+
99
+ return None