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.
- doc_ai_runtime-0.1.0.dist-info/METADATA +106 -0
- doc_ai_runtime-0.1.0.dist-info/RECORD +20 -0
- doc_ai_runtime-0.1.0.dist-info/WHEEL +5 -0
- doc_ai_runtime-0.1.0.dist-info/licenses/LICENSE +22 -0
- doc_ai_runtime-0.1.0.dist-info/top_level.txt +1 -0
- src/core/config.py +0 -0
- src/core/dom/models.py +127 -0
- src/core/interfaces.py +41 -0
- src/core/pipeline/runner.py +83 -0
- src/exporters/markdown.py +56 -0
- src/exporters/rag.py +78 -0
- src/plugins/layout/surya_layout.py +76 -0
- src/plugins/loaders/docx_loader.py +89 -0
- src/plugins/loaders/image_loader.py +41 -0
- src/plugins/loaders/pdf_loader.py +93 -0
- src/plugins/loaders/pptx_loader.py +60 -0
- src/plugins/loaders/xlsx_loader.py +48 -0
- src/plugins/ocr/qwen_ocr.py +74 -0
- src/plugins/ocr/tesseract_ocr.py +75 -0
- src/plugins/reading_order/reading_order_engine.py +99 -0
|
@@ -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,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
|