Spaces:
Running on Zero
Running on Zero
Initial commit
Browse files- LICENSE +21 -0
- README.md +38 -12
- app.py +6 -0
- main.py +140 -0
- processors/__init__.py +19 -0
- processors/base.py +291 -0
- processors/text/__init__.py +19 -0
- processors/text/code_processor.py +66 -0
- processors/text/docx_processor.py +470 -0
- processors/text/excel_processor.py +138 -0
- processors/text/ipynb_processor.py +90 -0
- processors/text/markdown_processor.py +81 -0
- processors/text/pdf_processor.py +94 -0
- processors/text/pptx_processor.py +92 -0
- processors/text/text_processor.py +41 -0
- processors/vision/__init__.py +7 -0
- processors/vision/vision_processor.py +658 -0
- requirements.txt +24 -0
- ui/__init__.py +1 -0
- ui/__pycache__/__init__.cpython-312.pyc +0 -0
- ui/__pycache__/gradio_app.cpython-312.pyc +0 -0
- ui/gradio_app.py +81 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Soumyak
|
| 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.
|
README.md
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
license: mit
|
| 12 |
-
short_description: Convert Anything to Markdown file. (.md for life)
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VisionMark
|
| 2 |
+
|
| 3 |
+
VisionMark is a local-first document conversion pipeline that transforms various file formats into clean, structured Markdown optimized for Large Language Model (LLM) context windows.
|
| 4 |
+
|
| 5 |
+
While other tools rely on cloud APIs and external services, VisionMark is built to run entirely on your local machine. It uses a hybrid approach: rule-based parsing for standard text formats and native, local execution of the Qwen2.5-VL vision model for complex, scanned, or visually-heavy documents. This ensures your data remains private and you incur zero API costs.
|
| 6 |
+
|
| 7 |
---
|
| 8 |
+
|
| 9 |
+
## Core Features
|
| 10 |
+
|
| 11 |
+
- **Fully Local Execution:** Runs Qwen2.5-VL directly via Hugging Face Transformers. No third-party API keys or cloud services required.
|
| 12 |
+
- **Format Agnostic:** Native support for PDFs, Office documents (DOCX, PPTX, XLSX), images, code files, and Jupyter Notebooks.
|
| 13 |
+
- **Hybrid Pipeline:** Automatically routes text-heavy documents through fast rule-based parsers and uses the vision model for complex layouts or images.
|
| 14 |
+
- **Smart PDF Batching:** Dynamically adjusts batch sizes when processing large PDFs to manage VRAM and token limits efficiently.
|
| 15 |
+
- **Granular Control:** Offers CLI and UI options to control generation temperature, token limits, and concurrent processing threads.
|
| 16 |
+
|
|
|
|
|
|
|
| 17 |
---
|
| 18 |
|
| 19 |
+
|
| 20 |
+
## Supported Formats
|
| 21 |
+
Category | Formats |
|
| 22 |
+
|------------|----------------------------------|
|
| 23 |
+
| Documents | PDF, DOCX, PPTX, XLSX |
|
| 24 |
+
| Images | PNG, JPG, JPEG, BMP |
|
| 25 |
+
| Code | Python, R, JavaScript, C++, etc.|
|
| 26 |
+
| Notebooks | Jupyter Notebooks (.ipynb) |
|
| 27 |
+
| Markdown | MD, RMD |
|
| 28 |
+
| Text | TXT |
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Installation
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
git clone https://github.com/mkdir-smyk/VisionMark.git
|
| 36 |
+
cd VisionMark
|
| 37 |
+
|
| 38 |
+
python -m venv venv
|
| 39 |
+
source venv/bin/activate
|
| 40 |
+
|
| 41 |
+
pip install -r requirements.txt
|
app.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ui.gradio_app import create_ui
|
| 2 |
+
|
| 3 |
+
demo = create_ui()
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
demo.launch()
|
main.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import argparse
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from processors.base import BaseDocumentProcessor, DocumentType
|
| 5 |
+
from processors.vision.vision_processor import VisionDocumentProcessor
|
| 6 |
+
|
| 7 |
+
def process_file(file_path, output_dir, force_vision=False, max_concurrent=2, images_per_batch=1,
|
| 8 |
+
temperature=0.0, max_tokens=None, dynamic_batching=True, max_tokens_per_batch=4000,
|
| 9 |
+
api_key=None, base_url=None, model=None):
|
| 10 |
+
"""Process a single file"""
|
| 11 |
+
try:
|
| 12 |
+
print(f"Processing: {file_path}")
|
| 13 |
+
|
| 14 |
+
# Determine document type first
|
| 15 |
+
doc_type = DocumentType.from_file_extension(file_path)
|
| 16 |
+
|
| 17 |
+
# Select appropriate processor based on type and options
|
| 18 |
+
if force_vision and doc_type == DocumentType.PDF:
|
| 19 |
+
print(f"Using Vision processor for PDF: {file_path}")
|
| 20 |
+
processor = VisionDocumentProcessor(
|
| 21 |
+
api_key=api_key,
|
| 22 |
+
base_url=base_url,
|
| 23 |
+
model=model,
|
| 24 |
+
temperature=temperature,
|
| 25 |
+
max_tokens=max_tokens
|
| 26 |
+
)
|
| 27 |
+
elif doc_type == DocumentType.IMAGE:
|
| 28 |
+
print(f"Using Vision processor for image: {file_path}")
|
| 29 |
+
processor = VisionDocumentProcessor(
|
| 30 |
+
api_key=api_key,
|
| 31 |
+
base_url=base_url,
|
| 32 |
+
model=model,
|
| 33 |
+
temperature=temperature,
|
| 34 |
+
max_tokens=max_tokens
|
| 35 |
+
)
|
| 36 |
+
else:
|
| 37 |
+
# Use standard document detection
|
| 38 |
+
processor = BaseDocumentProcessor.get_processor(file_path)
|
| 39 |
+
|
| 40 |
+
print(f"Using {processor.__class__.__name__} for {file_path}")
|
| 41 |
+
|
| 42 |
+
# Process document - with max_concurrent and images_per_batch for PDFs
|
| 43 |
+
if isinstance(processor, VisionDocumentProcessor) and doc_type == DocumentType.PDF:
|
| 44 |
+
document = processor.process(
|
| 45 |
+
file_path,
|
| 46 |
+
max_concurrent=max_concurrent,
|
| 47 |
+
images_per_batch=images_per_batch,
|
| 48 |
+
dynamic_batching=dynamic_batching,
|
| 49 |
+
max_tokens_per_batch=max_tokens_per_batch
|
| 50 |
+
)
|
| 51 |
+
print(f"Processed PDF with {max_concurrent} concurrent workers, dynamic batching: {dynamic_batching}")
|
| 52 |
+
else:
|
| 53 |
+
document = processor.process(file_path)
|
| 54 |
+
|
| 55 |
+
# Generate output filename
|
| 56 |
+
base_name = Path(file_path).stem
|
| 57 |
+
output_base = os.path.join(output_dir, base_name)
|
| 58 |
+
|
| 59 |
+
md_path = f"{output_base}.md"
|
| 60 |
+
with open(md_path, "w", encoding="utf-8") as f:
|
| 61 |
+
f.write(document.to_markdown())
|
| 62 |
+
print(f"Markdown saved to: {md_path}")
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f"Error processing {file_path}: {str(e)}")
|
| 66 |
+
import traceback
|
| 67 |
+
traceback.print_exc()
|
| 68 |
+
|
| 69 |
+
def process_directory(input_dir, output_dir, force_vision=False, max_concurrent=2, images_per_batch=1,
|
| 70 |
+
temperature=0.0, max_tokens=None, dynamic_batching=True, max_tokens_per_batch=4000,
|
| 71 |
+
api_key=None, base_url=None, model=None):
|
| 72 |
+
"""Process all files in directory"""
|
| 73 |
+
for root, _, files in os.walk(input_dir):
|
| 74 |
+
for file in files:
|
| 75 |
+
file_path = os.path.join(root, file)
|
| 76 |
+
process_file(file_path, output_dir, force_vision, max_concurrent, images_per_batch,
|
| 77 |
+
temperature, max_tokens, dynamic_batching, max_tokens_per_batch,
|
| 78 |
+
api_key, base_url, model)
|
| 79 |
+
|
| 80 |
+
def main():
|
| 81 |
+
parser = argparse.ArgumentParser(description="Convert documents to structured markdown")
|
| 82 |
+
|
| 83 |
+
parser.add_argument("input", nargs="?", help="Input file or directory path")
|
| 84 |
+
parser.add_argument("--output", "-o", help="Output directory path", default="output")
|
| 85 |
+
parser.add_argument("--ui", action="store_true", help="Launch graphical user interface")
|
| 86 |
+
parser.add_argument("--force-vision", action="store_true",
|
| 87 |
+
help="Force using vision model for PDFs instead of text extraction")
|
| 88 |
+
parser.add_argument("--max-concurrent", type=int, default=2,
|
| 89 |
+
help="Maximum number of concurrent workers for PDF page processing (default: 2)")
|
| 90 |
+
parser.add_argument("--api-key", help="API key for Qwen vision processor")
|
| 91 |
+
parser.add_argument("--base-url", help="Base URL for API")
|
| 92 |
+
parser.add_argument("--model", help="Model name to use")
|
| 93 |
+
parser.add_argument("--images-per-batch", type=int, default=1,
|
| 94 |
+
help="Maximum number of PDF pages to process in a single API call (default: 1, 2+ enables multi-image processing)")
|
| 95 |
+
parser.add_argument("--temperature", type=float, default=0.0,
|
| 96 |
+
help="Temperature for vision model generation (0.0-1.0, lower is more deterministic)")
|
| 97 |
+
parser.add_argument("--max-tokens", type=int,
|
| 98 |
+
help="Maximum tokens for vision model generation (default uses model's limit)")
|
| 99 |
+
parser.add_argument("--dynamic-batching", action="store_true", default=True,
|
| 100 |
+
help="Automatically determine optimal batch size based on image complexity. The optimal batch size will not exceed the number set by --images-per-batch.")
|
| 101 |
+
parser.add_argument("--no-dynamic-batching", action="store_false", dest="dynamic_batching",
|
| 102 |
+
help="Disable dynamic batching and use fixed images-per-batch")
|
| 103 |
+
parser.add_argument("--max-tokens-per-batch", type=int, default=4000,
|
| 104 |
+
help="Maximum tokens per batch when using dynamic batching (default: 4000)")
|
| 105 |
+
|
| 106 |
+
args = parser.parse_args()
|
| 107 |
+
|
| 108 |
+
# Launch UI if requested
|
| 109 |
+
if args.ui:
|
| 110 |
+
from ui.app import create_ui
|
| 111 |
+
app = create_ui()
|
| 112 |
+
# Allow access to any path
|
| 113 |
+
app.launch(allowed_paths=["/"])
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
# If no input is provided and not in UI mode, print help
|
| 117 |
+
if not args.input:
|
| 118 |
+
parser.print_help()
|
| 119 |
+
return
|
| 120 |
+
|
| 121 |
+
# Ensure output directory exists
|
| 122 |
+
os.makedirs(args.output, exist_ok=True)
|
| 123 |
+
|
| 124 |
+
if os.path.isdir(args.input):
|
| 125 |
+
# Process directory
|
| 126 |
+
process_directory(
|
| 127 |
+
args.input, args.output, args.force_vision, args.max_concurrent, args.images_per_batch,
|
| 128 |
+
args.temperature, args.max_tokens, args.dynamic_batching, args.max_tokens_per_batch,
|
| 129 |
+
args.api_key, args.base_url, args.model
|
| 130 |
+
)
|
| 131 |
+
else:
|
| 132 |
+
# Process single file
|
| 133 |
+
process_file(
|
| 134 |
+
args.input, args.output, args.force_vision, args.max_concurrent, args.images_per_batch,
|
| 135 |
+
args.temperature, args.max_tokens, args.dynamic_batching, args.max_tokens_per_batch,
|
| 136 |
+
args.api_key, args.base_url, args.model
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
main()
|
processors/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VisionMark document processors package.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from processors.base import (
|
| 6 |
+
BaseDocumentProcessor,
|
| 7 |
+
DocumentType,
|
| 8 |
+
DocumentElement,
|
| 9 |
+
DocumentSection,
|
| 10 |
+
StructuredDocument
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
'BaseDocumentProcessor',
|
| 15 |
+
'DocumentType',
|
| 16 |
+
'DocumentElement',
|
| 17 |
+
'DocumentSection',
|
| 18 |
+
'StructuredDocument'
|
| 19 |
+
]
|
processors/base.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Dict, List, Optional, Union, Any
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
class DocumentType(Enum):
|
| 9 |
+
"""Supported document types"""
|
| 10 |
+
PDF = "pdf"
|
| 11 |
+
IMAGE = "image" # jpg, png, etc.
|
| 12 |
+
WORD = "docx"
|
| 13 |
+
POWERPOINT = "pptx"
|
| 14 |
+
JUPYTER = "ipynb"
|
| 15 |
+
PYTHON = "py"
|
| 16 |
+
R_SCRIPT = "r"
|
| 17 |
+
R_MARKDOWN = "rmd"
|
| 18 |
+
MARKDOWN = "md"
|
| 19 |
+
TEXT = "txt"
|
| 20 |
+
EXCEL = "excel" # xlsx, xls files
|
| 21 |
+
UNKNOWN = "unknown"
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def from_file_extension(cls, file_path: str) -> "DocumentType":
|
| 25 |
+
"""Determine document type from file extension"""
|
| 26 |
+
ext = os.path.splitext(file_path.lower())[1][1:]
|
| 27 |
+
|
| 28 |
+
if ext in ("jpg", "jpeg", "png", "bmp", "tiff"):
|
| 29 |
+
return cls.IMAGE
|
| 30 |
+
elif ext == "pdf":
|
| 31 |
+
return cls.PDF
|
| 32 |
+
elif ext == "docx":
|
| 33 |
+
return cls.WORD
|
| 34 |
+
elif ext == "pptx":
|
| 35 |
+
return cls.POWERPOINT
|
| 36 |
+
elif ext == "ipynb":
|
| 37 |
+
return cls.JUPYTER
|
| 38 |
+
elif ext == "py":
|
| 39 |
+
return cls.PYTHON
|
| 40 |
+
elif ext == "r":
|
| 41 |
+
return cls.R_SCRIPT
|
| 42 |
+
elif ext == "rmd":
|
| 43 |
+
return cls.R_MARKDOWN
|
| 44 |
+
elif ext == "md":
|
| 45 |
+
return cls.MARKDOWN
|
| 46 |
+
elif ext == "txt":
|
| 47 |
+
return cls.TEXT
|
| 48 |
+
elif ext in ("xlsx", "xls", "xlsm"):
|
| 49 |
+
return cls.EXCEL
|
| 50 |
+
else:
|
| 51 |
+
return cls.UNKNOWN
|
| 52 |
+
|
| 53 |
+
class DocumentElement:
|
| 54 |
+
"""Base class for document elements"""
|
| 55 |
+
def __init__(
|
| 56 |
+
self,
|
| 57 |
+
content: str,
|
| 58 |
+
element_type: str,
|
| 59 |
+
position: Optional[Dict[str, Any]] = None,
|
| 60 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 61 |
+
):
|
| 62 |
+
self.content = content
|
| 63 |
+
self.element_type = element_type
|
| 64 |
+
self.position = position or {}
|
| 65 |
+
self.metadata = metadata or {}
|
| 66 |
+
|
| 67 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 68 |
+
"""Convert to dictionary representation"""
|
| 69 |
+
return {
|
| 70 |
+
"content": self.content,
|
| 71 |
+
"type": self.element_type,
|
| 72 |
+
"position": self.position,
|
| 73 |
+
"metadata": self.metadata
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
def to_markdown(self) -> str:
|
| 77 |
+
"""Convert to markdown representation"""
|
| 78 |
+
if self.element_type == "heading":
|
| 79 |
+
level = self.metadata.get("level", 1)
|
| 80 |
+
return f"{'#' * level} {self.content}\n\n"
|
| 81 |
+
elif self.element_type == "paragraph":
|
| 82 |
+
return f"{self.content}\n\n"
|
| 83 |
+
elif self.element_type == "code":
|
| 84 |
+
lang = self.metadata.get("language", "")
|
| 85 |
+
return f"```{lang}\n{self.content}\n```\n\n"
|
| 86 |
+
elif self.element_type == "list_item":
|
| 87 |
+
return f"- {self.content}\n"
|
| 88 |
+
elif self.element_type == "image":
|
| 89 |
+
alt = self.metadata.get("alt", "Image")
|
| 90 |
+
src = self.metadata.get("src", "")
|
| 91 |
+
return f"\n\n"
|
| 92 |
+
elif self.element_type == "table":
|
| 93 |
+
# Basic table rendering - in real implementation this would be more complex
|
| 94 |
+
return f"**Table**: {self.content}\n\n"
|
| 95 |
+
else:
|
| 96 |
+
return f"{self.content}\n\n"
|
| 97 |
+
|
| 98 |
+
class DocumentSection:
|
| 99 |
+
"""Section of a document containing multiple elements"""
|
| 100 |
+
def __init__(
|
| 101 |
+
self,
|
| 102 |
+
title: Optional[str] = None,
|
| 103 |
+
elements: Optional[List[DocumentElement]] = None,
|
| 104 |
+
level: int = 1,
|
| 105 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 106 |
+
):
|
| 107 |
+
self.title = title
|
| 108 |
+
self.elements = elements or []
|
| 109 |
+
self.level = level
|
| 110 |
+
self.metadata = metadata or {}
|
| 111 |
+
|
| 112 |
+
def add_element(self, element: DocumentElement) -> None:
|
| 113 |
+
"""Add an element to the section"""
|
| 114 |
+
self.elements.append(element)
|
| 115 |
+
|
| 116 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 117 |
+
"""Convert to dictionary representation"""
|
| 118 |
+
return {
|
| 119 |
+
"title": self.title,
|
| 120 |
+
"level": self.level,
|
| 121 |
+
"elements": [element.to_dict() for element in self.elements],
|
| 122 |
+
"metadata": self.metadata
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
def to_markdown(self) -> str:
|
| 126 |
+
"""Convert to markdown representation"""
|
| 127 |
+
result = ""
|
| 128 |
+
if self.title:
|
| 129 |
+
result += f"{'#' * self.level} {self.title}\n\n"
|
| 130 |
+
|
| 131 |
+
for element in self.elements:
|
| 132 |
+
result += element.to_markdown()
|
| 133 |
+
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
class StructuredDocument:
|
| 137 |
+
"""Complete structured document"""
|
| 138 |
+
def __init__(
|
| 139 |
+
self,
|
| 140 |
+
title: Optional[str] = None,
|
| 141 |
+
source_file: Optional[str] = None,
|
| 142 |
+
doc_type: Optional[DocumentType] = None,
|
| 143 |
+
sections: Optional[List[DocumentSection]] = None,
|
| 144 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 145 |
+
):
|
| 146 |
+
self.title = title
|
| 147 |
+
self.source_file = source_file
|
| 148 |
+
self.doc_type = doc_type or DocumentType.UNKNOWN
|
| 149 |
+
self.sections = sections or []
|
| 150 |
+
self.metadata = metadata or {}
|
| 151 |
+
|
| 152 |
+
def add_section(self, section: DocumentSection) -> None:
|
| 153 |
+
"""Add a section to the document"""
|
| 154 |
+
self.sections.append(section)
|
| 155 |
+
|
| 156 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 157 |
+
"""Convert to dictionary representation"""
|
| 158 |
+
return {
|
| 159 |
+
"title": self.title,
|
| 160 |
+
"source_file": self.source_file,
|
| 161 |
+
"doc_type": self.doc_type.value if self.doc_type else None,
|
| 162 |
+
"sections": [section.to_dict() for section in self.sections],
|
| 163 |
+
"metadata": self.metadata
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
def to_markdown(self) -> str:
|
| 167 |
+
"""Convert document to markdown format"""
|
| 168 |
+
# If we have direct markdown in metadata, use that
|
| 169 |
+
if "markdown" in self.metadata:
|
| 170 |
+
return self.metadata["markdown"]
|
| 171 |
+
|
| 172 |
+
# Otherwise, build markdown from structure
|
| 173 |
+
parts = []
|
| 174 |
+
|
| 175 |
+
# Add document title
|
| 176 |
+
if self.title:
|
| 177 |
+
parts.append(f"# {self.title}\n\n")
|
| 178 |
+
|
| 179 |
+
# Add each section
|
| 180 |
+
for section in self.sections:
|
| 181 |
+
# Add section title with appropriate heading level
|
| 182 |
+
if section.title:
|
| 183 |
+
level = section.level if section.level else 1
|
| 184 |
+
heading = "#" * (level + 1) # +1 because document title is h1
|
| 185 |
+
parts.append(f"{heading} {section.title}\n\n")
|
| 186 |
+
|
| 187 |
+
# Add elements
|
| 188 |
+
for element in section.elements:
|
| 189 |
+
if element.element_type == "markdown":
|
| 190 |
+
# For markdown elements, just add the content directly
|
| 191 |
+
parts.append(f"{element.content}\n\n")
|
| 192 |
+
else:
|
| 193 |
+
# For other elements, convert to markdown
|
| 194 |
+
if element.element_type == "paragraph":
|
| 195 |
+
parts.append(f"{element.content}\n\n")
|
| 196 |
+
elif element.element_type == "heading":
|
| 197 |
+
level = element.metadata.get("level", 2)
|
| 198 |
+
heading = "#" * (level + 1) # +1 because doc title is h1
|
| 199 |
+
parts.append(f"{heading} {element.content}\n\n")
|
| 200 |
+
elif element.element_type == "list":
|
| 201 |
+
for item in element.content.split("\n"):
|
| 202 |
+
parts.append(f"- {item.strip()}\n")
|
| 203 |
+
parts.append("\n")
|
| 204 |
+
elif element.element_type == "code":
|
| 205 |
+
lang = element.metadata.get("language", "")
|
| 206 |
+
parts.append(f"```{lang}\n{element.content}\n```\n\n")
|
| 207 |
+
else:
|
| 208 |
+
# Default behavior for unknown element types
|
| 209 |
+
parts.append(f"{element.content}\n\n")
|
| 210 |
+
|
| 211 |
+
return "".join(parts)
|
| 212 |
+
|
| 213 |
+
def save_markdown(self, output_path: str) -> None:
|
| 214 |
+
"""Save as markdown file"""
|
| 215 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 216 |
+
f.write(self.to_markdown())
|
| 217 |
+
|
| 218 |
+
def get_direct_markdown(self) -> str:
|
| 219 |
+
"""
|
| 220 |
+
Get direct markdown if available, otherwise generate from structure
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
str: Markdown representation of the document
|
| 224 |
+
"""
|
| 225 |
+
if "direct_markdown" in self.metadata:
|
| 226 |
+
return self.metadata["direct_markdown"]
|
| 227 |
+
else:
|
| 228 |
+
return self.to_markdown()
|
| 229 |
+
|
| 230 |
+
class BaseDocumentProcessor(ABC):
|
| 231 |
+
"""Abstract base class for document processors"""
|
| 232 |
+
|
| 233 |
+
def __init__(self):
|
| 234 |
+
"""Initialize the document processor"""
|
| 235 |
+
pass
|
| 236 |
+
|
| 237 |
+
@abstractmethod
|
| 238 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 239 |
+
"""
|
| 240 |
+
Process a document and return structured content
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
file_path: Path to the document file
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
StructuredDocument: The processed document
|
| 247 |
+
"""
|
| 248 |
+
pass
|
| 249 |
+
|
| 250 |
+
@classmethod
|
| 251 |
+
def get_processor(cls, file_path: str, force_vision: bool = False) -> "BaseDocumentProcessor":
|
| 252 |
+
"""Factory method to get appropriate processor for a file"""
|
| 253 |
+
doc_type = DocumentType.from_file_extension(file_path)
|
| 254 |
+
|
| 255 |
+
# Import here to avoid circular imports
|
| 256 |
+
from processors.text.pdf_processor import PDFProcessor
|
| 257 |
+
from processors.vision.vision_processor import VisionDocumentProcessor
|
| 258 |
+
from processors.text.docx_processor import DocxProcessor
|
| 259 |
+
from processors.text.pptx_processor import PowerPointProcessor
|
| 260 |
+
from processors.text.ipynb_processor import JupyterNotebookProcessor
|
| 261 |
+
from processors.text.code_processor import CodeFileProcessor
|
| 262 |
+
from processors.text.markdown_processor import MarkdownProcessor
|
| 263 |
+
from processors.text.text_processor import TextProcessor
|
| 264 |
+
from processors.text.excel_processor import ExcelProcessor
|
| 265 |
+
|
| 266 |
+
# Use vision processor if forced (applies to PDF)
|
| 267 |
+
if force_vision:
|
| 268 |
+
return VisionDocumentProcessor()
|
| 269 |
+
|
| 270 |
+
# Return appropriate processor based on document type
|
| 271 |
+
if doc_type == DocumentType.PDF:
|
| 272 |
+
return PDFProcessor()
|
| 273 |
+
elif doc_type == DocumentType.WORD:
|
| 274 |
+
return DocxProcessor()
|
| 275 |
+
elif doc_type == DocumentType.POWERPOINT:
|
| 276 |
+
return PowerPointProcessor()
|
| 277 |
+
elif doc_type == DocumentType.JUPYTER:
|
| 278 |
+
return JupyterNotebookProcessor()
|
| 279 |
+
elif doc_type == DocumentType.IMAGE:
|
| 280 |
+
return VisionDocumentProcessor()
|
| 281 |
+
elif doc_type in (DocumentType.PYTHON, DocumentType.R_SCRIPT):
|
| 282 |
+
return CodeFileProcessor()
|
| 283 |
+
elif doc_type in (DocumentType.MARKDOWN, DocumentType.R_MARKDOWN):
|
| 284 |
+
return MarkdownProcessor()
|
| 285 |
+
elif doc_type == DocumentType.EXCEL:
|
| 286 |
+
return ExcelProcessor()
|
| 287 |
+
elif doc_type == DocumentType.TEXT:
|
| 288 |
+
return TextProcessor()
|
| 289 |
+
else:
|
| 290 |
+
# Default to text processor for unknown types
|
| 291 |
+
return TextProcessor()
|
processors/text/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.text.pdf_processor import PDFProcessor
|
| 2 |
+
from processors.text.docx_processor import DocxProcessor
|
| 3 |
+
from processors.text.pptx_processor import PowerPointProcessor
|
| 4 |
+
from processors.text.ipynb_processor import JupyterNotebookProcessor
|
| 5 |
+
from processors.text.code_processor import CodeFileProcessor
|
| 6 |
+
from processors.text.markdown_processor import MarkdownProcessor
|
| 7 |
+
from processors.text.text_processor import TextProcessor
|
| 8 |
+
from processors.text.excel_processor import ExcelProcessor
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
'PDFProcessor',
|
| 12 |
+
'DocxProcessor',
|
| 13 |
+
'PowerPointProcessor',
|
| 14 |
+
'JupyterNotebookProcessor',
|
| 15 |
+
'CodeFileProcessor',
|
| 16 |
+
'MarkdownProcessor',
|
| 17 |
+
'TextProcessor',
|
| 18 |
+
'ExcelProcessor',
|
| 19 |
+
]
|
processors/text/code_processor.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
class CodeFileProcessor(BaseDocumentProcessor):
|
| 5 |
+
"""Processor for code files (Python, R, etc.)"""
|
| 6 |
+
|
| 7 |
+
# Language mapping for syntax highlighting
|
| 8 |
+
LANGUAGE_MAP = {
|
| 9 |
+
"py": "python",
|
| 10 |
+
"r": "r",
|
| 11 |
+
"js": "javascript",
|
| 12 |
+
"ts": "typescript",
|
| 13 |
+
"sh": "bash",
|
| 14 |
+
"cpp": "cpp",
|
| 15 |
+
"c": "c",
|
| 16 |
+
"java": "java",
|
| 17 |
+
"go": "go",
|
| 18 |
+
"rb": "ruby",
|
| 19 |
+
"php": "php",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 23 |
+
"""Process code file"""
|
| 24 |
+
file_ext = Path(file_path).suffix.lower()[1:]
|
| 25 |
+
doc_type = DocumentType.from_file_extension(file_path)
|
| 26 |
+
|
| 27 |
+
document = StructuredDocument(
|
| 28 |
+
title=Path(file_path).stem,
|
| 29 |
+
source_file=file_path,
|
| 30 |
+
doc_type=doc_type
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
# Read code file
|
| 35 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 36 |
+
code_content = f.read()
|
| 37 |
+
|
| 38 |
+
# Get language for syntax highlighting
|
| 39 |
+
language = self.LANGUAGE_MAP.get(file_ext, file_ext)
|
| 40 |
+
|
| 41 |
+
# Create markdown representation
|
| 42 |
+
markdown = f"# {Path(file_path).name}\n\n"
|
| 43 |
+
markdown += f"```{language}\n{code_content}\n```\n"
|
| 44 |
+
|
| 45 |
+
# Create document structure
|
| 46 |
+
main_section = DocumentSection(title=Path(file_path).name)
|
| 47 |
+
main_section.add_element(DocumentElement(
|
| 48 |
+
content=code_content,
|
| 49 |
+
element_type="code",
|
| 50 |
+
metadata={"language": language}
|
| 51 |
+
))
|
| 52 |
+
document.add_section(main_section)
|
| 53 |
+
|
| 54 |
+
# Store markdown
|
| 55 |
+
document.metadata["markdown"] = markdown
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"Error processing code file: {e}")
|
| 59 |
+
error_section = DocumentSection(title="Error")
|
| 60 |
+
error_section.add_element(DocumentElement(
|
| 61 |
+
content=f"Failed to process code file: {str(e)}",
|
| 62 |
+
element_type="paragraph"
|
| 63 |
+
))
|
| 64 |
+
document.add_section(error_section)
|
| 65 |
+
|
| 66 |
+
return document
|
processors/text/docx_processor.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import mammoth
|
| 4 |
+
import re
|
| 5 |
+
import docx
|
| 6 |
+
from docx2python import docx2python
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from io import StringIO
|
| 9 |
+
|
| 10 |
+
class DocxProcessor(BaseDocumentProcessor):
|
| 11 |
+
"""Processor for DOCX documents with enhanced table support"""
|
| 12 |
+
|
| 13 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 14 |
+
"""Process DOCX document with improved table handling"""
|
| 15 |
+
document = StructuredDocument(
|
| 16 |
+
title=Path(file_path).stem,
|
| 17 |
+
source_file=file_path,
|
| 18 |
+
doc_type=DocumentType.WORD
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
# First approach: Try to extract tables using python-docx directly
|
| 23 |
+
doc = docx.Document(file_path)
|
| 24 |
+
has_tables = len(doc.tables) > 0
|
| 25 |
+
|
| 26 |
+
# Convert DOCX to Markdown using mammoth for general content
|
| 27 |
+
with open(file_path, "rb") as docx_file:
|
| 28 |
+
result = mammoth.convert_to_markdown(docx_file)
|
| 29 |
+
markdown_content = result.value
|
| 30 |
+
|
| 31 |
+
# If document has tables, use our direct table extraction
|
| 32 |
+
if (has_tables):
|
| 33 |
+
markdown_content = self._handle_tables_directly(doc, markdown_content)
|
| 34 |
+
else:
|
| 35 |
+
# As a fallback, try docx2python for table extraction
|
| 36 |
+
try:
|
| 37 |
+
doc_data = docx2python(file_path)
|
| 38 |
+
if hasattr(doc_data, 'tables') and doc_data.tables:
|
| 39 |
+
markdown_content = self._process_tables(file_path, markdown_content, doc_data.tables)
|
| 40 |
+
except Exception as table_error:
|
| 41 |
+
print(f"Warning: Unable to process tables with docx2python: {table_error}")
|
| 42 |
+
|
| 43 |
+
# Create a single section with the markdown content
|
| 44 |
+
section = DocumentSection(title="Document Content")
|
| 45 |
+
section.add_element(DocumentElement(
|
| 46 |
+
content=markdown_content,
|
| 47 |
+
element_type="markdown"
|
| 48 |
+
))
|
| 49 |
+
document.add_section(section)
|
| 50 |
+
|
| 51 |
+
# Store raw markdown in metadata
|
| 52 |
+
document.metadata["markdown"] = markdown_content
|
| 53 |
+
|
| 54 |
+
# Extract any messages/warnings
|
| 55 |
+
if result.messages:
|
| 56 |
+
document.metadata["conversion_messages"] = [msg.message for msg in result.messages]
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"Error extracting content from DOCX: {e}")
|
| 60 |
+
# Create error section
|
| 61 |
+
error_section = DocumentSection(title="Error")
|
| 62 |
+
error_section.add_element(DocumentElement(
|
| 63 |
+
content=f"Failed to process document: {str(e)}",
|
| 64 |
+
element_type="paragraph"
|
| 65 |
+
))
|
| 66 |
+
document.add_section(error_section)
|
| 67 |
+
|
| 68 |
+
return document
|
| 69 |
+
|
| 70 |
+
def _handle_tables_directly(self, doc, content):
|
| 71 |
+
"""
|
| 72 |
+
Extract tables directly from python-docx and insert them as proper markdown tables
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
doc: python-docx Document object
|
| 76 |
+
content: Markdown content from mammoth
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
str: Markdown content with properly formatted tables
|
| 80 |
+
"""
|
| 81 |
+
if not doc.tables:
|
| 82 |
+
return content
|
| 83 |
+
|
| 84 |
+
# Split content into paragraphs
|
| 85 |
+
paragraphs = content.split('\n\n')
|
| 86 |
+
result_paragraphs = []
|
| 87 |
+
table_markers = []
|
| 88 |
+
|
| 89 |
+
# Find potential table markers
|
| 90 |
+
for i, para in enumerate(paragraphs):
|
| 91 |
+
if 'table' in para.lower():
|
| 92 |
+
table_markers.append(i)
|
| 93 |
+
|
| 94 |
+
# If no explicit markers, look for potential places by identifying short consecutive lines
|
| 95 |
+
if not table_markers:
|
| 96 |
+
for i in range(len(paragraphs) - 1):
|
| 97 |
+
lines = paragraphs[i].split('\n')
|
| 98 |
+
# Look for potential table headers (short lines)
|
| 99 |
+
if all(len(line) < 30 for line in lines) and len(lines) > 1:
|
| 100 |
+
table_markers.append(i)
|
| 101 |
+
|
| 102 |
+
# Process each paragraph, inserting tables at appropriate places
|
| 103 |
+
i = 0
|
| 104 |
+
tables_inserted = 0
|
| 105 |
+
|
| 106 |
+
while i < len(paragraphs):
|
| 107 |
+
if i in table_markers and tables_inserted < len(doc.tables):
|
| 108 |
+
# Add the current paragraph (table caption or header)
|
| 109 |
+
result_paragraphs.append(paragraphs[i])
|
| 110 |
+
|
| 111 |
+
# Convert the table to markdown and add it
|
| 112 |
+
table_md = self._table_to_markdown(doc.tables[tables_inserted])
|
| 113 |
+
result_paragraphs.append(table_md)
|
| 114 |
+
|
| 115 |
+
# Skip any paragraphs that might be part of the table in the original content
|
| 116 |
+
next_text = False
|
| 117 |
+
j = i + 1
|
| 118 |
+
while j < len(paragraphs) and not next_text:
|
| 119 |
+
# Check if paragraph j looks like normal text
|
| 120 |
+
if len(paragraphs[j].split('\n')) <= 1 and len(paragraphs[j]) > 30:
|
| 121 |
+
next_text = True
|
| 122 |
+
j += 1
|
| 123 |
+
|
| 124 |
+
if next_text:
|
| 125 |
+
i = j - 1 # Go back to the text paragraph
|
| 126 |
+
else:
|
| 127 |
+
i = j
|
| 128 |
+
|
| 129 |
+
tables_inserted += 1
|
| 130 |
+
else:
|
| 131 |
+
# Regular paragraph, just add it
|
| 132 |
+
result_paragraphs.append(paragraphs[i])
|
| 133 |
+
i += 1
|
| 134 |
+
|
| 135 |
+
# If we still have tables left, add them at the end
|
| 136 |
+
for j in range(tables_inserted, len(doc.tables)):
|
| 137 |
+
table_md = self._table_to_markdown(doc.tables[j])
|
| 138 |
+
result_paragraphs.append("Additional table found:")
|
| 139 |
+
result_paragraphs.append(table_md)
|
| 140 |
+
|
| 141 |
+
return '\n\n'.join(result_paragraphs)
|
| 142 |
+
|
| 143 |
+
def _table_to_markdown(self, table):
|
| 144 |
+
"""
|
| 145 |
+
Convert python-docx table to markdown table format
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
table: python-docx Table object
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
str: Markdown table
|
| 152 |
+
"""
|
| 153 |
+
if not table.rows:
|
| 154 |
+
return ""
|
| 155 |
+
|
| 156 |
+
markdown_rows = []
|
| 157 |
+
|
| 158 |
+
# Get cell text for each row (preserve empty cells)
|
| 159 |
+
all_rows = []
|
| 160 |
+
for row in table.rows:
|
| 161 |
+
row_cells = []
|
| 162 |
+
for cell in row.cells:
|
| 163 |
+
# Remove newlines and extra spaces from cell text
|
| 164 |
+
cell_text = cell.text.strip().replace('\n', ' ')
|
| 165 |
+
cell_text = re.sub(r'\s+', ' ', cell_text)
|
| 166 |
+
row_cells.append(cell_text if cell_text else " ")
|
| 167 |
+
all_rows.append(row_cells)
|
| 168 |
+
|
| 169 |
+
# Determine the number of columns based on the row with the most cells
|
| 170 |
+
num_cols = max(len(row) for row in all_rows)
|
| 171 |
+
|
| 172 |
+
# Ensure all rows have the same number of columns
|
| 173 |
+
for row in all_rows:
|
| 174 |
+
while len(row) < num_cols:
|
| 175 |
+
row.append(" ")
|
| 176 |
+
|
| 177 |
+
# Generate header row
|
| 178 |
+
header_row = "| " + " | ".join(all_rows[0]) + " |"
|
| 179 |
+
markdown_rows.append(header_row)
|
| 180 |
+
|
| 181 |
+
# Generate separator row
|
| 182 |
+
separator_row = "| " + " | ".join(["---"] * num_cols) + " |"
|
| 183 |
+
markdown_rows.append(separator_row)
|
| 184 |
+
|
| 185 |
+
# Generate data rows
|
| 186 |
+
for row in all_rows[1:]:
|
| 187 |
+
data_row = "| " + " | ".join(row) + " |"
|
| 188 |
+
markdown_rows.append(data_row)
|
| 189 |
+
|
| 190 |
+
return "\n".join(markdown_rows)
|
| 191 |
+
|
| 192 |
+
def _process_tables(self, file_path: str, content: str, tables_data) -> str:
|
| 193 |
+
"""
|
| 194 |
+
Process tables extracted by docx2python and replace mammoth's table output
|
| 195 |
+
with better markdown tables
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
file_path: Path to the DOCX file
|
| 199 |
+
content: Markdown content from mammoth
|
| 200 |
+
tables_data: Tables extracted by docx2python
|
| 201 |
+
|
| 202 |
+
Returns:
|
| 203 |
+
str: Markdown content with improved tables
|
| 204 |
+
"""
|
| 205 |
+
if not tables_data:
|
| 206 |
+
# Direct table detection in the content if docx2python didn't find tables
|
| 207 |
+
return self._detect_and_format_tables_from_content(content, file_path)
|
| 208 |
+
|
| 209 |
+
try:
|
| 210 |
+
# Try to use python-docx to get additional table information
|
| 211 |
+
doc = docx.Document(file_path)
|
| 212 |
+
|
| 213 |
+
# Find table markers in mammoth output
|
| 214 |
+
table_pattern = r'\|[^\n]+\|\n\|[^\n]+\|'
|
| 215 |
+
table_matches = list(re.finditer(table_pattern, content))
|
| 216 |
+
|
| 217 |
+
# If no tables found with the standard pattern, try a broader pattern
|
| 218 |
+
if not table_matches:
|
| 219 |
+
# This broader pattern looks for possible table content by finding consecutive lines
|
| 220 |
+
# with keyword patterns that might indicate a flattened table
|
| 221 |
+
broader_pattern = r'(?:[\w\s]+\n){2,}(?:[\w\s]+)'
|
| 222 |
+
table_matches = list(re.finditer(broader_pattern, content))
|
| 223 |
+
|
| 224 |
+
# If we still don't find tables with regex patterns, use doc.tables directly
|
| 225 |
+
if (not table_matches or not doc.tables) and doc.tables:
|
| 226 |
+
return self._reconstruct_tables_from_python_docx(content, doc.tables)
|
| 227 |
+
|
| 228 |
+
# Start with the original content
|
| 229 |
+
result = content
|
| 230 |
+
|
| 231 |
+
# If tables found in docx but not in mammoth output, reconstruct them completely
|
| 232 |
+
if doc.tables and not table_matches:
|
| 233 |
+
return self._reconstruct_tables_from_python_docx(content, doc.tables)
|
| 234 |
+
|
| 235 |
+
# Standard processing when we have matching tables
|
| 236 |
+
offset = 0
|
| 237 |
+
|
| 238 |
+
# Process each table
|
| 239 |
+
for i, table_data in enumerate(tables_data):
|
| 240 |
+
if i >= len(doc.tables):
|
| 241 |
+
break
|
| 242 |
+
|
| 243 |
+
# Get table from python-docx
|
| 244 |
+
table = doc.tables[i]
|
| 245 |
+
|
| 246 |
+
# Find the table in the content
|
| 247 |
+
if i < len(table_matches):
|
| 248 |
+
table_match = table_matches[i]
|
| 249 |
+
start_pos = table_match.start() + offset
|
| 250 |
+
end_pos = table_match.end() + offset
|
| 251 |
+
else:
|
| 252 |
+
# If we don't have an exact match, place the table at the end
|
| 253 |
+
start_pos = len(result)
|
| 254 |
+
end_pos = start_pos
|
| 255 |
+
|
| 256 |
+
# Convert table to pandas DataFrame
|
| 257 |
+
df = self._table_to_dataframe(table_data)
|
| 258 |
+
|
| 259 |
+
# Convert DataFrame to markdown table
|
| 260 |
+
markdown_table = self._df_to_markdown(df)
|
| 261 |
+
|
| 262 |
+
# Replace table in content
|
| 263 |
+
result = result[:start_pos] + markdown_table + result[end_pos:]
|
| 264 |
+
|
| 265 |
+
# Update offset for next replacement
|
| 266 |
+
offset += len(markdown_table) - (end_pos - start_pos)
|
| 267 |
+
|
| 268 |
+
return result
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
print(f"Error processing tables: {e}")
|
| 272 |
+
# Fallback to direct content processing
|
| 273 |
+
return self._detect_and_format_tables_from_content(content, file_path)
|
| 274 |
+
|
| 275 |
+
def _detect_and_format_tables_from_content(self, content: str, file_path: str) -> str:
|
| 276 |
+
"""
|
| 277 |
+
Detect and format tables directly from the content when docx2python fails
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
content: Markdown content
|
| 281 |
+
file_path: Path to the DOCX file
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
str: Content with improved tables
|
| 285 |
+
"""
|
| 286 |
+
try:
|
| 287 |
+
# Try to open with python-docx directly
|
| 288 |
+
doc = docx.Document(file_path)
|
| 289 |
+
if not doc.tables:
|
| 290 |
+
return content
|
| 291 |
+
|
| 292 |
+
return self._reconstruct_tables_from_python_docx(content, doc.tables)
|
| 293 |
+
except:
|
| 294 |
+
# Another fallback approach - try to detect tables based on text patterns
|
| 295 |
+
# Look for lines that might be table headers (consecutive short lines followed by a series of values)
|
| 296 |
+
lines = content.split('\n')
|
| 297 |
+
result = []
|
| 298 |
+
i = 0
|
| 299 |
+
while i < len(lines):
|
| 300 |
+
line = lines[i]
|
| 301 |
+
# Check if this line might be the start of a table (contains "table" keyword)
|
| 302 |
+
if "table" in line.lower() and i + 2 < len(lines):
|
| 303 |
+
potential_headers = []
|
| 304 |
+
j = i + 1
|
| 305 |
+
# Collect potential header lines (short lines with no punctuation)
|
| 306 |
+
while j < len(lines) and len(lines[j].strip()) < 30 and not re.search(r'[.,:;]', lines[j]) and lines[j].strip():
|
| 307 |
+
potential_headers.append(lines[j].strip())
|
| 308 |
+
j += 1
|
| 309 |
+
|
| 310 |
+
# If we found potential headers, format as a table
|
| 311 |
+
if len(potential_headers) >= 2:
|
| 312 |
+
result.append(line) # Add the table description line
|
| 313 |
+
result.append("") # Empty line before table
|
| 314 |
+
|
| 315 |
+
# Create markdown table headers
|
| 316 |
+
result.append("| " + " | ".join(potential_headers) + " |")
|
| 317 |
+
result.append("| " + " | ".join(["---"] * len(potential_headers)) + " |")
|
| 318 |
+
|
| 319 |
+
# Try to detect table rows
|
| 320 |
+
k = j
|
| 321 |
+
row_data = []
|
| 322 |
+
while k < len(lines) and len(row_data) < 20: # Limit to 20 rows to avoid false positives
|
| 323 |
+
if not lines[k].strip():
|
| 324 |
+
k += 1
|
| 325 |
+
continue
|
| 326 |
+
|
| 327 |
+
if len(lines[k].strip()) < 30:
|
| 328 |
+
row_data.append(lines[k].strip())
|
| 329 |
+
else:
|
| 330 |
+
break # Stop if we hit a long line (paragraph)
|
| 331 |
+
k += 1
|
| 332 |
+
|
| 333 |
+
# Create rows based on headers (split data into rows)
|
| 334 |
+
rows = []
|
| 335 |
+
row = []
|
| 336 |
+
for item in row_data:
|
| 337 |
+
row.append(item)
|
| 338 |
+
if len(row) == len(potential_headers):
|
| 339 |
+
rows.append("| " + " | ".join(row) + " |")
|
| 340 |
+
row = []
|
| 341 |
+
|
| 342 |
+
# If we have a partial row at the end, add it with empty cells
|
| 343 |
+
if row:
|
| 344 |
+
while len(row) < len(potential_headers):
|
| 345 |
+
row.append("")
|
| 346 |
+
rows.append("| " + " | ".join(row) + " |")
|
| 347 |
+
|
| 348 |
+
# Add all rows to result
|
| 349 |
+
result.extend(rows)
|
| 350 |
+
result.append("") # Empty line after table
|
| 351 |
+
|
| 352 |
+
i = k # Skip processed lines
|
| 353 |
+
else:
|
| 354 |
+
result.append(line)
|
| 355 |
+
i += 1
|
| 356 |
+
else:
|
| 357 |
+
result.append(line)
|
| 358 |
+
i += 1
|
| 359 |
+
|
| 360 |
+
return "\n".join(result)
|
| 361 |
+
|
| 362 |
+
def _reconstruct_tables_from_python_docx(self, content: str, tables) -> str:
|
| 363 |
+
"""
|
| 364 |
+
Completely reconstruct tables using python-docx
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
content: Original markdown content
|
| 368 |
+
tables: Tables from python-docx
|
| 369 |
+
|
| 370 |
+
Returns:
|
| 371 |
+
str: Content with properly formatted tables
|
| 372 |
+
"""
|
| 373 |
+
# First, find places in the content where tables might go
|
| 374 |
+
# Look for lines mentioning "table" for table captions/descriptions
|
| 375 |
+
table_markers = []
|
| 376 |
+
lines = content.split('\n')
|
| 377 |
+
|
| 378 |
+
for i, line in enumerate(lines):
|
| 379 |
+
if re.search(r'table|Table', line):
|
| 380 |
+
table_markers.append(i)
|
| 381 |
+
|
| 382 |
+
# If we found markers for all tables, use them to insert tables
|
| 383 |
+
if table_markers and len(table_markers) >= len(tables):
|
| 384 |
+
for i, table in enumerate(tables):
|
| 385 |
+
if i >= len(table_markers):
|
| 386 |
+
break
|
| 387 |
+
|
| 388 |
+
marker_pos = table_markers[i]
|
| 389 |
+
|
| 390 |
+
# Create markdown table from this table
|
| 391 |
+
markdown_table = self._convert_python_docx_table(table)
|
| 392 |
+
|
| 393 |
+
# Insert after the marker line
|
| 394 |
+
lines.insert(marker_pos + 1, "")
|
| 395 |
+
lines.insert(marker_pos + 2, markdown_table)
|
| 396 |
+
lines.insert(marker_pos + 3, "")
|
| 397 |
+
|
| 398 |
+
# Update remaining marker positions
|
| 399 |
+
for j in range(i + 1, len(table_markers)):
|
| 400 |
+
table_markers[j] += 3
|
| 401 |
+
|
| 402 |
+
return "\n".join(lines)
|
| 403 |
+
else:
|
| 404 |
+
# If we can't find markers for all tables, append tables at the end
|
| 405 |
+
result = content
|
| 406 |
+
|
| 407 |
+
for table in tables:
|
| 408 |
+
markdown_table = self._convert_python_docx_table(table)
|
| 409 |
+
result += "\n\n" + markdown_table + "\n"
|
| 410 |
+
|
| 411 |
+
return result
|
| 412 |
+
|
| 413 |
+
def _convert_python_docx_table(self, table) -> str:
|
| 414 |
+
"""
|
| 415 |
+
Convert a python-docx table to markdown
|
| 416 |
+
|
| 417 |
+
Args:
|
| 418 |
+
table: python-docx table object
|
| 419 |
+
|
| 420 |
+
Returns:
|
| 421 |
+
str: Markdown table
|
| 422 |
+
"""
|
| 423 |
+
# Extract the table data
|
| 424 |
+
table_data = []
|
| 425 |
+
|
| 426 |
+
for row in table.rows:
|
| 427 |
+
row_data = []
|
| 428 |
+
for cell in row.cells:
|
| 429 |
+
# Get text and clean it
|
| 430 |
+
text = cell.text.strip().replace('\n', ' ')
|
| 431 |
+
row_data.append(text)
|
| 432 |
+
table_data.append(row_data)
|
| 433 |
+
|
| 434 |
+
# If no data, return empty string
|
| 435 |
+
if not table_data:
|
| 436 |
+
return ""
|
| 437 |
+
|
| 438 |
+
# Get the max columns
|
| 439 |
+
max_cols = max(len(row) for row in table_data)
|
| 440 |
+
|
| 441 |
+
# Ensure all rows have the same number of columns
|
| 442 |
+
for row in table_data:
|
| 443 |
+
while len(row) < max_cols:
|
| 444 |
+
row.append("")
|
| 445 |
+
|
| 446 |
+
# Create header row
|
| 447 |
+
header = "| " + " | ".join(table_data[0]) + " |"
|
| 448 |
+
|
| 449 |
+
# Create separator row
|
| 450 |
+
separator = "| " + " | ".join(["---"] * max_cols) + " |"
|
| 451 |
+
|
| 452 |
+
# Create data rows
|
| 453 |
+
rows = []
|
| 454 |
+
for row in table_data[1:]:
|
| 455 |
+
rows.append("| " + " | ".join(row) + " |")
|
| 456 |
+
|
| 457 |
+
# Combine all parts
|
| 458 |
+
return header + "\n" + separator + "\n" + "\n".join(rows)
|
| 459 |
+
|
| 460 |
+
def _df_to_markdown(self, df: pd.DataFrame) -> str:
|
| 461 |
+
"""Convert pandas DataFrame to markdown table"""
|
| 462 |
+
if df.empty:
|
| 463 |
+
return ''
|
| 464 |
+
|
| 465 |
+
# Use pandas built-in to_markdown when possible
|
| 466 |
+
try:
|
| 467 |
+
return df.to_markdown(index=False)
|
| 468 |
+
except Exception as e:
|
| 469 |
+
# Fallback to manual conversion
|
| 470 |
+
return self._complex_df_to_markdown(df)
|
processors/text/excel_processor.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
class ExcelProcessor(BaseDocumentProcessor):
|
| 7 |
+
"""Processor for Excel files (xlsx, xls, xlsm)"""
|
| 8 |
+
|
| 9 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 10 |
+
"""
|
| 11 |
+
Process Excel file and convert to markdown
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
file_path: Path to Excel file
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
StructuredDocument: The processed document
|
| 18 |
+
"""
|
| 19 |
+
document = StructuredDocument(
|
| 20 |
+
title=Path(file_path).stem,
|
| 21 |
+
source_file=file_path,
|
| 22 |
+
doc_type=DocumentType.EXCEL
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
# Get all sheet names
|
| 27 |
+
xl = pd.ExcelFile(file_path)
|
| 28 |
+
sheet_names = xl.sheet_names
|
| 29 |
+
|
| 30 |
+
# Process all sheets and create complete markdown
|
| 31 |
+
all_markdown = []
|
| 32 |
+
|
| 33 |
+
# Add document title - make it more descriptive
|
| 34 |
+
# Clean up the filename to remove temporary path prefixes
|
| 35 |
+
filename = Path(file_path).stem
|
| 36 |
+
# Remove temporary filename prefixes like med_temp_XXX_
|
| 37 |
+
if 'med_temp_' in filename:
|
| 38 |
+
clean_filename = filename.split('_', 3)[-1] if len(filename.split('_')) > 3 else filename
|
| 39 |
+
else:
|
| 40 |
+
clean_filename = filename
|
| 41 |
+
|
| 42 |
+
all_markdown.append(f"# Excel Document: {clean_filename}\n\n")
|
| 43 |
+
|
| 44 |
+
# Track sheet data for document metadata
|
| 45 |
+
sheets_data = {}
|
| 46 |
+
|
| 47 |
+
# Process each sheet
|
| 48 |
+
for sheet_name in sheet_names:
|
| 49 |
+
# Read the sheet into a DataFrame
|
| 50 |
+
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
| 51 |
+
|
| 52 |
+
# Skip empty sheets
|
| 53 |
+
if df.empty:
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
# Create markdown for this sheet with better heading
|
| 57 |
+
sheet_markdown = f"## Sheet: {sheet_name}"
|
| 58 |
+
|
| 59 |
+
# Add row and column count for better context
|
| 60 |
+
row_count = len(df)
|
| 61 |
+
col_count = len(df.columns)
|
| 62 |
+
sheet_markdown += f" ({row_count} rows × {col_count} columns)\n\n"
|
| 63 |
+
|
| 64 |
+
# Convert DataFrame to markdown table
|
| 65 |
+
table_markdown = df.to_markdown(index=False)
|
| 66 |
+
if table_markdown:
|
| 67 |
+
sheet_markdown += table_markdown + "\n\n"
|
| 68 |
+
else:
|
| 69 |
+
# Fallback for complex tables
|
| 70 |
+
sheet_markdown += self._complex_df_to_markdown(df) + "\n\n"
|
| 71 |
+
|
| 72 |
+
# Add sheet data to metadata
|
| 73 |
+
sheets_data[sheet_name] = {
|
| 74 |
+
"rows": row_count,
|
| 75 |
+
"columns": col_count,
|
| 76 |
+
"column_names": df.columns.tolist()
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
# Create section for this sheet with improved title
|
| 80 |
+
section = DocumentSection(title=f"Sheet: {sheet_name} ({row_count} rows × {col_count} columns)", level=2)
|
| 81 |
+
section.metadata["sheet_name"] = sheet_name
|
| 82 |
+
section.metadata["rows"] = row_count
|
| 83 |
+
section.metadata["columns"] = col_count
|
| 84 |
+
|
| 85 |
+
# Add the table as a markdown element
|
| 86 |
+
section.add_element(DocumentElement(
|
| 87 |
+
content=table_markdown,
|
| 88 |
+
element_type="markdown"
|
| 89 |
+
))
|
| 90 |
+
|
| 91 |
+
# Add section to document
|
| 92 |
+
document.add_section(section)
|
| 93 |
+
|
| 94 |
+
# Add to combined markdown
|
| 95 |
+
all_markdown.append(sheet_markdown)
|
| 96 |
+
|
| 97 |
+
# Store the combined markdown in metadata
|
| 98 |
+
document.metadata["markdown"] = "\n".join(all_markdown)
|
| 99 |
+
document.metadata["sheets"] = sheets_data
|
| 100 |
+
document.metadata["sheet_count"] = len(sheet_names)
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f"Error processing Excel file: {str(e)}")
|
| 104 |
+
error_section = DocumentSection(title="Error")
|
| 105 |
+
error_section.add_element(DocumentElement(
|
| 106 |
+
content=f"Failed to process Excel file: {str(e)}",
|
| 107 |
+
element_type="paragraph"
|
| 108 |
+
))
|
| 109 |
+
document.add_section(error_section)
|
| 110 |
+
document.metadata["error"] = str(e)
|
| 111 |
+
|
| 112 |
+
return document
|
| 113 |
+
|
| 114 |
+
def _complex_df_to_markdown(self, df: pd.DataFrame) -> str:
|
| 115 |
+
"""
|
| 116 |
+
Convert complex DataFrames to markdown tables
|
| 117 |
+
Handles cases where the standard to_markdown might fail
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
df: DataFrame to convert
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
str: Markdown table representation
|
| 124 |
+
"""
|
| 125 |
+
# Create header row
|
| 126 |
+
header = "| " + " | ".join(str(col) for col in df.columns) + " |"
|
| 127 |
+
|
| 128 |
+
# Create separator row
|
| 129 |
+
separator = "| " + " | ".join(["---"] * len(df.columns)) + " |"
|
| 130 |
+
|
| 131 |
+
# Create data rows
|
| 132 |
+
rows = []
|
| 133 |
+
for _, row in df.iterrows():
|
| 134 |
+
row_str = "| " + " | ".join(str(val) if not pd.isna(val) else "" for val in row) + " |"
|
| 135 |
+
rows.append(row_str)
|
| 136 |
+
|
| 137 |
+
# Combine all parts
|
| 138 |
+
return header + "\n" + separator + "\n" + "\n".join(rows)
|
processors/text/ipynb_processor.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import json
|
| 4 |
+
import nbformat
|
| 5 |
+
|
| 6 |
+
class JupyterNotebookProcessor(BaseDocumentProcessor):
|
| 7 |
+
"""Processor for Jupyter Notebooks"""
|
| 8 |
+
|
| 9 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 10 |
+
"""Process Jupyter Notebook"""
|
| 11 |
+
document = StructuredDocument(
|
| 12 |
+
title=Path(file_path).stem,
|
| 13 |
+
source_file=file_path,
|
| 14 |
+
doc_type=DocumentType.JUPYTER
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
# Parse the notebook
|
| 19 |
+
notebook = nbformat.read(file_path, as_version=4)
|
| 20 |
+
|
| 21 |
+
# Create a section for each cell
|
| 22 |
+
markdown_parts = []
|
| 23 |
+
|
| 24 |
+
# Add notebook title if available
|
| 25 |
+
if 'title' in notebook.metadata:
|
| 26 |
+
markdown_parts.append(f"# {notebook.metadata['title']}\n\n")
|
| 27 |
+
|
| 28 |
+
# Process cells
|
| 29 |
+
for i, cell in enumerate(notebook.cells):
|
| 30 |
+
cell_type = cell.cell_type
|
| 31 |
+
|
| 32 |
+
# For markdown cells, add the content directly
|
| 33 |
+
if cell_type == 'markdown':
|
| 34 |
+
markdown_parts.append(cell.source + "\n\n")
|
| 35 |
+
|
| 36 |
+
# Also add to document structure
|
| 37 |
+
section = DocumentSection(title=f"Markdown Cell {i+1}")
|
| 38 |
+
section.add_element(DocumentElement(
|
| 39 |
+
content=cell.source,
|
| 40 |
+
element_type="markdown"
|
| 41 |
+
))
|
| 42 |
+
document.add_section(section)
|
| 43 |
+
|
| 44 |
+
# For code cells, format as code blocks with output
|
| 45 |
+
elif cell_type == 'code':
|
| 46 |
+
# Code
|
| 47 |
+
markdown_parts.append(f"```python\n{cell.source}\n```\n\n")
|
| 48 |
+
|
| 49 |
+
# Add code to document structure
|
| 50 |
+
section = DocumentSection(title=f"Code Cell {i+1}")
|
| 51 |
+
section.add_element(DocumentElement(
|
| 52 |
+
content=cell.source,
|
| 53 |
+
element_type="code",
|
| 54 |
+
metadata={"language": "python"}
|
| 55 |
+
))
|
| 56 |
+
|
| 57 |
+
# Process output if available
|
| 58 |
+
if hasattr(cell, 'outputs') and cell.outputs:
|
| 59 |
+
outputs = []
|
| 60 |
+
for output in cell.outputs:
|
| 61 |
+
if output.output_type == 'stream':
|
| 62 |
+
outputs.append(f"```\n{output.text}\n```\n\n")
|
| 63 |
+
elif output.output_type == 'execute_result':
|
| 64 |
+
if 'text/plain' in output.data:
|
| 65 |
+
outputs.append(f"```\n{output.data['text/plain']}\n```\n\n")
|
| 66 |
+
|
| 67 |
+
if outputs:
|
| 68 |
+
output_content = "".join(outputs)
|
| 69 |
+
markdown_parts.append(f"**Output:**\n\n{output_content}")
|
| 70 |
+
section.add_element(DocumentElement(
|
| 71 |
+
content=output_content,
|
| 72 |
+
element_type="code_output"
|
| 73 |
+
))
|
| 74 |
+
|
| 75 |
+
document.add_section(section)
|
| 76 |
+
|
| 77 |
+
# Store combined markdown in metadata
|
| 78 |
+
document.metadata["markdown"] = "".join(markdown_parts)
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"Error processing notebook: {e}")
|
| 82 |
+
# Create error section
|
| 83 |
+
error_section = DocumentSection(title="Error")
|
| 84 |
+
error_section.add_element(DocumentElement(
|
| 85 |
+
content=f"Failed to process notebook: {str(e)}",
|
| 86 |
+
element_type="paragraph"
|
| 87 |
+
))
|
| 88 |
+
document.add_section(error_section)
|
| 89 |
+
|
| 90 |
+
return document
|
processors/text/markdown_processor.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
class MarkdownProcessor(BaseDocumentProcessor):
|
| 6 |
+
"""Processor for Markdown and R Markdown files"""
|
| 7 |
+
|
| 8 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 9 |
+
"""Process markdown file"""
|
| 10 |
+
doc_type = DocumentType.from_file_extension(file_path)
|
| 11 |
+
|
| 12 |
+
document = StructuredDocument(
|
| 13 |
+
title=Path(file_path).stem,
|
| 14 |
+
source_file=file_path,
|
| 15 |
+
doc_type=doc_type
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
# Read markdown content
|
| 20 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 21 |
+
markdown_content = f.read()
|
| 22 |
+
|
| 23 |
+
# Extract sections based on headings
|
| 24 |
+
section_pattern = r'^(#{1,6})\s+(.+)$'
|
| 25 |
+
current_section = DocumentSection(title="Main Content")
|
| 26 |
+
current_content = []
|
| 27 |
+
last_position = 0
|
| 28 |
+
|
| 29 |
+
for match in re.finditer(section_pattern, markdown_content, re.MULTILINE):
|
| 30 |
+
# If we have content before this heading, add it to current section
|
| 31 |
+
if last_position < match.start():
|
| 32 |
+
content_before = markdown_content[last_position:match.start()].strip()
|
| 33 |
+
if content_before:
|
| 34 |
+
current_section.add_element(DocumentElement(
|
| 35 |
+
content=content_before,
|
| 36 |
+
element_type="markdown"
|
| 37 |
+
))
|
| 38 |
+
current_content.append(content_before)
|
| 39 |
+
|
| 40 |
+
# Add current section to document if it has content
|
| 41 |
+
if current_content:
|
| 42 |
+
document.add_section(current_section)
|
| 43 |
+
current_content = []
|
| 44 |
+
|
| 45 |
+
# Create new section for this heading
|
| 46 |
+
level = len(match.group(1)) # Number of # characters
|
| 47 |
+
title = match.group(2)
|
| 48 |
+
current_section = DocumentSection(title=title, level=level)
|
| 49 |
+
|
| 50 |
+
last_position = match.end()
|
| 51 |
+
|
| 52 |
+
# Add any remaining content to the last section
|
| 53 |
+
if last_position < len(markdown_content):
|
| 54 |
+
remaining_content = markdown_content[last_position:].strip()
|
| 55 |
+
if remaining_content:
|
| 56 |
+
current_section.add_element(DocumentElement(
|
| 57 |
+
content=remaining_content,
|
| 58 |
+
element_type="markdown"
|
| 59 |
+
))
|
| 60 |
+
current_content.append(remaining_content)
|
| 61 |
+
|
| 62 |
+
# Add the last section if it has content
|
| 63 |
+
if current_content:
|
| 64 |
+
document.add_section(current_section)
|
| 65 |
+
|
| 66 |
+
# For R Markdown, we could additionally parse and extract R code chunks
|
| 67 |
+
# but for now we'll just handle it as regular markdown
|
| 68 |
+
|
| 69 |
+
# Store the original markdown content
|
| 70 |
+
document.metadata["markdown"] = markdown_content
|
| 71 |
+
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"Error processing markdown file: {e}")
|
| 74 |
+
error_section = DocumentSection(title="Error")
|
| 75 |
+
error_section.add_element(DocumentElement(
|
| 76 |
+
content=f"Failed to process markdown file: {str(e)}",
|
| 77 |
+
element_type="paragraph"
|
| 78 |
+
))
|
| 79 |
+
document.add_section(error_section)
|
| 80 |
+
|
| 81 |
+
return document
|
processors/text/pdf_processor.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Dict, List, Optional, Union, Any
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 6 |
+
|
| 7 |
+
class PDFProcessor(BaseDocumentProcessor):
|
| 8 |
+
"""Processor for PDF documents"""
|
| 9 |
+
|
| 10 |
+
def __init__(self, use_vision_for_complex: bool = True):
|
| 11 |
+
"""
|
| 12 |
+
Initialize PDF processor
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
use_vision_for_complex: Whether to use vision processing for complex PDFs
|
| 16 |
+
"""
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.use_vision_for_complex = use_vision_for_complex
|
| 19 |
+
|
| 20 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 21 |
+
"""Process PDF document"""
|
| 22 |
+
# First try text extraction
|
| 23 |
+
document = self._extract_text(file_path)
|
| 24 |
+
|
| 25 |
+
# If text extraction didn't yield much content and vision processing is enabled,
|
| 26 |
+
# fall back to vision processing
|
| 27 |
+
content_size = sum(len(element.content) for section in document.sections for element in section.elements)
|
| 28 |
+
if content_size < 500 and self.use_vision_for_complex:
|
| 29 |
+
from processors.vision.vision_processor import VisionDocumentProcessor
|
| 30 |
+
try:
|
| 31 |
+
return VisionDocumentProcessor().process(file_path)
|
| 32 |
+
except Exception as e:
|
| 33 |
+
# If vision processing fails, return what we got from text extraction
|
| 34 |
+
print(f"Vision processing failed: {e}")
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
return document
|
| 38 |
+
|
| 39 |
+
def _extract_text(self, file_path: str) -> StructuredDocument:
|
| 40 |
+
"""Extract text from PDF using PyPDF2"""
|
| 41 |
+
from PyPDF2 import PdfReader
|
| 42 |
+
|
| 43 |
+
document = StructuredDocument(
|
| 44 |
+
title=Path(file_path).stem,
|
| 45 |
+
source_file=file_path,
|
| 46 |
+
doc_type=DocumentType.PDF
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
reader = PdfReader(file_path)
|
| 51 |
+
|
| 52 |
+
# Simple processing - create one section per page
|
| 53 |
+
for i, page in enumerate(reader.pages):
|
| 54 |
+
text = page.extract_text()
|
| 55 |
+
if not text.strip():
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
section = DocumentSection(title=f"Page {i+1}")
|
| 59 |
+
section.metadata["page"] = i+1
|
| 60 |
+
|
| 61 |
+
# Simple paragraph splitting
|
| 62 |
+
paragraphs = text.split("\n\n")
|
| 63 |
+
for para in paragraphs:
|
| 64 |
+
if not para.strip():
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Try to detect if paragraph is a heading
|
| 68 |
+
element_type = "paragraph"
|
| 69 |
+
metadata = {}
|
| 70 |
+
|
| 71 |
+
# Simple heuristic: if paragraph is short and ends with colon, it might be a heading
|
| 72 |
+
if len(para) < 100 and para.strip().endswith(":"):
|
| 73 |
+
element_type = "heading"
|
| 74 |
+
metadata["level"] = 2
|
| 75 |
+
|
| 76 |
+
section.add_element(DocumentElement(
|
| 77 |
+
content=para.strip(),
|
| 78 |
+
element_type=element_type,
|
| 79 |
+
metadata=metadata
|
| 80 |
+
))
|
| 81 |
+
|
| 82 |
+
document.add_section(section)
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"Error extracting text from PDF: {e}")
|
| 86 |
+
# Create error section
|
| 87 |
+
error_section = DocumentSection(title="Error")
|
| 88 |
+
error_section.add_element(DocumentElement(
|
| 89 |
+
content=f"Failed to extract text: {str(e)}",
|
| 90 |
+
element_type="paragraph"
|
| 91 |
+
))
|
| 92 |
+
document.add_section(error_section)
|
| 93 |
+
|
| 94 |
+
return document
|
processors/text/pptx_processor.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from pptx import Presentation
|
| 4 |
+
|
| 5 |
+
class PowerPointProcessor(BaseDocumentProcessor):
|
| 6 |
+
"""Processor for PowerPoint (PPTX) documents"""
|
| 7 |
+
|
| 8 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 9 |
+
"""Process PowerPoint presentation"""
|
| 10 |
+
document = StructuredDocument(
|
| 11 |
+
title=Path(file_path).stem,
|
| 12 |
+
source_file=file_path,
|
| 13 |
+
doc_type=DocumentType.POWERPOINT
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
# Open presentation
|
| 18 |
+
presentation = Presentation(file_path)
|
| 19 |
+
|
| 20 |
+
# Storage for markdown output
|
| 21 |
+
markdown_parts = []
|
| 22 |
+
markdown_parts.append(f"# {Path(file_path).stem}\n\n")
|
| 23 |
+
|
| 24 |
+
# Process each slide
|
| 25 |
+
for i, slide in enumerate(presentation.slides):
|
| 26 |
+
# Create section for each slide
|
| 27 |
+
slide_section = DocumentSection(title=f"Slide {i+1}", level=1)
|
| 28 |
+
slide_section.metadata["slide_number"] = i+1
|
| 29 |
+
|
| 30 |
+
# Add slide title to markdown
|
| 31 |
+
markdown_parts.append(f"## Slide {i+1}\n\n")
|
| 32 |
+
|
| 33 |
+
# Process slide title if available
|
| 34 |
+
if slide.shapes.title:
|
| 35 |
+
title_text = slide.shapes.title.text
|
| 36 |
+
markdown_parts.append(f"### {title_text}\n\n")
|
| 37 |
+
|
| 38 |
+
title_element = DocumentElement(
|
| 39 |
+
content=title_text,
|
| 40 |
+
element_type="heading",
|
| 41 |
+
metadata={"level": 3}
|
| 42 |
+
)
|
| 43 |
+
slide_section.add_element(title_element)
|
| 44 |
+
|
| 45 |
+
# Process text elements
|
| 46 |
+
for shape in slide.shapes:
|
| 47 |
+
if hasattr(shape, "text") and shape.text.strip() and shape != slide.shapes.title:
|
| 48 |
+
shape_text = shape.text.strip()
|
| 49 |
+
# Skip if it's same as title
|
| 50 |
+
if slide.shapes.title and shape_text == slide.shapes.title.text:
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
markdown_parts.append(f"{shape_text}\n\n")
|
| 54 |
+
|
| 55 |
+
# Add text as paragraph
|
| 56 |
+
text_element = DocumentElement(
|
| 57 |
+
content=shape_text,
|
| 58 |
+
element_type="paragraph"
|
| 59 |
+
)
|
| 60 |
+
slide_section.add_element(text_element)
|
| 61 |
+
|
| 62 |
+
# Add notes if available
|
| 63 |
+
if slide.has_notes_slide and slide.notes_slide.notes_text_frame.text.strip():
|
| 64 |
+
notes_text = slide.notes_slide.notes_text_frame.text.strip()
|
| 65 |
+
markdown_parts.append(f"**Notes:**\n\n{notes_text}\n\n")
|
| 66 |
+
|
| 67 |
+
notes_element = DocumentElement(
|
| 68 |
+
content=notes_text,
|
| 69 |
+
element_type="paragraph",
|
| 70 |
+
metadata={"is_notes": True}
|
| 71 |
+
)
|
| 72 |
+
slide_section.add_element(notes_element)
|
| 73 |
+
|
| 74 |
+
# Add divider between slides
|
| 75 |
+
markdown_parts.append("---\n\n")
|
| 76 |
+
|
| 77 |
+
# Add section to document
|
| 78 |
+
document.add_section(slide_section)
|
| 79 |
+
|
| 80 |
+
# Store combined markdown
|
| 81 |
+
document.metadata["markdown"] = "".join(markdown_parts)
|
| 82 |
+
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Error processing PowerPoint: {e}")
|
| 85 |
+
error_section = DocumentSection(title="Error")
|
| 86 |
+
error_section.add_element(DocumentElement(
|
| 87 |
+
content=f"Failed to process PowerPoint: {str(e)}",
|
| 88 |
+
element_type="paragraph"
|
| 89 |
+
))
|
| 90 |
+
document.add_section(error_section)
|
| 91 |
+
|
| 92 |
+
return document
|
processors/text/text_processor.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
class TextProcessor(BaseDocumentProcessor):
|
| 5 |
+
"""Processor for plain text files"""
|
| 6 |
+
|
| 7 |
+
def process(self, file_path: str) -> StructuredDocument:
|
| 8 |
+
"""Process text file"""
|
| 9 |
+
document = StructuredDocument(
|
| 10 |
+
title=Path(file_path).stem,
|
| 11 |
+
source_file=file_path,
|
| 12 |
+
doc_type=DocumentType.TEXT
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
# Read text content
|
| 17 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 18 |
+
text_content = f.read()
|
| 19 |
+
|
| 20 |
+
# Create document structure
|
| 21 |
+
main_section = DocumentSection(title="Text Content")
|
| 22 |
+
main_section.add_element(DocumentElement(
|
| 23 |
+
content=text_content,
|
| 24 |
+
element_type="paragraph"
|
| 25 |
+
))
|
| 26 |
+
document.add_section(main_section)
|
| 27 |
+
|
| 28 |
+
# Generate markdown representation
|
| 29 |
+
markdown = f"# {Path(file_path).name}\n\n{text_content}\n"
|
| 30 |
+
document.metadata["markdown"] = markdown
|
| 31 |
+
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"Error processing text file: {e}")
|
| 34 |
+
error_section = DocumentSection(title="Error")
|
| 35 |
+
error_section.add_element(DocumentElement(
|
| 36 |
+
content=f"Failed to process text file: {str(e)}",
|
| 37 |
+
element_type="paragraph"
|
| 38 |
+
))
|
| 39 |
+
document.add_section(error_section)
|
| 40 |
+
|
| 41 |
+
return document
|
processors/vision/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vision-based document processors using Qwen2.5-VL.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from processors.vision.vision_processor import VisionDocumentProcessor
|
| 6 |
+
|
| 7 |
+
__all__ = ['VisionDocumentProcessor']
|
processors/vision/vision_processor.py
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
from typing import Dict, List, Optional, Union, Any, Tuple
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from openai import OpenAI
|
| 9 |
+
|
| 10 |
+
from processors.base import BaseDocumentProcessor, StructuredDocument, DocumentSection, DocumentElement, DocumentType
|
| 11 |
+
|
| 12 |
+
class VisionDocumentProcessor(BaseDocumentProcessor):
|
| 13 |
+
"""Document processor using Qwen2.5-VL via OpenAI-compatible API"""
|
| 14 |
+
|
| 15 |
+
# Configurable remote model and API settings
|
| 16 |
+
MODEL_PATH = "Qwen/Qwen2.5-VL-32B-Instruct:featherless-ai"
|
| 17 |
+
BASE_URL = "https://router.huggingface.co/v1"
|
| 18 |
+
API_KEY = None
|
| 19 |
+
|
| 20 |
+
@classmethod
|
| 21 |
+
def configure_api(
|
| 22 |
+
cls,
|
| 23 |
+
api_key=None,
|
| 24 |
+
base_url=None,
|
| 25 |
+
model=None,
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
Configure class-level API settings for the remote vision model.
|
| 29 |
+
"""
|
| 30 |
+
if api_key:
|
| 31 |
+
cls.API_KEY = api_key
|
| 32 |
+
|
| 33 |
+
if base_url:
|
| 34 |
+
cls.BASE_URL = base_url
|
| 35 |
+
|
| 36 |
+
if model:
|
| 37 |
+
cls.MODEL_PATH = model
|
| 38 |
+
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
api_key: Optional[str] = None,
|
| 42 |
+
base_url: Optional[str] = None,
|
| 43 |
+
model: Optional[str] = None,
|
| 44 |
+
temperature: float = 0.0,
|
| 45 |
+
max_tokens: Optional[int] = None
|
| 46 |
+
):
|
| 47 |
+
"""
|
| 48 |
+
Initialize remote vision document processor with OpenAI SDK
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
api_key: API key for the remote service
|
| 52 |
+
base_url: Base URL for the OpenAI compatible endpoint
|
| 53 |
+
model: Remote model identifier
|
| 54 |
+
temperature: Temperature for generation (0.0-1.0).
|
| 55 |
+
max_tokens: Maximum tokens to generate.
|
| 56 |
+
"""
|
| 57 |
+
super().__init__()
|
| 58 |
+
|
| 59 |
+
self.temperature = float(temperature) if temperature is not None else 0.0
|
| 60 |
+
self.max_tokens = int(max_tokens) if max_tokens is not None else 4000
|
| 61 |
+
|
| 62 |
+
self.model = model or self.MODEL_PATH
|
| 63 |
+
api_key = (
|
| 64 |
+
api_key
|
| 65 |
+
or self.API_KEY
|
| 66 |
+
or os.getenv("HF_TOKEN")
|
| 67 |
+
)
|
| 68 |
+
self.client = OpenAI(
|
| 69 |
+
base_url=base_url or self.BASE_URL,
|
| 70 |
+
api_key=api_key,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def process(self, file_path: str, max_concurrent: int = 2, images_per_batch: int = 1,
|
| 74 |
+
dynamic_batching: bool = True, max_tokens_per_batch: int = 4000) -> StructuredDocument:
|
| 75 |
+
"""
|
| 76 |
+
Process document using remote vision model
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
file_path: Path to document file
|
| 80 |
+
max_concurrent: Maximum number of concurrent API calls for PDFs
|
| 81 |
+
images_per_batch: Number of consecutive pages to process in a single API call
|
| 82 |
+
(1 = traditional single page processing, 2+ = multi-image processing)
|
| 83 |
+
dynamic_batching: Whether to dynamically determine batch sizes based on image complexity
|
| 84 |
+
max_tokens_per_batch: Maximum tokens per batch when using dynamic batching
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
StructuredDocument: Processed document
|
| 88 |
+
"""
|
| 89 |
+
# Get document type
|
| 90 |
+
doc_type = DocumentType.from_file_extension(file_path)
|
| 91 |
+
|
| 92 |
+
# Create document with basic metadata
|
| 93 |
+
document = StructuredDocument(
|
| 94 |
+
title=Path(file_path).stem,
|
| 95 |
+
source_file=file_path,
|
| 96 |
+
doc_type=doc_type
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# If file is PDF, process each page separately
|
| 100 |
+
if (doc_type == DocumentType.PDF):
|
| 101 |
+
if images_per_batch > 1:
|
| 102 |
+
# Use multi-image processing if specified
|
| 103 |
+
self._process_pdf_multi(file_path, document, max_concurrent, images_per_batch,
|
| 104 |
+
dynamic_batching, max_tokens_per_batch)
|
| 105 |
+
else:
|
| 106 |
+
# Use original single-page processing
|
| 107 |
+
self._process_pdf(file_path, document, max_concurrent)
|
| 108 |
+
|
| 109 |
+
# Ensure page markers are removed from final document output
|
| 110 |
+
if "markdown" in document.metadata:
|
| 111 |
+
document.metadata["markdown"] = self._remove_page_markers(document.metadata["markdown"])
|
| 112 |
+
|
| 113 |
+
# Also update section content to remove page markers
|
| 114 |
+
for section in document.sections:
|
| 115 |
+
for element in section.elements:
|
| 116 |
+
if element.element_type == "markdown":
|
| 117 |
+
element.content = self._remove_page_markers(element.content)
|
| 118 |
+
|
| 119 |
+
else:
|
| 120 |
+
# For images and other document types
|
| 121 |
+
image_content = self._prepare_image(file_path)
|
| 122 |
+
|
| 123 |
+
# Get markdown response directly
|
| 124 |
+
markdown_response = self._call_api(image_content)
|
| 125 |
+
|
| 126 |
+
# Create a section for the entire document
|
| 127 |
+
section = DocumentSection(title=Path(file_path).stem, level=1)
|
| 128 |
+
section.add_element(DocumentElement(
|
| 129 |
+
content=markdown_response,
|
| 130 |
+
element_type="markdown"
|
| 131 |
+
))
|
| 132 |
+
document.add_section(section)
|
| 133 |
+
|
| 134 |
+
# Store raw markdown in metadata
|
| 135 |
+
document.metadata["markdown"] = markdown_response
|
| 136 |
+
|
| 137 |
+
return document
|
| 138 |
+
|
| 139 |
+
def _prepare_image(self, file_path: str) -> str:
|
| 140 |
+
"""Read image file and encode as base64"""
|
| 141 |
+
with open(file_path, "rb") as image_file:
|
| 142 |
+
return base64.b64encode(image_file.read()).decode("utf-8")
|
| 143 |
+
|
| 144 |
+
def _process_pdf(self, file_path: str, document: StructuredDocument, max_concurrent: int = 2) -> None:
|
| 145 |
+
"""
|
| 146 |
+
Process PDF document with parallel page processing
|
| 147 |
+
"""
|
| 148 |
+
from pdf2image import convert_from_path
|
| 149 |
+
import tempfile
|
| 150 |
+
import concurrent.futures
|
| 151 |
+
|
| 152 |
+
# Storage for combined markdown from all pages
|
| 153 |
+
all_markdown = [None] * 0 # Will resize based on page count
|
| 154 |
+
all_sections = [None] * 0 # Will resize based on page count
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
# Convert PDF to images
|
| 158 |
+
with tempfile.TemporaryDirectory() as path:
|
| 159 |
+
# Check if we can convert the PDF
|
| 160 |
+
try:
|
| 161 |
+
print("Converting PDF to images...")
|
| 162 |
+
images = convert_from_path(file_path)
|
| 163 |
+
page_count = len(images)
|
| 164 |
+
print(f"Converted {page_count} pages")
|
| 165 |
+
|
| 166 |
+
# Resize result arrays
|
| 167 |
+
all_markdown = [None] * page_count
|
| 168 |
+
all_sections = [None] * page_count
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
print(f"Error converting PDF to images: {str(e)}")
|
| 172 |
+
error_section = DocumentSection(title="Error")
|
| 173 |
+
error_section.add_element(DocumentElement(
|
| 174 |
+
content=f"Failed to convert PDF to images: {str(e)}",
|
| 175 |
+
element_type="paragraph"
|
| 176 |
+
))
|
| 177 |
+
document.add_section(error_section)
|
| 178 |
+
return
|
| 179 |
+
|
| 180 |
+
# Save all images first
|
| 181 |
+
image_paths = []
|
| 182 |
+
for i, image in enumerate(images):
|
| 183 |
+
temp_image_path = os.path.join(path, f"page_{i+1}.jpg")
|
| 184 |
+
image.save(temp_image_path, "JPEG")
|
| 185 |
+
image_paths.append((i, temp_image_path))
|
| 186 |
+
|
| 187 |
+
# Process pages in parallel
|
| 188 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
| 189 |
+
# Submit all tasks and store the futures
|
| 190 |
+
future_to_page = {
|
| 191 |
+
executor.submit(self._process_single_page, path, index, temp_path): (index, temp_path)
|
| 192 |
+
for index, temp_path in image_paths
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
# Process completed tasks as they finish
|
| 196 |
+
completed = 0
|
| 197 |
+
for future in concurrent.futures.as_completed(future_to_page):
|
| 198 |
+
index, temp_path = future_to_page[future]
|
| 199 |
+
try:
|
| 200 |
+
section, markdown = future.result()
|
| 201 |
+
all_sections[index] = section
|
| 202 |
+
all_markdown[index] = markdown
|
| 203 |
+
completed += 1
|
| 204 |
+
print(f"Completed page {index+1}/{page_count} ({completed}/{page_count} done)")
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f"Error processing page {index+1}: {str(e)}")
|
| 207 |
+
error_section = DocumentSection(title=f"Error on Page {index+1}")
|
| 208 |
+
error_section.add_element(DocumentElement(
|
| 209 |
+
content=f"Failed to process page: {str(e)}",
|
| 210 |
+
element_type="paragraph"
|
| 211 |
+
))
|
| 212 |
+
all_sections[index] = error_section
|
| 213 |
+
all_markdown[index] = f"## Page {index+1}\n\nError processing page: {str(e)}"
|
| 214 |
+
|
| 215 |
+
# Add all sections to document in correct order
|
| 216 |
+
for section in all_sections:
|
| 217 |
+
if section:
|
| 218 |
+
document.add_section(section)
|
| 219 |
+
|
| 220 |
+
# Combine all markdown
|
| 221 |
+
valid_markdown = [md for md in all_markdown if md]
|
| 222 |
+
if valid_markdown:
|
| 223 |
+
document.metadata["markdown"] = "\n\n".join(valid_markdown)
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f"Error in PDF processing: {str(e)}")
|
| 227 |
+
error_section = DocumentSection(title="Error")
|
| 228 |
+
error_section.add_element(DocumentElement(
|
| 229 |
+
content=f"Failed to process PDF: {str(e)}",
|
| 230 |
+
element_type="paragraph"
|
| 231 |
+
))
|
| 232 |
+
document.add_section(error_section)
|
| 233 |
+
|
| 234 |
+
def _process_pdf_multi(self, file_path: str, document: StructuredDocument, max_concurrent: int = 2, images_per_batch: int = 2,
|
| 235 |
+
dynamic_batching: bool = True, max_tokens_per_batch: int = 4000) -> None:
|
| 236 |
+
"""
|
| 237 |
+
Process PDF document with multi-image batches for improved context
|
| 238 |
+
"""
|
| 239 |
+
from pdf2image import convert_from_path
|
| 240 |
+
import tempfile
|
| 241 |
+
import concurrent.futures
|
| 242 |
+
|
| 243 |
+
# Storage for combined markdown from all pages
|
| 244 |
+
all_markdown = []
|
| 245 |
+
all_sections = []
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
# Convert PDF to images
|
| 249 |
+
with tempfile.TemporaryDirectory() as path:
|
| 250 |
+
# Check if we can convert the PDF
|
| 251 |
+
try:
|
| 252 |
+
print("Converting PDF to images...")
|
| 253 |
+
images = convert_from_path(file_path)
|
| 254 |
+
page_count = len(images)
|
| 255 |
+
print(f"Converted {page_count} pages")
|
| 256 |
+
|
| 257 |
+
# Initialize result arrays
|
| 258 |
+
all_markdown = [None] * page_count
|
| 259 |
+
all_sections = [None] * page_count
|
| 260 |
+
|
| 261 |
+
except Exception as e:
|
| 262 |
+
print(f"Error converting PDF to images: {str(e)}")
|
| 263 |
+
error_section = DocumentSection(title="Error")
|
| 264 |
+
error_section.add_element(DocumentElement(
|
| 265 |
+
content=f"Failed to convert PDF to images: {str(e)}",
|
| 266 |
+
element_type="paragraph"
|
| 267 |
+
))
|
| 268 |
+
document.add_section(error_section)
|
| 269 |
+
return
|
| 270 |
+
|
| 271 |
+
# Save all images first
|
| 272 |
+
image_paths = []
|
| 273 |
+
for i, image in enumerate(images):
|
| 274 |
+
temp_image_path = os.path.join(path, f"page_{i+1}.jpg")
|
| 275 |
+
image.save(temp_image_path, "JPEG")
|
| 276 |
+
image_paths.append((i, temp_image_path))
|
| 277 |
+
|
| 278 |
+
# Create batch groups
|
| 279 |
+
batch_tasks = []
|
| 280 |
+
|
| 281 |
+
for i in range(0, len(image_paths), images_per_batch):
|
| 282 |
+
batch = image_paths[i:i+images_per_batch]
|
| 283 |
+
if batch:
|
| 284 |
+
# For each batch, all indices and paths
|
| 285 |
+
indices = [idx for idx, _ in batch]
|
| 286 |
+
paths = [p for _, p in batch]
|
| 287 |
+
batch_tasks.append((indices, paths))
|
| 288 |
+
|
| 289 |
+
print(f"Processing PDF with {len(batch_tasks)} batches of up to {images_per_batch} pages each")
|
| 290 |
+
|
| 291 |
+
# Process batches in parallel
|
| 292 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
| 293 |
+
# Submit all batch tasks
|
| 294 |
+
future_to_batch = {
|
| 295 |
+
executor.submit(self._process_multi_page_batch, path, indices, paths): (indices, paths)
|
| 296 |
+
for indices, paths in batch_tasks
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
# Process completed batch tasks
|
| 300 |
+
completed_batches = 0
|
| 301 |
+
for future in concurrent.futures.as_completed(future_to_batch):
|
| 302 |
+
indices, paths = future_to_batch[future]
|
| 303 |
+
try:
|
| 304 |
+
results = future.result() # List of (section, markdown) tuples
|
| 305 |
+
|
| 306 |
+
# Store results in correct positions
|
| 307 |
+
for i, (idx, result) in enumerate(zip(indices, results)):
|
| 308 |
+
if result:
|
| 309 |
+
section, markdown = result
|
| 310 |
+
all_sections[idx] = section
|
| 311 |
+
all_markdown[idx] = markdown
|
| 312 |
+
|
| 313 |
+
completed_batches += 1
|
| 314 |
+
processed_pages = min(completed_batches * images_per_batch, page_count)
|
| 315 |
+
print(f"Completed batch {completed_batches}/{len(batch_tasks)} ({processed_pages}/{page_count} pages)")
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
print(f"Error processing batch {indices}: {str(e)}")
|
| 319 |
+
# Create error sections for failed pages
|
| 320 |
+
for idx in indices:
|
| 321 |
+
page_num = idx + 1
|
| 322 |
+
error_section = DocumentSection(title=f"Error on Page {page_num}")
|
| 323 |
+
error_section.add_element(DocumentElement(
|
| 324 |
+
content=f"Failed to process page batch: {str(e)}",
|
| 325 |
+
element_type="paragraph"
|
| 326 |
+
))
|
| 327 |
+
all_sections[idx] = error_section
|
| 328 |
+
all_markdown[idx] = f"## Page {page_num}\n\nError processing page: {str(e)}"
|
| 329 |
+
|
| 330 |
+
# Add all sections to document in correct order
|
| 331 |
+
for section in all_sections:
|
| 332 |
+
if section:
|
| 333 |
+
document.add_section(section)
|
| 334 |
+
|
| 335 |
+
# Combine all markdown
|
| 336 |
+
valid_markdown = [md for md in all_markdown if md]
|
| 337 |
+
if valid_markdown:
|
| 338 |
+
document.metadata["markdown"] = "\n\n".join(valid_markdown)
|
| 339 |
+
|
| 340 |
+
except Exception as e:
|
| 341 |
+
print(f"Error in PDF processing: {str(e)}")
|
| 342 |
+
error_section = DocumentSection(title="Error")
|
| 343 |
+
error_section.add_element(DocumentElement(
|
| 344 |
+
content=f"Failed to process PDF: {str(e)}",
|
| 345 |
+
element_type="paragraph"
|
| 346 |
+
))
|
| 347 |
+
document.add_section(error_section)
|
| 348 |
+
|
| 349 |
+
def _process_single_page(self, temp_dir: str, page_index: int, image_path: str) -> tuple:
|
| 350 |
+
"""
|
| 351 |
+
Process a single page in the PDF
|
| 352 |
+
"""
|
| 353 |
+
print(f"Started processing page {page_index+1}")
|
| 354 |
+
page_num = page_index + 1 # Convert to 1-based page numbers for display
|
| 355 |
+
|
| 356 |
+
# Process image
|
| 357 |
+
image_content = self._prepare_image(image_path)
|
| 358 |
+
markdown_response = self._call_api(image_content)
|
| 359 |
+
|
| 360 |
+
# Create section for this page
|
| 361 |
+
section = DocumentSection(title=f"Page {page_num}", level=1)
|
| 362 |
+
section.metadata["page"] = page_num
|
| 363 |
+
section.add_element(DocumentElement(
|
| 364 |
+
content=markdown_response,
|
| 365 |
+
element_type="markdown"
|
| 366 |
+
))
|
| 367 |
+
|
| 368 |
+
# Format markdown with page indicator
|
| 369 |
+
page_markdown = f"## Page {page_num}\n\n{markdown_response}"
|
| 370 |
+
|
| 371 |
+
return section, page_markdown
|
| 372 |
+
|
| 373 |
+
def _call_api(self, image_content: str) -> str:
|
| 374 |
+
"""
|
| 375 |
+
Run remote vision model inference using OpenAI compatible API
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
image_content: Base64 encoded image
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
str: Markdown representation of the document
|
| 382 |
+
"""
|
| 383 |
+
prompt_text = (
|
| 384 |
+
"Convert this document into clean Markdown.\n"
|
| 385 |
+
"Requirements:\n"
|
| 386 |
+
"- preserve headings\n"
|
| 387 |
+
"- preserve tables\n"
|
| 388 |
+
"- preserve lists\n"
|
| 389 |
+
"- preserve code blocks\n"
|
| 390 |
+
"- preserve formatting\n"
|
| 391 |
+
"- preserve hierarchy\n"
|
| 392 |
+
"- do not include page numbers\n"
|
| 393 |
+
"- output Markdown only\n"
|
| 394 |
+
"- do not explain your reasoning"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
print("Running remote model inference...")
|
| 398 |
+
try:
|
| 399 |
+
data_uri = f"data:image/jpeg;base64,{image_content}"
|
| 400 |
+
|
| 401 |
+
messages = [
|
| 402 |
+
{
|
| 403 |
+
"role": "user",
|
| 404 |
+
"content": [
|
| 405 |
+
{
|
| 406 |
+
"type": "text",
|
| 407 |
+
"text": prompt_text
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"type": "image_url",
|
| 411 |
+
"image_url": {
|
| 412 |
+
"url": data_uri
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
]
|
| 416 |
+
}
|
| 417 |
+
]
|
| 418 |
+
|
| 419 |
+
response = self.client.chat.completions.create(
|
| 420 |
+
model=self.model,
|
| 421 |
+
messages=messages,
|
| 422 |
+
max_tokens=self.max_tokens,
|
| 423 |
+
temperature=self.temperature,
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
markdown_response = response.choices[0].message.content
|
| 427 |
+
print("\nMarkdown response generated successfully")
|
| 428 |
+
return markdown_response
|
| 429 |
+
|
| 430 |
+
except Exception as e:
|
| 431 |
+
print(f"Error during remote inference: {str(e)}")
|
| 432 |
+
return f"# Model Error\n\nFailed to process document: {str(e)}"
|
| 433 |
+
|
| 434 |
+
def _call_api_multi_image(self, image_contents: list, page_numbers: list) -> str:
|
| 435 |
+
"""
|
| 436 |
+
Run remote vision model inference with multiple images
|
| 437 |
+
|
| 438 |
+
Args:
|
| 439 |
+
image_contents: List of base64 encoded images
|
| 440 |
+
page_numbers: List of page numbers for these images
|
| 441 |
+
|
| 442 |
+
Returns:
|
| 443 |
+
str: Markdown representation of the document across multiple pages
|
| 444 |
+
"""
|
| 445 |
+
print(f"Processing {len(image_contents)} images remotely")
|
| 446 |
+
|
| 447 |
+
try:
|
| 448 |
+
content = []
|
| 449 |
+
|
| 450 |
+
for img in image_contents:
|
| 451 |
+
content.append({
|
| 452 |
+
"type": "image_url",
|
| 453 |
+
"image_url": {
|
| 454 |
+
"url": f"data:image/jpeg;base64,{img}"
|
| 455 |
+
}
|
| 456 |
+
})
|
| 457 |
+
|
| 458 |
+
prompt_text = (
|
| 459 |
+
f"These are consecutive pages from a document (pages {', '.join(map(str, page_numbers))}). "
|
| 460 |
+
"Convert this document into clean Markdown.\n"
|
| 461 |
+
"Requirements:\n"
|
| 462 |
+
"- preserve headings\n"
|
| 463 |
+
"- preserve tables\n"
|
| 464 |
+
"- preserve lists\n"
|
| 465 |
+
"- preserve code blocks\n"
|
| 466 |
+
"- preserve formatting\n"
|
| 467 |
+
"- preserve hierarchy\n"
|
| 468 |
+
"- do not include page numbers\n"
|
| 469 |
+
"- output Markdown only\n"
|
| 470 |
+
"- do not explain your reasoning"
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
content.append({
|
| 474 |
+
"type": "text",
|
| 475 |
+
"text": prompt_text
|
| 476 |
+
})
|
| 477 |
+
|
| 478 |
+
response = self.client.chat.completions.create(
|
| 479 |
+
model=self.model,
|
| 480 |
+
messages=[
|
| 481 |
+
{
|
| 482 |
+
"role": "user",
|
| 483 |
+
"content": content
|
| 484 |
+
}
|
| 485 |
+
],
|
| 486 |
+
max_tokens=self.max_tokens,
|
| 487 |
+
temperature=self.temperature,
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
return response.choices[0].message.content
|
| 491 |
+
|
| 492 |
+
except Exception as e:
|
| 493 |
+
print(f"Multi-image inference failed ({str(e)}). Falling back to sequential processing.")
|
| 494 |
+
|
| 495 |
+
# Fallback to sequential execution if context window or multi-image processing fails
|
| 496 |
+
results = []
|
| 497 |
+
for img_content, page_num in zip(image_contents, page_numbers):
|
| 498 |
+
print(f"Running fallback inference for page {page_num}...")
|
| 499 |
+
page_markdown = self._call_api(img_content)
|
| 500 |
+
results.append(f"## Page {page_num}\n\n{page_markdown}")
|
| 501 |
+
|
| 502 |
+
return "\n\n".join(results)
|
| 503 |
+
|
| 504 |
+
def _process_multi_page_batch(self, temp_dir: str, page_indices: list, image_paths: list) -> list:
|
| 505 |
+
"""
|
| 506 |
+
Process multiple pages in a single API call
|
| 507 |
+
"""
|
| 508 |
+
page_numbers = [idx+1 for idx in page_indices] # Convert to 1-based page numbers
|
| 509 |
+
print(f"Processing batch with pages: {page_numbers}")
|
| 510 |
+
|
| 511 |
+
# Encode all images in the batch
|
| 512 |
+
image_contents = []
|
| 513 |
+
for path in image_paths:
|
| 514 |
+
image_contents.append(self._prepare_image(path))
|
| 515 |
+
|
| 516 |
+
# Call API with multiple images
|
| 517 |
+
markdown_response = self._call_api_multi_image(image_contents, page_numbers)
|
| 518 |
+
|
| 519 |
+
# Split response by page markers
|
| 520 |
+
results = []
|
| 521 |
+
|
| 522 |
+
try:
|
| 523 |
+
# Check if it's a multi-page response with page markers
|
| 524 |
+
if "## Page" in markdown_response:
|
| 525 |
+
# Attempt to split by page markers
|
| 526 |
+
parts = []
|
| 527 |
+
current_part = []
|
| 528 |
+
current_page_idx = None
|
| 529 |
+
|
| 530 |
+
for line in markdown_response.split("\n"):
|
| 531 |
+
if line.strip().startswith("## Page"):
|
| 532 |
+
# If we already have content for a page, save it
|
| 533 |
+
if current_part and current_page_idx is not None:
|
| 534 |
+
parts.append((current_page_idx, "\n".join(current_part)))
|
| 535 |
+
|
| 536 |
+
# Start a new page
|
| 537 |
+
current_part = [line]
|
| 538 |
+
|
| 539 |
+
# Extract page number from heading
|
| 540 |
+
try:
|
| 541 |
+
page_text = line.strip().replace("## Page", "").strip()
|
| 542 |
+
current_page_idx = int(page_text) - 1 # Convert to 0-based index
|
| 543 |
+
except:
|
| 544 |
+
# If we can't extract page number, use position in batch
|
| 545 |
+
current_page_idx = len(parts)
|
| 546 |
+
else:
|
| 547 |
+
current_part.append(line)
|
| 548 |
+
|
| 549 |
+
# Add the last part
|
| 550 |
+
if current_part and current_page_idx is not None:
|
| 551 |
+
parts.append((current_page_idx, "\n".join(current_part)))
|
| 552 |
+
|
| 553 |
+
# Create sections for each identified page
|
| 554 |
+
for page_idx, content in parts:
|
| 555 |
+
if 0 <= page_idx < len(page_indices):
|
| 556 |
+
idx = page_indices[page_idx]
|
| 557 |
+
page_num = idx + 1
|
| 558 |
+
|
| 559 |
+
section = DocumentSection(title=f"Page {page_num}", level=1)
|
| 560 |
+
section.metadata["page"] = page_num
|
| 561 |
+
section.add_element(DocumentElement(
|
| 562 |
+
content=content,
|
| 563 |
+
element_type="markdown"
|
| 564 |
+
))
|
| 565 |
+
|
| 566 |
+
# Include page heading if not already there
|
| 567 |
+
if not content.strip().startswith("## Page"):
|
| 568 |
+
page_markdown = f"## Page {page_num}\n\n{content}"
|
| 569 |
+
else:
|
| 570 |
+
page_markdown = content
|
| 571 |
+
|
| 572 |
+
results.append((section, page_markdown))
|
| 573 |
+
else:
|
| 574 |
+
# If no page markers are found, distribute content evenly
|
| 575 |
+
# This is a fallback and likely to be less accurate
|
| 576 |
+
for i, idx in enumerate(page_indices):
|
| 577 |
+
page_num = idx + 1
|
| 578 |
+
|
| 579 |
+
# Simple approach - split content by number of pages
|
| 580 |
+
chunk_size = max(1, len(markdown_response) // len(page_indices))
|
| 581 |
+
start_pos = i * chunk_size
|
| 582 |
+
end_pos = start_pos + chunk_size if i < len(page_indices)-1 else len(markdown_response)
|
| 583 |
+
|
| 584 |
+
content = markdown_response[start_pos:end_pos]
|
| 585 |
+
|
| 586 |
+
section = DocumentSection(title=f"Page {page_num}", level=1)
|
| 587 |
+
section.metadata["page"] = page_num
|
| 588 |
+
section.add_element(DocumentElement(
|
| 589 |
+
content=content,
|
| 590 |
+
element_type="markdown"
|
| 591 |
+
))
|
| 592 |
+
|
| 593 |
+
page_markdown = f"## Page {page_num}\n\n{content}"
|
| 594 |
+
results.append((section, page_markdown))
|
| 595 |
+
except Exception as e:
|
| 596 |
+
print(f"Error parsing multi-page response: {str(e)}")
|
| 597 |
+
# Fallback - create an error section for each page
|
| 598 |
+
for idx in page_indices:
|
| 599 |
+
page_num = idx + 1
|
| 600 |
+
error_content = f"Error parsing multi-page response: {str(e)}"
|
| 601 |
+
|
| 602 |
+
section = DocumentSection(title=f"Page {page_num}", level=1)
|
| 603 |
+
section.metadata["page"] = page_num
|
| 604 |
+
section.add_element(DocumentElement(
|
| 605 |
+
content=error_content,
|
| 606 |
+
element_type="paragraph"
|
| 607 |
+
))
|
| 608 |
+
|
| 609 |
+
page_markdown = f"## Page {page_num}\n\n{error_content}"
|
| 610 |
+
results.append((section, page_markdown))
|
| 611 |
+
|
| 612 |
+
# Ensure we have a result for each page in the batch
|
| 613 |
+
while len(results) < len(page_indices):
|
| 614 |
+
idx = page_indices[len(results)]
|
| 615 |
+
page_num = idx + 1
|
| 616 |
+
error_message = "No content was generated for this page in the batch."
|
| 617 |
+
|
| 618 |
+
section = DocumentSection(title=f"Page {page_num}", level=1)
|
| 619 |
+
section.metadata["page"] = page_num
|
| 620 |
+
section.add_element(DocumentElement(
|
| 621 |
+
content=error_message,
|
| 622 |
+
element_type="paragraph"
|
| 623 |
+
))
|
| 624 |
+
|
| 625 |
+
page_markdown = f"## Page {page_num}\n\n{error_message}"
|
| 626 |
+
results.append((section, page_markdown))
|
| 627 |
+
|
| 628 |
+
return results
|
| 629 |
+
|
| 630 |
+
def _remove_page_markers(self, content: str) -> str:
|
| 631 |
+
"""
|
| 632 |
+
Remove page marker headings from the markdown content
|
| 633 |
+
"""
|
| 634 |
+
if not content:
|
| 635 |
+
return content
|
| 636 |
+
|
| 637 |
+
# Split by lines and filter out page marker headings
|
| 638 |
+
lines = content.split('\n')
|
| 639 |
+
filtered_lines = []
|
| 640 |
+
skip_next_empty = False
|
| 641 |
+
|
| 642 |
+
for line in lines:
|
| 643 |
+
# Check if line is a page marker (## Page X)
|
| 644 |
+
if line.strip().startswith('## Page '):
|
| 645 |
+
skip_next_empty = True # Skip the next empty line if it exists
|
| 646 |
+
continue
|
| 647 |
+
|
| 648 |
+
# Skip empty line after page marker
|
| 649 |
+
if skip_next_empty and not line.strip():
|
| 650 |
+
skip_next_empty = False
|
| 651 |
+
continue
|
| 652 |
+
|
| 653 |
+
filtered_lines.append(line)
|
| 654 |
+
|
| 655 |
+
# Rejoin the filtered lines
|
| 656 |
+
cleaned_content = '\n'.join(filtered_lines)
|
| 657 |
+
|
| 658 |
+
return cleaned_content
|
requirements.txt
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
requests>=2.31.0
|
| 3 |
+
pillow>=10.0.0
|
| 4 |
+
beautifulsoup4>=4.12.0
|
| 5 |
+
pydantic>=2.5.0
|
| 6 |
+
tqdm>=4.66.0
|
| 7 |
+
openai>=1.30.0
|
| 8 |
+
|
| 9 |
+
# Document handling
|
| 10 |
+
PyPDF2>=3.0.0
|
| 11 |
+
pdf2image>=1.16.3
|
| 12 |
+
python-docx>=1.0.0
|
| 13 |
+
python-pptx>=0.6.21
|
| 14 |
+
nbformat>=5.9.0
|
| 15 |
+
markdown>=3.5.1
|
| 16 |
+
tree-sitter>=0.20.1
|
| 17 |
+
mammoth>=1.6.0
|
| 18 |
+
docx2python>=1.0.0
|
| 19 |
+
pandas>=2.0.0
|
| 20 |
+
openpyxl>=3.1.0
|
| 21 |
+
tabulate>=0.9.0
|
| 22 |
+
|
| 23 |
+
# UI Framework
|
| 24 |
+
gradio>=4.13.0
|
ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# UI package
|
ui/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (140 Bytes). View file
|
|
|
ui/__pycache__/gradio_app.cpython-312.pyc
ADDED
|
Binary file (2.57 kB). View file
|
|
|
ui/gradio_app.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
from processors.base import BaseDocumentProcessor, DocumentType
|
| 4 |
+
from processors.vision.vision_processor import VisionDocumentProcessor
|
| 5 |
+
|
| 6 |
+
MODEL_NAME = "Qwen/Qwen2.5-VL-32B-Instruct:featherless-ai"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def convert_document(
|
| 10 |
+
uploaded_file,
|
| 11 |
+
force_vision,
|
| 12 |
+
temperature,
|
| 13 |
+
):
|
| 14 |
+
if uploaded_file is None:
|
| 15 |
+
return "Please upload a document."
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
doc_type = DocumentType.from_file_extension(uploaded_file)
|
| 19 |
+
|
| 20 |
+
if force_vision or doc_type == DocumentType.IMAGE:
|
| 21 |
+
processor = VisionDocumentProcessor(
|
| 22 |
+
temperature=temperature,
|
| 23 |
+
)
|
| 24 |
+
else:
|
| 25 |
+
processor = BaseDocumentProcessor.get_processor(uploaded_file)
|
| 26 |
+
|
| 27 |
+
document = processor.process(uploaded_file)
|
| 28 |
+
|
| 29 |
+
return document.to_markdown()
|
| 30 |
+
|
| 31 |
+
except Exception as e:
|
| 32 |
+
return f"Error:\n\n{str(e)}"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def create_ui():
|
| 36 |
+
|
| 37 |
+
with gr.Blocks(title="VisionMark") as demo:
|
| 38 |
+
|
| 39 |
+
gr.Markdown("# VisionMark")
|
| 40 |
+
gr.Markdown(
|
| 41 |
+
"Convert PDFs and images into structured Markdown using a Vision Language Model."
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
file_input = gr.File(
|
| 45 |
+
label="Upload Document",
|
| 46 |
+
type="filepath",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
force_vision = gr.Checkbox(
|
| 50 |
+
value=False,
|
| 51 |
+
label="Force Vision Processing",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
gr.Markdown(f"**Model:** `{MODEL_NAME}`")
|
| 55 |
+
|
| 56 |
+
temperature = gr.Slider(
|
| 57 |
+
minimum=0.0,
|
| 58 |
+
maximum=1.0,
|
| 59 |
+
value=0.0,
|
| 60 |
+
step=0.1,
|
| 61 |
+
label="Temperature",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
convert_btn = gr.Button("Convert")
|
| 65 |
+
|
| 66 |
+
output = gr.Code(
|
| 67 |
+
label="Markdown Output",
|
| 68 |
+
language="markdown",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
convert_btn.click(
|
| 72 |
+
fn=convert_document,
|
| 73 |
+
inputs=[
|
| 74 |
+
file_input,
|
| 75 |
+
force_vision,
|
| 76 |
+
temperature,
|
| 77 |
+
],
|
| 78 |
+
outputs=output,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return demo
|