Upload folder using huggingface_hub
Browse files- warraqa/__init__.py +20 -0
- warraqa/__pycache__/__init__.cpython-313.pyc +0 -0
- warraqa/__pycache__/__init__.cpython-314.pyc +0 -0
- warraqa/__pycache__/agent.cpython-313.pyc +0 -0
- warraqa/__pycache__/agent.cpython-314.pyc +0 -0
- warraqa/__pycache__/cli.cpython-313.pyc +0 -0
- warraqa/__pycache__/models.cpython-313.pyc +0 -0
- warraqa/__pycache__/models.cpython-314.pyc +0 -0
- warraqa/__pycache__/utils.cpython-313.pyc +0 -0
- warraqa/__pycache__/utils.cpython-314.pyc +0 -0
- warraqa/agent.py +801 -0
- warraqa/cli.py +150 -0
- warraqa/engines/__init__.py +5 -0
- warraqa/engines/__pycache__/__init__.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/__init__.cpython-314.pyc +0 -0
- warraqa/engines/__pycache__/base.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/base.cpython-314.pyc +0 -0
- warraqa/engines/__pycache__/docx_markitdown.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/docx_markitdown.cpython-314.pyc +0 -0
- warraqa/engines/__pycache__/docx_pandoc.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/docx_pandoc.cpython-314.pyc +0 -0
- warraqa/engines/__pycache__/office_com.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/office_markitdown.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/pdf_marker.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/pdf_marker.cpython-314.pyc +0 -0
- warraqa/engines/__pycache__/pdf_pymupdf.cpython-313.pyc +0 -0
- warraqa/engines/__pycache__/pdf_pymupdf.cpython-314.pyc +0 -0
- warraqa/engines/base.py +141 -0
- warraqa/engines/docx_pandoc.py +181 -0
- warraqa/engines/office_com.py +194 -0
- warraqa/engines/office_markitdown.py +144 -0
- warraqa/engines/pdf_marker.py +179 -0
- warraqa/engines/pdf_pymupdf.py +123 -0
- warraqa/models.py +158 -0
- warraqa/scoring/__init__.py +5 -0
- warraqa/scoring/__pycache__/__init__.cpython-313.pyc +0 -0
- warraqa/scoring/__pycache__/__init__.cpython-314.pyc +0 -0
- warraqa/scoring/__pycache__/scorer.cpython-313.pyc +0 -0
- warraqa/scoring/__pycache__/scorer.cpython-314.pyc +0 -0
- warraqa/scoring/scorer.py +276 -0
- warraqa/utils.py +310 -0
warraqa/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warraqa (ورّاقة) — The Document Scribe Agent
|
| 3 |
+
|
| 4 |
+
Named after the professional scribes and paper-makers of the Islamic Golden Age,
|
| 5 |
+
Warraqa is a specialized agent whose sole mission is converting PDF and Word
|
| 6 |
+
documents into clean, accurate Markdown with surgical precision.
|
| 7 |
+
|
| 8 |
+
She reads. She transcribes. She scores her own work.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
__version__ = "1.0.0"
|
| 12 |
+
|
| 13 |
+
AGENT_NAME = "Warraqa"
|
| 14 |
+
AGENT_NAME_AR = "ورّاقة"
|
| 15 |
+
AGENT_DESCRIPTION = "Document-to-Markdown conversion specialist"
|
| 16 |
+
AGENT_IDENTITY = f"{AGENT_NAME} ({AGENT_NAME_AR}) v{__version__} — {AGENT_DESCRIPTION}"
|
| 17 |
+
|
| 18 |
+
from warraqa.agent import Warraqa # noqa: E402, F401
|
| 19 |
+
|
| 20 |
+
__all__ = ["Warraqa", "AGENT_NAME", "AGENT_IDENTITY", "__version__"]
|
warraqa/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (863 Bytes). View file
|
|
|
warraqa/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (855 Bytes). View file
|
|
|
warraqa/__pycache__/agent.cpython-313.pyc
ADDED
|
Binary file (33.5 kB). View file
|
|
|
warraqa/__pycache__/agent.cpython-314.pyc
ADDED
|
Binary file (32.2 kB). View file
|
|
|
warraqa/__pycache__/cli.cpython-313.pyc
ADDED
|
Binary file (5.95 kB). View file
|
|
|
warraqa/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (8.68 kB). View file
|
|
|
warraqa/__pycache__/models.cpython-314.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
warraqa/__pycache__/utils.cpython-313.pyc
ADDED
|
Binary file (11.4 kB). View file
|
|
|
warraqa/__pycache__/utils.cpython-314.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
warraqa/agent.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warraqa Agent — Core Orchestrator
|
| 3 |
+
|
| 4 |
+
The brain of the Warraqa agent. Handles file discovery, engine selection,
|
| 5 |
+
conversion with fallback chains, quality scoring, output saving, and
|
| 6 |
+
file movement. Designed for both CLI use and programmatic inter-agent calls.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import time
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from warraqa.engines.base import BaseEngine
|
| 18 |
+
from warraqa.engines.docx_pandoc import PandocEngine
|
| 19 |
+
from warraqa.engines.office_com import OfficeCOMConverter
|
| 20 |
+
from warraqa.engines.office_markitdown import MarkItDownEngine
|
| 21 |
+
from warraqa.engines.pdf_marker import MarkerPDFEngine
|
| 22 |
+
from warraqa.engines.pdf_pymupdf import PyMuPDFEngine
|
| 23 |
+
from warraqa.models import BatchReport, ConversionResult, FileInfo
|
| 24 |
+
from warraqa.scoring.scorer import ConversionScorer
|
| 25 |
+
from warraqa.utils import (
|
| 26 |
+
PDFAnalyzer,
|
| 27 |
+
detect_file_type,
|
| 28 |
+
ensure_directories,
|
| 29 |
+
find_supported_files,
|
| 30 |
+
format_file_size,
|
| 31 |
+
get_mime_type,
|
| 32 |
+
open_folder_picker,
|
| 33 |
+
safe_move,
|
| 34 |
+
sanitize_filename,
|
| 35 |
+
sanitize_markdown,
|
| 36 |
+
setup_logging,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger("warraqa")
|
| 40 |
+
|
| 41 |
+
# Default config
|
| 42 |
+
DEFAULT_CONFIG = {
|
| 43 |
+
"mode": "manual",
|
| 44 |
+
"watch": {"input_folder": None, "poll_interval_seconds": 10},
|
| 45 |
+
"output": {
|
| 46 |
+
"base_dir": "./output",
|
| 47 |
+
"md_files": "md_files",
|
| 48 |
+
"processed": "processed",
|
| 49 |
+
"failed": "failed",
|
| 50 |
+
"reports": "reports",
|
| 51 |
+
"scanned_pdfs": "scanned_pdfs",
|
| 52 |
+
},
|
| 53 |
+
"engines": {
|
| 54 |
+
"pdf_primary": "marker",
|
| 55 |
+
"pdf_fallback": "pymupdf",
|
| 56 |
+
"docx_primary": "markitdown",
|
| 57 |
+
"docx_fallback": "pandoc",
|
| 58 |
+
"pptx_primary": "markitdown",
|
| 59 |
+
"pptx_fallback": None,
|
| 60 |
+
},
|
| 61 |
+
"scoring": {"fail_threshold": 40, "warn_threshold": 70},
|
| 62 |
+
"logging": {"level": "INFO", "console": True, "file": None},
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class Warraqa:
|
| 67 |
+
"""
|
| 68 |
+
The Document Scribe Agent.
|
| 69 |
+
|
| 70 |
+
Converts PDF and Word documents to clean Markdown with quality scoring.
|
| 71 |
+
|
| 72 |
+
Usage:
|
| 73 |
+
agent = Warraqa()
|
| 74 |
+
result = agent.convert_file("path/to/document.pdf")
|
| 75 |
+
results = agent.process_folder("path/to/input/folder")
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
def __init__(self, config_path: str | Path | None = None, native_engine: str = "pymupdf"):
|
| 79 |
+
"""
|
| 80 |
+
Initialize Warraqa with configuration.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
config_path: Path to config.yaml. If None, uses defaults and
|
| 84 |
+
looks for config.yaml in the package's parent directory.
|
| 85 |
+
"""
|
| 86 |
+
self._config = self._load_config(config_path)
|
| 87 |
+
self._native_engine = native_engine
|
| 88 |
+
self._setup_logging()
|
| 89 |
+
self._output_base = Path(self._config["output"]["base_dir"]).resolve()
|
| 90 |
+
self._init_output_dirs()
|
| 91 |
+
self._engines = self._init_engines()
|
| 92 |
+
self._office_com = OfficeCOMConverter()
|
| 93 |
+
self._scorer = ConversionScorer(
|
| 94 |
+
fail_threshold=self._config["scoring"]["fail_threshold"],
|
| 95 |
+
warn_threshold=self._config["scoring"]["warn_threshold"],
|
| 96 |
+
)
|
| 97 |
+
if self._office_com.is_available():
|
| 98 |
+
logger.debug("Office COM pre-converter available (legacy .doc/.ppt support)")
|
| 99 |
+
else:
|
| 100 |
+
logger.warning(
|
| 101 |
+
"Office COM pre-converter unavailable — install pywin32 + MS Office "
|
| 102 |
+
"to support legacy .doc and .ppt files"
|
| 103 |
+
)
|
| 104 |
+
logger.info("Warraqa (ورّاقة) initialized — The Document Scribe is ready")
|
| 105 |
+
|
| 106 |
+
# --- Public API ---
|
| 107 |
+
|
| 108 |
+
def convert_file(
|
| 109 |
+
self,
|
| 110 |
+
file_path: str | Path,
|
| 111 |
+
*,
|
| 112 |
+
force_ocr: bool = False,
|
| 113 |
+
defer_pdf_fallback: bool = False,
|
| 114 |
+
) -> ConversionResult:
|
| 115 |
+
"""
|
| 116 |
+
Convert a single file to Markdown.
|
| 117 |
+
|
| 118 |
+
This is the primary API for other agents to call.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
file_path: Path to the document file (PDF, DOCX, DOC).
|
| 122 |
+
force_ocr: If True, bypass PDF native-text analysis and force the
|
| 123 |
+
OCR engine (Marker) as primary. Used by the Phase 2 deferred
|
| 124 |
+
OCR pass for files that were already classified scanned or
|
| 125 |
+
that failed PyMuPDF extraction in Phase 1.
|
| 126 |
+
defer_pdf_fallback: If True, when a native-PDF PyMuPDF run
|
| 127 |
+
produces a poor result, return immediately with
|
| 128 |
+
`defer_to_ocr=True` instead of falling back to Marker inline.
|
| 129 |
+
Used by Phase 1 of `process_folder` to keep the fast pass
|
| 130 |
+
fast — the caller is expected to stage the file for Phase 2.
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
ConversionResult with markdown content, score, and metadata.
|
| 134 |
+
"""
|
| 135 |
+
file_path = Path(file_path).resolve()
|
| 136 |
+
start_time = time.time()
|
| 137 |
+
|
| 138 |
+
# Build file info
|
| 139 |
+
file_info = self._build_file_info(file_path)
|
| 140 |
+
if file_info is None:
|
| 141 |
+
return ConversionResult(
|
| 142 |
+
source_file=FileInfo(
|
| 143 |
+
path=file_path, file_type="unknown",
|
| 144 |
+
size_bytes=0, mime_type="unknown",
|
| 145 |
+
),
|
| 146 |
+
error=f"Unsupported or undetectable file type: {file_path.suffix}",
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
logger.info(
|
| 150 |
+
f"Converting: [bold]{file_info.filename}[/bold] "
|
| 151 |
+
f"({file_info.file_type.upper()}, {format_file_size(file_info.size_bytes)})",
|
| 152 |
+
extra={"markup": True},
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Pre-convert legacy binary formats (.doc, .ppt) to modern .docx/.pptx
|
| 156 |
+
# via MS Office COM. The original FileInfo is kept so reports, moves,
|
| 157 |
+
# and the .md filename reflect the user's actual file.
|
| 158 |
+
original_file_info = file_info
|
| 159 |
+
working_info, temp_to_cleanup, preconvert_error = self._maybe_preconvert_legacy(file_info)
|
| 160 |
+
|
| 161 |
+
if preconvert_error is not None:
|
| 162 |
+
result = ConversionResult(
|
| 163 |
+
source_file=original_file_info,
|
| 164 |
+
error=preconvert_error,
|
| 165 |
+
engine_used="OfficeCOM",
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
try:
|
| 169 |
+
result = self._convert_with_fallback(
|
| 170 |
+
working_info,
|
| 171 |
+
force_ocr=force_ocr,
|
| 172 |
+
defer_pdf_fallback=defer_pdf_fallback,
|
| 173 |
+
)
|
| 174 |
+
finally:
|
| 175 |
+
if temp_to_cleanup is not None:
|
| 176 |
+
try:
|
| 177 |
+
temp_to_cleanup.unlink(missing_ok=True)
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.debug(f"Temp cleanup failed for {temp_to_cleanup}: {e}")
|
| 180 |
+
# Restore original FileInfo so output/reporting reflect the user's file
|
| 181 |
+
result.source_file = original_file_info
|
| 182 |
+
|
| 183 |
+
result.processing_time_seconds = time.time() - start_time
|
| 184 |
+
|
| 185 |
+
# Log result
|
| 186 |
+
grade_color = {"A": "green", "B": "blue", "C": "yellow", "D": "red", "F": "bold red"}
|
| 187 |
+
color = grade_color.get(result.grade, "white")
|
| 188 |
+
if result.success:
|
| 189 |
+
logger.info(
|
| 190 |
+
f"Result: [{color}]{result.grade}[/{color}] "
|
| 191 |
+
f"({result.confidence_score}/100) — "
|
| 192 |
+
f"{result.engine_used} engine — "
|
| 193 |
+
f"{result.processing_time_seconds:.1f}s",
|
| 194 |
+
extra={"markup": True},
|
| 195 |
+
)
|
| 196 |
+
else:
|
| 197 |
+
logger.error(
|
| 198 |
+
f"FAILED: [{color}]{result.grade}[/{color}] "
|
| 199 |
+
f"({result.confidence_score}/100) — {result.error}",
|
| 200 |
+
extra={"markup": True},
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
if result.warnings:
|
| 204 |
+
for w in result.warnings:
|
| 205 |
+
logger.warning(f" ⚠ {w}")
|
| 206 |
+
|
| 207 |
+
return result
|
| 208 |
+
|
| 209 |
+
def process_folder(
|
| 210 |
+
self,
|
| 211 |
+
input_dir: str | Path | None = None,
|
| 212 |
+
save_output: bool = True,
|
| 213 |
+
move_files: bool = True,
|
| 214 |
+
) -> list[ConversionResult]:
|
| 215 |
+
"""
|
| 216 |
+
Process all supported files in a folder.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
input_dir: Path to the input folder. If None, opens a folder picker.
|
| 220 |
+
save_output: Whether to save .md files and reports to output/.
|
| 221 |
+
move_files: Whether to move originals to processed/failed folders.
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
List of ConversionResult objects.
|
| 225 |
+
"""
|
| 226 |
+
# Resolve input directory
|
| 227 |
+
if input_dir is None:
|
| 228 |
+
logger.info("Opening folder picker...")
|
| 229 |
+
input_dir = open_folder_picker("Select Input Folder for Warraqa")
|
| 230 |
+
if input_dir is None:
|
| 231 |
+
logger.warning("No folder selected. Aborting.")
|
| 232 |
+
return []
|
| 233 |
+
input_dir = Path(input_dir).resolve()
|
| 234 |
+
|
| 235 |
+
if not input_dir.is_dir():
|
| 236 |
+
logger.error(f"Not a valid directory: {input_dir}")
|
| 237 |
+
return []
|
| 238 |
+
|
| 239 |
+
# Find supported files
|
| 240 |
+
files = find_supported_files(input_dir)
|
| 241 |
+
if not files:
|
| 242 |
+
logger.info(f"No supported files found in: {input_dir}")
|
| 243 |
+
return []
|
| 244 |
+
|
| 245 |
+
logger.info(
|
| 246 |
+
f"Found [bold]{len(files)}[/bold] file(s) in: {input_dir}",
|
| 247 |
+
extra={"markup": True},
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# --- Pre-pass: triage PDFs into native vs scanned ---
|
| 251 |
+
# Scanned PDFs are deferred to the end (OCR is slow); native PDFs and
|
| 252 |
+
# Word docs run first. Scanned PDFs are physically moved to the staging
|
| 253 |
+
# folder so they're out of the input set during phase 1.
|
| 254 |
+
fast_queue: list[Path] = []
|
| 255 |
+
scanned_queue: list[Path] = []
|
| 256 |
+
for file_path in files:
|
| 257 |
+
if file_path.suffix.lower() == ".pdf" and not PDFAnalyzer.is_native_text(file_path):
|
| 258 |
+
staged_path = safe_move(file_path, self._scanned_dir)
|
| 259 |
+
scanned_queue.append(staged_path)
|
| 260 |
+
else:
|
| 261 |
+
fast_queue.append(file_path)
|
| 262 |
+
|
| 263 |
+
if scanned_queue:
|
| 264 |
+
logger.info(
|
| 265 |
+
f"Triage: [bold]{len(fast_queue)}[/bold] fast file(s), "
|
| 266 |
+
f"[bold]{len(scanned_queue)}[/bold] scanned PDF(s) staged to "
|
| 267 |
+
f"[dim]{self._scanned_dir.name}/[/dim] for deferred OCR",
|
| 268 |
+
extra={"markup": True},
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
report = BatchReport(start_time=datetime.now())
|
| 272 |
+
deferred_in_phase1 = 0
|
| 273 |
+
|
| 274 |
+
def _run(
|
| 275 |
+
queue: list[Path],
|
| 276 |
+
phase_label: str,
|
| 277 |
+
*,
|
| 278 |
+
defer_pdf_fallback: bool = False,
|
| 279 |
+
force_ocr: bool = False,
|
| 280 |
+
) -> None:
|
| 281 |
+
nonlocal deferred_in_phase1
|
| 282 |
+
for i, file_path in enumerate(queue, 1):
|
| 283 |
+
logger.info(f"\n{'='*60}")
|
| 284 |
+
logger.info(f"{phase_label} — file {i}/{len(queue)}")
|
| 285 |
+
logger.info(f"{'='*60}")
|
| 286 |
+
|
| 287 |
+
result = self.convert_file(
|
| 288 |
+
file_path,
|
| 289 |
+
force_ocr=force_ocr,
|
| 290 |
+
defer_pdf_fallback=defer_pdf_fallback,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# If Phase 1 chose to defer this file to OCR, stage it and skip
|
| 294 |
+
# save/move/report — Phase 2 will produce the final result.
|
| 295 |
+
if result.defer_to_ocr:
|
| 296 |
+
staged = safe_move(file_path, self._scanned_dir)
|
| 297 |
+
scanned_queue.append(staged)
|
| 298 |
+
deferred_in_phase1 += 1
|
| 299 |
+
logger.info(
|
| 300 |
+
f" → Staged for Phase 2 OCR: "
|
| 301 |
+
f"[dim]{self._scanned_dir.name}/{staged.name}[/dim]",
|
| 302 |
+
extra={"markup": True},
|
| 303 |
+
)
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
if save_output and result.markdown_content:
|
| 307 |
+
self._save_output(result)
|
| 308 |
+
|
| 309 |
+
if move_files:
|
| 310 |
+
self._move_original(result)
|
| 311 |
+
|
| 312 |
+
if save_output:
|
| 313 |
+
self._save_report(result)
|
| 314 |
+
|
| 315 |
+
report.results.append(result)
|
| 316 |
+
|
| 317 |
+
# Phase 1: native PDFs + Word docs
|
| 318 |
+
# `defer_pdf_fallback=True` keeps the fast pass fast: any native PDF
|
| 319 |
+
# that PyMuPDF can't extract from is staged to Phase 2 instead of
|
| 320 |
+
# triggering an inline Marker call.
|
| 321 |
+
if fast_queue:
|
| 322 |
+
_run(fast_queue, "Phase 1 (fast)", defer_pdf_fallback=True)
|
| 323 |
+
|
| 324 |
+
# Phase 2: deferred OCR for staged scanned PDFs
|
| 325 |
+
# `force_ocr=True` skips the native-text re-analysis (it was either
|
| 326 |
+
# already classified scanned in triage, or got deferred from Phase 1
|
| 327 |
+
# because PyMuPDF couldn't extract text — both cases want Marker).
|
| 328 |
+
if scanned_queue:
|
| 329 |
+
logger.info(f"\n{'#'*60}")
|
| 330 |
+
logger.info(
|
| 331 |
+
f"Phase 2 (OCR): processing {len(scanned_queue)} scanned PDF(s) "
|
| 332 |
+
f"({deferred_in_phase1} deferred from Phase 1)"
|
| 333 |
+
)
|
| 334 |
+
logger.info(f"{'#'*60}")
|
| 335 |
+
_run(scanned_queue, "Phase 2 (OCR)", force_ocr=True)
|
| 336 |
+
|
| 337 |
+
report.end_time = datetime.now()
|
| 338 |
+
|
| 339 |
+
# Print batch summary
|
| 340 |
+
self._print_batch_summary(report)
|
| 341 |
+
|
| 342 |
+
return report.results
|
| 343 |
+
|
| 344 |
+
def watch_folder(
|
| 345 |
+
self,
|
| 346 |
+
input_dir: str | Path | None = None,
|
| 347 |
+
interval: int | None = None,
|
| 348 |
+
) -> None:
|
| 349 |
+
"""
|
| 350 |
+
Watch mode: continuously monitor a folder for new files.
|
| 351 |
+
|
| 352 |
+
Args:
|
| 353 |
+
input_dir: Folder to watch. Uses config if None.
|
| 354 |
+
interval: Poll interval in seconds. Uses config if None.
|
| 355 |
+
"""
|
| 356 |
+
if input_dir is None:
|
| 357 |
+
input_dir = self._config["watch"].get("input_folder")
|
| 358 |
+
if input_dir is None:
|
| 359 |
+
logger.info("No watch folder configured. Opening picker...")
|
| 360 |
+
input_dir = open_folder_picker("Select Folder to Watch")
|
| 361 |
+
if input_dir is None:
|
| 362 |
+
logger.warning("No folder selected. Aborting watch mode.")
|
| 363 |
+
return
|
| 364 |
+
|
| 365 |
+
input_dir = Path(input_dir).resolve()
|
| 366 |
+
if interval is None:
|
| 367 |
+
interval = self._config["watch"].get("poll_interval_seconds", 10)
|
| 368 |
+
|
| 369 |
+
logger.info(
|
| 370 |
+
f"Watch mode: monitoring [bold]{input_dir}[/bold] "
|
| 371 |
+
f"every {interval}s (Ctrl+C to stop)",
|
| 372 |
+
extra={"markup": True},
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
processed_files: set[str] = set()
|
| 376 |
+
|
| 377 |
+
try:
|
| 378 |
+
while True:
|
| 379 |
+
files = find_supported_files(input_dir)
|
| 380 |
+
new_files = [
|
| 381 |
+
f for f in files if str(f) not in processed_files
|
| 382 |
+
]
|
| 383 |
+
|
| 384 |
+
if new_files:
|
| 385 |
+
logger.info(f"Found {len(new_files)} new file(s)")
|
| 386 |
+
for file_path in new_files:
|
| 387 |
+
result = self.convert_file(file_path)
|
| 388 |
+
if result.markdown_content:
|
| 389 |
+
self._save_output(result)
|
| 390 |
+
self._move_original(result)
|
| 391 |
+
self._save_report(result)
|
| 392 |
+
processed_files.add(str(file_path))
|
| 393 |
+
|
| 394 |
+
time.sleep(interval)
|
| 395 |
+
|
| 396 |
+
except KeyboardInterrupt:
|
| 397 |
+
logger.info("\nWatch mode stopped by user.")
|
| 398 |
+
|
| 399 |
+
# --- Internal Methods ---
|
| 400 |
+
|
| 401 |
+
def _load_config(self, config_path: str | Path | None) -> dict:
|
| 402 |
+
"""Load configuration from YAML file, falling back to defaults."""
|
| 403 |
+
config = DEFAULT_CONFIG.copy()
|
| 404 |
+
|
| 405 |
+
if config_path is None:
|
| 406 |
+
# Look for config.yaml relative to this package
|
| 407 |
+
pkg_dir = Path(__file__).parent.parent
|
| 408 |
+
config_path = pkg_dir / "config.yaml"
|
| 409 |
+
|
| 410 |
+
config_path = Path(config_path)
|
| 411 |
+
if config_path.is_file():
|
| 412 |
+
try:
|
| 413 |
+
import yaml
|
| 414 |
+
with open(config_path, encoding="utf-8") as f:
|
| 415 |
+
file_config = yaml.safe_load(f) or {}
|
| 416 |
+
config = self._deep_merge(config, file_config)
|
| 417 |
+
except Exception as e:
|
| 418 |
+
logger.warning(f"Failed to load config from {config_path}: {e}")
|
| 419 |
+
|
| 420 |
+
return config
|
| 421 |
+
|
| 422 |
+
def _deep_merge(self, base: dict, override: dict) -> dict:
|
| 423 |
+
"""Deep merge override dict into base dict."""
|
| 424 |
+
result = base.copy()
|
| 425 |
+
for key, value in override.items():
|
| 426 |
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
| 427 |
+
result[key] = self._deep_merge(result[key], value)
|
| 428 |
+
else:
|
| 429 |
+
result[key] = value
|
| 430 |
+
return result
|
| 431 |
+
|
| 432 |
+
def _setup_logging(self) -> None:
|
| 433 |
+
"""Configure logging from config."""
|
| 434 |
+
log_config = self._config.get("logging", {})
|
| 435 |
+
setup_logging(
|
| 436 |
+
level=log_config.get("level", "INFO"),
|
| 437 |
+
log_file=log_config.get("file"),
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
def _init_output_dirs(self) -> None:
|
| 441 |
+
"""Create output directory structure."""
|
| 442 |
+
out = self._config["output"]
|
| 443 |
+
self._md_dir = self._output_base / out["md_files"]
|
| 444 |
+
self._processed_dir = self._output_base / out["processed"]
|
| 445 |
+
self._failed_dir = self._output_base / out["failed"]
|
| 446 |
+
self._reports_dir = self._output_base / out["reports"]
|
| 447 |
+
self._scanned_dir = self._output_base / out.get("scanned_pdfs", "scanned_pdfs")
|
| 448 |
+
ensure_directories(
|
| 449 |
+
self._md_dir, self._processed_dir, self._failed_dir,
|
| 450 |
+
self._reports_dir, self._scanned_dir,
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
def _init_engines(self) -> dict[str, dict[str, BaseEngine | None]]:
|
| 454 |
+
"""Initialize conversion engines based on config."""
|
| 455 |
+
engine_config = self._config["engines"]
|
| 456 |
+
|
| 457 |
+
engine_map: dict[str, BaseEngine] = {
|
| 458 |
+
"marker": MarkerPDFEngine(),
|
| 459 |
+
"pymupdf": PyMuPDFEngine(),
|
| 460 |
+
"markitdown": MarkItDownEngine(),
|
| 461 |
+
"pandoc": PandocEngine(),
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
def _resolve(key: str, default: str | None) -> BaseEngine | None:
|
| 465 |
+
name = engine_config.get(key, default)
|
| 466 |
+
return engine_map.get(name) if name else None
|
| 467 |
+
|
| 468 |
+
engines = {
|
| 469 |
+
"pdf": {
|
| 470 |
+
"primary": _resolve("pdf_primary", "marker"),
|
| 471 |
+
"fallback": _resolve("pdf_fallback", "pymupdf"),
|
| 472 |
+
},
|
| 473 |
+
"docx": {
|
| 474 |
+
"primary": _resolve("docx_primary", "markitdown"),
|
| 475 |
+
"fallback": _resolve("docx_fallback", "pandoc"),
|
| 476 |
+
},
|
| 477 |
+
"pptx": {
|
| 478 |
+
"primary": _resolve("pptx_primary", "markitdown"),
|
| 479 |
+
# Pandoc cannot read .pptx — no native fallback by default.
|
| 480 |
+
"fallback": _resolve("pptx_fallback", None),
|
| 481 |
+
},
|
| 482 |
+
}
|
| 483 |
+
# Legacy formats route to their modern counterpart's engines —
|
| 484 |
+
# the OfficeCOMConverter rewrites file_info.file_type upstream,
|
| 485 |
+
# but we keep these aliases as a safety net.
|
| 486 |
+
engines["doc"] = engines["docx"]
|
| 487 |
+
engines["ppt"] = engines["pptx"]
|
| 488 |
+
|
| 489 |
+
# Log engine availability
|
| 490 |
+
for name, engine in engine_map.items():
|
| 491 |
+
status = "✓ available" if engine.is_available() else "✗ not installed"
|
| 492 |
+
logger.debug(f"Engine {name}: {status}")
|
| 493 |
+
|
| 494 |
+
return engines
|
| 495 |
+
|
| 496 |
+
def _build_file_info(self, file_path: Path) -> FileInfo | None:
|
| 497 |
+
"""Build FileInfo from a file path. Returns None if unsupported."""
|
| 498 |
+
file_type = detect_file_type(file_path)
|
| 499 |
+
if file_type is None:
|
| 500 |
+
return None
|
| 501 |
+
|
| 502 |
+
return FileInfo(
|
| 503 |
+
path=file_path,
|
| 504 |
+
file_type=file_type,
|
| 505 |
+
size_bytes=file_path.stat().st_size,
|
| 506 |
+
mime_type=get_mime_type(file_path),
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
def _maybe_preconvert_legacy(
|
| 510 |
+
self, file_info: FileInfo
|
| 511 |
+
) -> tuple[FileInfo, Path | None, str | None]:
|
| 512 |
+
"""
|
| 513 |
+
Pre-convert legacy binary Office files (.doc, .ppt) to modern XML
|
| 514 |
+
(.docx, .pptx) via MS Office COM. Returns:
|
| 515 |
+
(working_file_info, temp_path_to_cleanup, error_message)
|
| 516 |
+
|
| 517 |
+
- For non-legacy formats: returns the original FileInfo unchanged.
|
| 518 |
+
- For legacy formats with COM available: returns a FileInfo pointing
|
| 519 |
+
at the temp converted file, plus the temp path for later cleanup.
|
| 520 |
+
- For legacy formats when COM is unavailable or conversion fails:
|
| 521 |
+
returns the original FileInfo plus a descriptive error message.
|
| 522 |
+
The caller should treat this as a hard failure rather than calling
|
| 523 |
+
the engines (MarkItDown and Pandoc both choke on the binary format).
|
| 524 |
+
"""
|
| 525 |
+
ftype = file_info.file_type
|
| 526 |
+
if ftype not in ("doc", "ppt"):
|
| 527 |
+
return file_info, None, None
|
| 528 |
+
|
| 529 |
+
if not self._office_com.is_available():
|
| 530 |
+
return file_info, None, (
|
| 531 |
+
f"Legacy {ftype.upper()} format requires MS Office COM "
|
| 532 |
+
f"(pywin32 + Word/PowerPoint) — not available on this system"
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
try:
|
| 536 |
+
if ftype == "doc":
|
| 537 |
+
converted = self._office_com.convert_doc_to_docx(file_info.path)
|
| 538 |
+
new_type, new_mime = "docx", (
|
| 539 |
+
"application/vnd.openxmlformats-officedocument."
|
| 540 |
+
"wordprocessingml.document"
|
| 541 |
+
)
|
| 542 |
+
else: # ppt
|
| 543 |
+
converted = self._office_com.convert_ppt_to_pptx(file_info.path)
|
| 544 |
+
new_type, new_mime = "pptx", (
|
| 545 |
+
"application/vnd.openxmlformats-officedocument."
|
| 546 |
+
"presentationml.presentation"
|
| 547 |
+
)
|
| 548 |
+
except Exception as e:
|
| 549 |
+
return file_info, None, f"Legacy {ftype.upper()} preconvert failed: {e}"
|
| 550 |
+
|
| 551 |
+
working = FileInfo(
|
| 552 |
+
path=converted,
|
| 553 |
+
file_type=new_type,
|
| 554 |
+
size_bytes=converted.stat().st_size,
|
| 555 |
+
mime_type=new_mime,
|
| 556 |
+
filename=file_info.filename, # keep original filename for reporting
|
| 557 |
+
)
|
| 558 |
+
return working, converted, None
|
| 559 |
+
|
| 560 |
+
def _convert_with_fallback(
|
| 561 |
+
self,
|
| 562 |
+
file_info: FileInfo,
|
| 563 |
+
*,
|
| 564 |
+
force_ocr: bool = False,
|
| 565 |
+
defer_pdf_fallback: bool = False,
|
| 566 |
+
) -> ConversionResult:
|
| 567 |
+
"""
|
| 568 |
+
Try primary engine, fall back if failure or very low score.
|
| 569 |
+
Keeps the best result.
|
| 570 |
+
|
| 571 |
+
If `defer_pdf_fallback` and the primary PDF engine is PyMuPDF and it
|
| 572 |
+
produces a poor result, skip the inline Marker fallback and mark the
|
| 573 |
+
result with `defer_to_ocr=True` so the batch orchestrator can stage
|
| 574 |
+
the file for the Phase 2 OCR pass.
|
| 575 |
+
"""
|
| 576 |
+
engine_set = self._engines.get(file_info.file_type)
|
| 577 |
+
if not engine_set:
|
| 578 |
+
return ConversionResult(
|
| 579 |
+
source_file=file_info,
|
| 580 |
+
error=f"No engines configured for file type: {file_info.file_type}",
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
primary: BaseEngine | None = engine_set.get("primary")
|
| 584 |
+
fallback: BaseEngine | None = engine_set.get("fallback")
|
| 585 |
+
|
| 586 |
+
# --- Pre-scan Document (Dynamic Engine Selection) ---
|
| 587 |
+
if file_info.file_type == "pdf":
|
| 588 |
+
if force_ocr:
|
| 589 |
+
logger.info("PDF: force_ocr=True — using Marker (OCR) as primary.")
|
| 590 |
+
primary = self._engines["pdf"]["primary"] # Marker
|
| 591 |
+
fallback = self._engines["pdf"]["fallback"] # PyMuPDF
|
| 592 |
+
else:
|
| 593 |
+
is_native = PDFAnalyzer.is_native_text(file_info.path)
|
| 594 |
+
if is_native:
|
| 595 |
+
logger.info(f"PDF is NATIVE TEXT. Using '{self._native_engine}' for speed/quality tradeoff.")
|
| 596 |
+
if self._native_engine == "pymupdf":
|
| 597 |
+
primary = self._engines["pdf"]["fallback"] # PyMuPDF
|
| 598 |
+
fallback = self._engines["pdf"]["primary"] # Marker
|
| 599 |
+
else:
|
| 600 |
+
primary = self._engines["pdf"]["primary"] # Marker
|
| 601 |
+
fallback = self._engines["pdf"]["fallback"] # PyMuPDF
|
| 602 |
+
else:
|
| 603 |
+
logger.info("PDF is SCANNED IMAGE. Forcing 'marker' engine for OCR.")
|
| 604 |
+
primary = self._engines["pdf"]["primary"] # Marker
|
| 605 |
+
fallback = self._engines["pdf"]["fallback"] # PyMuPDF
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
# Try primary engine
|
| 609 |
+
result = self._try_engine(primary, file_info)
|
| 610 |
+
|
| 611 |
+
fail_threshold = self._config["scoring"]["fail_threshold"]
|
| 612 |
+
primary_poor = result.error or result.confidence_score < fail_threshold
|
| 613 |
+
|
| 614 |
+
# --- Phase 1 deferral: skip inline Marker fallback for poor PyMuPDF ---
|
| 615 |
+
# When called from the batch fast-pass, a poor native-PDF result should
|
| 616 |
+
# not stall Phase 1 by invoking the slow OCR engine. Stage it instead.
|
| 617 |
+
if (
|
| 618 |
+
defer_pdf_fallback
|
| 619 |
+
and file_info.file_type == "pdf"
|
| 620 |
+
and primary is self._engines["pdf"]["fallback"] # primary was PyMuPDF
|
| 621 |
+
and primary_poor
|
| 622 |
+
):
|
| 623 |
+
logger.info(
|
| 624 |
+
f"PyMuPDF gave a poor result "
|
| 625 |
+
f"(score={result.confidence_score}, words={len(result.markdown_content.split())}). "
|
| 626 |
+
f"Deferring to Phase 2 OCR instead of inline Marker fallback."
|
| 627 |
+
)
|
| 628 |
+
result.defer_to_ocr = True
|
| 629 |
+
return result
|
| 630 |
+
|
| 631 |
+
# If primary failed or scored very poorly, try fallback
|
| 632 |
+
if fallback and primary_poor:
|
| 633 |
+
logger.info(
|
| 634 |
+
f"Primary engine {'failed' if result.error else 'scored poorly'}, "
|
| 635 |
+
f"trying fallback: {fallback.name}"
|
| 636 |
+
)
|
| 637 |
+
fallback_result = self._try_engine(fallback, file_info)
|
| 638 |
+
fallback_result.fallback_used = True
|
| 639 |
+
|
| 640 |
+
# Keep the better result
|
| 641 |
+
if fallback_result.error is None and (
|
| 642 |
+
result.error or fallback_result.confidence_score > result.confidence_score
|
| 643 |
+
):
|
| 644 |
+
result = fallback_result
|
| 645 |
+
|
| 646 |
+
return result
|
| 647 |
+
|
| 648 |
+
def _try_engine(self, engine: BaseEngine | None, file_info: FileInfo) -> ConversionResult:
|
| 649 |
+
"""Attempt conversion with a single engine."""
|
| 650 |
+
if engine is None:
|
| 651 |
+
return ConversionResult(
|
| 652 |
+
source_file=file_info, error="No engine available"
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
if not engine.is_available():
|
| 656 |
+
return ConversionResult(
|
| 657 |
+
source_file=file_info,
|
| 658 |
+
error=f"Engine {engine.name} is not installed",
|
| 659 |
+
engine_used=engine.name,
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
try:
|
| 663 |
+
markdown_text, metadata = engine.convert(file_info.path)
|
| 664 |
+
|
| 665 |
+
# Strip unpaired surrogates so the result can actually be saved
|
| 666 |
+
# to a UTF-8 file. PyMuPDF (and others) occasionally emit them
|
| 667 |
+
# from PDFs with malformed text encodings or custom font cmaps.
|
| 668 |
+
markdown_text, surrogate_count = sanitize_markdown(markdown_text)
|
| 669 |
+
|
| 670 |
+
# Score the result
|
| 671 |
+
score, grade, warnings = self._scorer.score(
|
| 672 |
+
markdown_text, file_info, metadata
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
if surrogate_count > 0:
|
| 676 |
+
warnings.append(
|
| 677 |
+
f"Replaced {surrogate_count} invalid Unicode surrogate(s) from engine output"
|
| 678 |
+
)
|
| 679 |
+
logger.debug(
|
| 680 |
+
f"Sanitized {surrogate_count} surrogate(s) from {engine.name} output"
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
return ConversionResult(
|
| 684 |
+
source_file=file_info,
|
| 685 |
+
markdown_content=markdown_text,
|
| 686 |
+
confidence_score=score,
|
| 687 |
+
grade=grade,
|
| 688 |
+
engine_used=engine.name,
|
| 689 |
+
metadata=metadata,
|
| 690 |
+
warnings=warnings,
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
except Exception as e:
|
| 694 |
+
logger.error(f"Engine {engine.name} failed: {e}")
|
| 695 |
+
return ConversionResult(
|
| 696 |
+
source_file=file_info,
|
| 697 |
+
error=str(e),
|
| 698 |
+
engine_used=engine.name,
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
def _save_output(self, result: ConversionResult) -> None:
|
| 702 |
+
"""Save the converted Markdown to output/md_files/."""
|
| 703 |
+
if not result.markdown_content:
|
| 704 |
+
return
|
| 705 |
+
|
| 706 |
+
filename = sanitize_filename(result.source_file.filename) + ".md"
|
| 707 |
+
output_path = self._md_dir / filename
|
| 708 |
+
|
| 709 |
+
# Handle collision
|
| 710 |
+
if output_path.exists():
|
| 711 |
+
stem = output_path.stem
|
| 712 |
+
counter = 1
|
| 713 |
+
while output_path.exists():
|
| 714 |
+
output_path = self._md_dir / f"{stem}_{counter}.md"
|
| 715 |
+
counter += 1
|
| 716 |
+
|
| 717 |
+
# `errors="replace"` is a safety net in case any odd code point slips
|
| 718 |
+
# past sanitize_markdown — one bad PDF should never crash the batch.
|
| 719 |
+
with open(output_path, "w", encoding="utf-8", errors="replace") as f:
|
| 720 |
+
f.write(result.markdown_content)
|
| 721 |
+
result.output_path = output_path
|
| 722 |
+
logger.debug(f"Saved: {output_path.name}")
|
| 723 |
+
|
| 724 |
+
def _move_original(self, result: ConversionResult) -> None:
|
| 725 |
+
"""Move the original file to processed/ or failed/."""
|
| 726 |
+
src = result.source_file.path
|
| 727 |
+
if not src.exists():
|
| 728 |
+
logger.warning(f"Source file no longer exists: {src}")
|
| 729 |
+
return
|
| 730 |
+
|
| 731 |
+
dest_dir = self._processed_dir if result.success else self._failed_dir
|
| 732 |
+
safe_move(src, dest_dir)
|
| 733 |
+
logger.debug(f"Moved original to: {dest_dir.name}/")
|
| 734 |
+
|
| 735 |
+
def _save_report(self, result: ConversionResult) -> None:
|
| 736 |
+
"""Save a JSON report for this conversion."""
|
| 737 |
+
report_name = sanitize_filename(result.source_file.filename) + ".json"
|
| 738 |
+
report_path = self._reports_dir / report_name
|
| 739 |
+
|
| 740 |
+
if report_path.exists():
|
| 741 |
+
stem = report_path.stem
|
| 742 |
+
counter = 1
|
| 743 |
+
while report_path.exists():
|
| 744 |
+
report_path = self._reports_dir / f"{stem}_{counter}.json"
|
| 745 |
+
counter += 1
|
| 746 |
+
|
| 747 |
+
# `errors="replace"` mirrors _save_output — one weird code point
|
| 748 |
+
# shouldn't ever take down report writing for a whole batch.
|
| 749 |
+
with open(report_path, "w", encoding="utf-8", errors="replace") as f:
|
| 750 |
+
json.dump(result.to_report_dict(), f, indent=2, ensure_ascii=False)
|
| 751 |
+
|
| 752 |
+
def _print_batch_summary(self, report: BatchReport) -> None:
|
| 753 |
+
"""Print a beautiful batch summary to console."""
|
| 754 |
+
from rich.table import Table
|
| 755 |
+
|
| 756 |
+
summary = report.summary()
|
| 757 |
+
|
| 758 |
+
logger.info(f"\n{'='*60}")
|
| 759 |
+
logger.info("BATCH CONVERSION COMPLETE")
|
| 760 |
+
logger.info(f"{'='*60}")
|
| 761 |
+
|
| 762 |
+
table = Table(title="Warraqa — Conversion Summary")
|
| 763 |
+
table.add_column("File", style="cyan")
|
| 764 |
+
table.add_column("Type", style="dim")
|
| 765 |
+
table.add_column("Engine", style="blue")
|
| 766 |
+
table.add_column("Score", justify="center")
|
| 767 |
+
table.add_column("Grade", justify="center")
|
| 768 |
+
table.add_column("Time", justify="right", style="dim")
|
| 769 |
+
table.add_column("Status", justify="center")
|
| 770 |
+
|
| 771 |
+
grade_styles = {"A": "bold green", "B": "green", "C": "yellow", "D": "red", "F": "bold red"}
|
| 772 |
+
|
| 773 |
+
for r in report.results:
|
| 774 |
+
grade_style = grade_styles.get(r.grade, "white")
|
| 775 |
+
status = "✅" if r.success else "❌"
|
| 776 |
+
table.add_row(
|
| 777 |
+
r.source_file.filename,
|
| 778 |
+
r.source_file.file_type.upper(),
|
| 779 |
+
r.engine_used or "—",
|
| 780 |
+
str(r.confidence_score),
|
| 781 |
+
f"[{grade_style}]{r.grade}[/{grade_style}]",
|
| 782 |
+
f"{r.processing_time_seconds:.1f}s",
|
| 783 |
+
status,
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
from warraqa.utils import console
|
| 787 |
+
console.print(table)
|
| 788 |
+
|
| 789 |
+
# Stats
|
| 790 |
+
logger.info(
|
| 791 |
+
f"\nTotal: {summary['total_files']} | "
|
| 792 |
+
f"Success: {summary['successful']} | "
|
| 793 |
+
f"Failed: {summary['failed']} | "
|
| 794 |
+
f"Avg Score: {summary['average_score']} | "
|
| 795 |
+
f"Total Time: {summary['total_time_seconds']:.1f}s"
|
| 796 |
+
)
|
| 797 |
+
grades = summary["grade_distribution"]
|
| 798 |
+
logger.info(
|
| 799 |
+
f"Grades: A={grades['A']} B={grades['B']} C={grades['C']} "
|
| 800 |
+
f"D={grades['D']} F={grades['F']}"
|
| 801 |
+
)
|
warraqa/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warraqa CLI — Command-Line Entry Point
|
| 3 |
+
|
| 4 |
+
Exposed as the `warraqa` console-script (see pyproject.toml). The repo-root
|
| 5 |
+
`run.py` is a thin shim that imports `main` from here, so `python run.py`
|
| 6 |
+
continues to work for users who clone the repo without installing.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
warraqa # Manual mode: opens folder picker
|
| 10 |
+
warraqa --folder <path> # Manual mode: process specific folder
|
| 11 |
+
warraqa --file <path> # Convert a single file
|
| 12 |
+
warraqa --watch [--folder <path>] # Watch mode: continuously monitor
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import io
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
| 23 |
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
| 24 |
+
|
| 25 |
+
from rich.console import Console
|
| 26 |
+
from rich.panel import Panel
|
| 27 |
+
from rich.text import Text
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def print_banner() -> None:
|
| 31 |
+
"""Print the Warraqa startup banner."""
|
| 32 |
+
console = Console()
|
| 33 |
+
banner = Text()
|
| 34 |
+
banner.append(" ورّاقة ", style="bold magenta")
|
| 35 |
+
banner.append("Warraqa", style="bold white")
|
| 36 |
+
banner.append(" — The Document Scribe\n", style="dim")
|
| 37 |
+
banner.append(" Converting documents to Markdown with precision", style="italic dim")
|
| 38 |
+
|
| 39 |
+
console.print(Panel(
|
| 40 |
+
banner,
|
| 41 |
+
border_style="magenta",
|
| 42 |
+
padding=(1, 2),
|
| 43 |
+
title="[bold]Document Scribe Agent[/bold]",
|
| 44 |
+
subtitle="v1.0.0",
|
| 45 |
+
))
|
| 46 |
+
console.print()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main() -> None:
|
| 50 |
+
parser = argparse.ArgumentParser(
|
| 51 |
+
prog="warraqa",
|
| 52 |
+
description="Warraqa (ورّاقة) — The Document Scribe Agent. "
|
| 53 |
+
"Converts PDF, Word, and PowerPoint documents to clean Markdown.",
|
| 54 |
+
)
|
| 55 |
+
parser.add_argument(
|
| 56 |
+
"--file",
|
| 57 |
+
type=str,
|
| 58 |
+
help="Convert a single file to Markdown",
|
| 59 |
+
)
|
| 60 |
+
parser.add_argument(
|
| 61 |
+
"--folder",
|
| 62 |
+
type=str,
|
| 63 |
+
help="Process all supported files in a folder",
|
| 64 |
+
)
|
| 65 |
+
parser.add_argument(
|
| 66 |
+
"--watch",
|
| 67 |
+
action="store_true",
|
| 68 |
+
default=False,
|
| 69 |
+
help="Enable watch mode: continuously monitor for new files",
|
| 70 |
+
)
|
| 71 |
+
parser.add_argument(
|
| 72 |
+
"--interval",
|
| 73 |
+
type=int,
|
| 74 |
+
default=None,
|
| 75 |
+
help="Watch mode poll interval in seconds (default: 10)",
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"--config",
|
| 79 |
+
type=str,
|
| 80 |
+
default=None,
|
| 81 |
+
help="Path to config.yaml (default: auto-detect)",
|
| 82 |
+
)
|
| 83 |
+
parser.add_argument(
|
| 84 |
+
"--native-engine",
|
| 85 |
+
type=str,
|
| 86 |
+
choices=["pymupdf", "marker"],
|
| 87 |
+
default="pymupdf",
|
| 88 |
+
help="Engine to use for NATIVE text PDFs (default: pymupdf for speed; "
|
| 89 |
+
"'marker' for max quality).",
|
| 90 |
+
)
|
| 91 |
+
parser.add_argument(
|
| 92 |
+
"--no-move",
|
| 93 |
+
action="store_true",
|
| 94 |
+
default=False,
|
| 95 |
+
help="Don't move original files after processing",
|
| 96 |
+
)
|
| 97 |
+
parser.add_argument(
|
| 98 |
+
"--no-save",
|
| 99 |
+
action="store_true",
|
| 100 |
+
default=False,
|
| 101 |
+
help="Don't save output files (dry-run mode)",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
args = parser.parse_args()
|
| 105 |
+
|
| 106 |
+
print_banner()
|
| 107 |
+
|
| 108 |
+
from warraqa.agent import Warraqa
|
| 109 |
+
agent = Warraqa(config_path=args.config, native_engine=args.native_engine)
|
| 110 |
+
|
| 111 |
+
# --- Single file mode ---
|
| 112 |
+
if args.file:
|
| 113 |
+
file_path = Path(args.file).resolve()
|
| 114 |
+
if not file_path.is_file():
|
| 115 |
+
Console().print(f"[red]Error: File not found: {file_path}[/red]")
|
| 116 |
+
sys.exit(1)
|
| 117 |
+
|
| 118 |
+
result = agent.convert_file(file_path)
|
| 119 |
+
|
| 120 |
+
if not args.no_save and result.markdown_content:
|
| 121 |
+
agent._save_output(result)
|
| 122 |
+
agent._save_report(result)
|
| 123 |
+
|
| 124 |
+
if not args.no_move:
|
| 125 |
+
agent._move_original(result)
|
| 126 |
+
|
| 127 |
+
sys.exit(0 if result.success else 1)
|
| 128 |
+
|
| 129 |
+
# --- Watch mode ---
|
| 130 |
+
if args.watch:
|
| 131 |
+
agent.watch_folder(
|
| 132 |
+
input_dir=args.folder,
|
| 133 |
+
interval=args.interval,
|
| 134 |
+
)
|
| 135 |
+
sys.exit(0)
|
| 136 |
+
|
| 137 |
+
# --- Batch/manual mode (default) ---
|
| 138 |
+
results = agent.process_folder(
|
| 139 |
+
input_dir=args.folder,
|
| 140 |
+
save_output=not args.no_save,
|
| 141 |
+
move_files=not args.no_move,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Exit code: non-zero if any files failed
|
| 145 |
+
failed = sum(1 for r in results if not r.success)
|
| 146 |
+
sys.exit(1 if failed > 0 else 0)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
main()
|
warraqa/engines/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Warraqa conversion engines — specialized converters for each document format."""
|
| 2 |
+
|
| 3 |
+
from warraqa.engines.base import BaseEngine # noqa: F401
|
| 4 |
+
|
| 5 |
+
__all__ = ["BaseEngine"]
|
warraqa/engines/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (347 Bytes). View file
|
|
|
warraqa/engines/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (339 Bytes). View file
|
|
|
warraqa/engines/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (6.08 kB). View file
|
|
|
warraqa/engines/__pycache__/base.cpython-314.pyc
ADDED
|
Binary file (7.38 kB). View file
|
|
|
warraqa/engines/__pycache__/docx_markitdown.cpython-313.pyc
ADDED
|
Binary file (5.7 kB). View file
|
|
|
warraqa/engines/__pycache__/docx_markitdown.cpython-314.pyc
ADDED
|
Binary file (6.78 kB). View file
|
|
|
warraqa/engines/__pycache__/docx_pandoc.cpython-313.pyc
ADDED
|
Binary file (7.37 kB). View file
|
|
|
warraqa/engines/__pycache__/docx_pandoc.cpython-314.pyc
ADDED
|
Binary file (8.76 kB). View file
|
|
|
warraqa/engines/__pycache__/office_com.cpython-313.pyc
ADDED
|
Binary file (10.4 kB). View file
|
|
|
warraqa/engines/__pycache__/office_markitdown.cpython-313.pyc
ADDED
|
Binary file (5.96 kB). View file
|
|
|
warraqa/engines/__pycache__/pdf_marker.cpython-313.pyc
ADDED
|
Binary file (7.13 kB). View file
|
|
|
warraqa/engines/__pycache__/pdf_marker.cpython-314.pyc
ADDED
|
Binary file (8.23 kB). View file
|
|
|
warraqa/engines/__pycache__/pdf_pymupdf.cpython-313.pyc
ADDED
|
Binary file (4.78 kB). View file
|
|
|
warraqa/engines/__pycache__/pdf_pymupdf.cpython-314.pyc
ADDED
|
Binary file (5.71 kB). View file
|
|
|
warraqa/engines/base.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Engine Interface
|
| 3 |
+
|
| 4 |
+
Abstract base class that all Warraqa conversion engines must implement.
|
| 5 |
+
This ensures a consistent API across PDF and Word engines, enabling
|
| 6 |
+
clean fallback chains and inter-engine substitution.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from abc import ABC, abstractmethod
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("warraqa.engines")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class BaseEngine(ABC):
|
| 20 |
+
"""Abstract base class for all document conversion engines."""
|
| 21 |
+
|
| 22 |
+
@property
|
| 23 |
+
@abstractmethod
|
| 24 |
+
def name(self) -> str:
|
| 25 |
+
"""Human-readable engine name (e.g., 'Marker', 'PyMuPDF', 'MarkItDown')."""
|
| 26 |
+
...
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
@abstractmethod
|
| 30 |
+
def supported_types(self) -> list[str]:
|
| 31 |
+
"""List of file types this engine supports (e.g., ['pdf'], ['docx', 'doc'])."""
|
| 32 |
+
...
|
| 33 |
+
|
| 34 |
+
@abstractmethod
|
| 35 |
+
def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
|
| 36 |
+
"""
|
| 37 |
+
Convert a document file to Markdown.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
file_path: Path to the input document.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
A tuple of:
|
| 44 |
+
- markdown_text: The converted Markdown content as a string.
|
| 45 |
+
- metadata: Dictionary with engine-specific metadata, such as:
|
| 46 |
+
- page_count: int
|
| 47 |
+
- word_count: int
|
| 48 |
+
- heading_count: int
|
| 49 |
+
- table_count: int
|
| 50 |
+
- images_detected: int
|
| 51 |
+
- ocr_pages: int (for PDFs)
|
| 52 |
+
- equations_detected: int (for PDFs)
|
| 53 |
+
|
| 54 |
+
Raises:
|
| 55 |
+
FileNotFoundError: If the input file does not exist.
|
| 56 |
+
ValueError: If the file type is not supported.
|
| 57 |
+
RuntimeError: If the conversion fails.
|
| 58 |
+
"""
|
| 59 |
+
...
|
| 60 |
+
|
| 61 |
+
def supports(self, file_type: str) -> bool:
|
| 62 |
+
"""Check if this engine supports the given file type."""
|
| 63 |
+
return file_type.lower() in self.supported_types
|
| 64 |
+
|
| 65 |
+
def is_available(self) -> bool:
|
| 66 |
+
"""
|
| 67 |
+
Check if this engine's dependencies are installed and ready.
|
| 68 |
+
|
| 69 |
+
Override this method in subclasses that have optional dependencies.
|
| 70 |
+
"""
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
def _validate_input(self, file_path: Path) -> None:
|
| 74 |
+
"""Common input validation for all engines."""
|
| 75 |
+
if not file_path.exists():
|
| 76 |
+
raise FileNotFoundError(f"File not found: {file_path}")
|
| 77 |
+
if not file_path.is_file():
|
| 78 |
+
raise ValueError(f"Not a file: {file_path}")
|
| 79 |
+
if file_path.stat().st_size == 0:
|
| 80 |
+
raise ValueError(f"File is empty: {file_path}")
|
| 81 |
+
|
| 82 |
+
def _count_markdown_elements(self, markdown: str) -> dict[str, int]:
|
| 83 |
+
"""
|
| 84 |
+
Count structural Markdown elements in the output.
|
| 85 |
+
|
| 86 |
+
Useful for engines that don't provide their own metadata about
|
| 87 |
+
the structure of the converted document.
|
| 88 |
+
"""
|
| 89 |
+
lines = markdown.split("\n")
|
| 90 |
+
counts = {
|
| 91 |
+
"heading_count": 0,
|
| 92 |
+
"list_item_count": 0,
|
| 93 |
+
"table_row_count": 0,
|
| 94 |
+
"code_block_count": 0,
|
| 95 |
+
"link_count": 0,
|
| 96 |
+
"image_placeholder_count": 0,
|
| 97 |
+
"word_count": len(markdown.split()),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
in_code_block = False
|
| 101 |
+
for line in lines:
|
| 102 |
+
stripped = line.strip()
|
| 103 |
+
|
| 104 |
+
# Code blocks
|
| 105 |
+
if stripped.startswith("```"):
|
| 106 |
+
in_code_block = not in_code_block
|
| 107 |
+
if not in_code_block:
|
| 108 |
+
counts["code_block_count"] += 1
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
if in_code_block:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
# Headings
|
| 115 |
+
if stripped.startswith("#"):
|
| 116 |
+
counts["heading_count"] += 1
|
| 117 |
+
|
| 118 |
+
# List items
|
| 119 |
+
if stripped.startswith(("- ", "* ", "+ ")) or (
|
| 120 |
+
len(stripped) > 2 and stripped[0].isdigit() and ". " in stripped[:5]
|
| 121 |
+
):
|
| 122 |
+
counts["list_item_count"] += 1
|
| 123 |
+
|
| 124 |
+
# Table rows
|
| 125 |
+
if stripped.startswith("|") and stripped.endswith("|"):
|
| 126 |
+
# Skip separator rows like |---|---|
|
| 127 |
+
if not all(c in "|-: " for c in stripped):
|
| 128 |
+
counts["table_row_count"] += 1
|
| 129 |
+
|
| 130 |
+
# Links
|
| 131 |
+
counts["link_count"] += stripped.count("](")
|
| 132 |
+
|
| 133 |
+
# Image placeholders
|
| 134 |
+
if stripped.startswith("!["):
|
| 135 |
+
counts["image_placeholder_count"] += 1
|
| 136 |
+
|
| 137 |
+
return counts
|
| 138 |
+
|
| 139 |
+
def __repr__(self) -> str:
|
| 140 |
+
available = "✓" if self.is_available() else "✗"
|
| 141 |
+
return f"<{self.name} Engine [{available}] types={self.supported_types}>"
|
warraqa/engines/docx_pandoc.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pandoc Word Engine
|
| 3 |
+
|
| 4 |
+
Fallback Word/DOCX conversion engine using Pandoc via subprocess.
|
| 5 |
+
Pandoc is the gold-standard universal document converter, providing
|
| 6 |
+
the most reliable handling of complex formatting edge cases.
|
| 7 |
+
|
| 8 |
+
Requires Pandoc to be installed on the system.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from warraqa.engines.base import BaseEngine
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("warraqa.engines.pandoc")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class PandocEngine(BaseEngine):
|
| 25 |
+
"""Word/DOCX to Markdown converter using Pandoc (subprocess)."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, pandoc_path: str | None = None):
|
| 28 |
+
"""
|
| 29 |
+
Args:
|
| 30 |
+
pandoc_path: Explicit path to the pandoc executable.
|
| 31 |
+
If None, uses system PATH.
|
| 32 |
+
"""
|
| 33 |
+
self._pandoc_path = pandoc_path
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def name(self) -> str:
|
| 37 |
+
return "Pandoc"
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def supported_types(self) -> list[str]:
|
| 41 |
+
return ["docx", "doc"]
|
| 42 |
+
|
| 43 |
+
def _get_pandoc_cmd(self) -> str:
|
| 44 |
+
"""Get the pandoc command path."""
|
| 45 |
+
if self._pandoc_path:
|
| 46 |
+
return self._pandoc_path
|
| 47 |
+
return "pandoc"
|
| 48 |
+
|
| 49 |
+
def is_available(self) -> bool:
|
| 50 |
+
"""Check if Pandoc is installed and accessible."""
|
| 51 |
+
cmd = self._get_pandoc_cmd()
|
| 52 |
+
|
| 53 |
+
# Check explicit path
|
| 54 |
+
if self._pandoc_path:
|
| 55 |
+
return Path(self._pandoc_path).is_file()
|
| 56 |
+
|
| 57 |
+
# Check system PATH
|
| 58 |
+
if shutil.which(cmd):
|
| 59 |
+
return True
|
| 60 |
+
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
def _get_pandoc_version(self) -> str | None:
|
| 64 |
+
"""Get the installed Pandoc version."""
|
| 65 |
+
try:
|
| 66 |
+
result = subprocess.run(
|
| 67 |
+
[self._get_pandoc_cmd(), "--version"],
|
| 68 |
+
capture_output=True,
|
| 69 |
+
text=True,
|
| 70 |
+
timeout=10,
|
| 71 |
+
)
|
| 72 |
+
if result.returncode == 0:
|
| 73 |
+
first_line = result.stdout.strip().split("\n")[0]
|
| 74 |
+
return first_line
|
| 75 |
+
except Exception:
|
| 76 |
+
pass
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
|
| 80 |
+
"""
|
| 81 |
+
Convert a Word document to Markdown using Pandoc.
|
| 82 |
+
|
| 83 |
+
Runs Pandoc as a subprocess:
|
| 84 |
+
pandoc input.docx -t markdown --wrap=none
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
file_path: Path to the Word document.
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
(markdown_text, metadata) tuple.
|
| 91 |
+
"""
|
| 92 |
+
self._validate_input(file_path)
|
| 93 |
+
|
| 94 |
+
if not self.is_available():
|
| 95 |
+
raise RuntimeError(
|
| 96 |
+
"Pandoc is not installed or not found on PATH. "
|
| 97 |
+
"Install from: https://pandoc.org/installing.html"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
logger.info(f"[Pandoc] Converting: {file_path.name}")
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
cmd = [
|
| 104 |
+
self._get_pandoc_cmd(),
|
| 105 |
+
str(file_path),
|
| 106 |
+
"-t", "markdown",
|
| 107 |
+
"--wrap=none", # Don't wrap long lines
|
| 108 |
+
"--extract-media=-", # Don't extract media files
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
result = subprocess.run(
|
| 112 |
+
cmd,
|
| 113 |
+
capture_output=True,
|
| 114 |
+
text=True,
|
| 115 |
+
timeout=120, # 2-minute timeout for very large documents
|
| 116 |
+
encoding="utf-8",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
if result.returncode != 0:
|
| 120 |
+
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
|
| 121 |
+
raise RuntimeError(f"Pandoc exited with code {result.returncode}: {error_msg}")
|
| 122 |
+
|
| 123 |
+
markdown_text = result.stdout
|
| 124 |
+
|
| 125 |
+
if not markdown_text.strip():
|
| 126 |
+
logger.warning(f"[Pandoc] Empty output for {file_path.name}")
|
| 127 |
+
|
| 128 |
+
# Post-process: replace image references with alt-text placeholders
|
| 129 |
+
markdown_text = self._replace_images_with_placeholders(markdown_text)
|
| 130 |
+
|
| 131 |
+
# Build metadata
|
| 132 |
+
metadata: dict[str, Any] = {
|
| 133 |
+
"page_count": 0,
|
| 134 |
+
"images_detected": 0,
|
| 135 |
+
"tables_detected": 0,
|
| 136 |
+
"pandoc_version": self._get_pandoc_version(),
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
# Count structural elements
|
| 140 |
+
element_counts = self._count_markdown_elements(markdown_text)
|
| 141 |
+
metadata.update(element_counts)
|
| 142 |
+
|
| 143 |
+
metadata["tables_detected"] = markdown_text.count("|---|") + markdown_text.count("| ---")
|
| 144 |
+
|
| 145 |
+
logger.info(
|
| 146 |
+
f"[Pandoc] Success: {file_path.name} — "
|
| 147 |
+
f"{metadata['word_count']} words, "
|
| 148 |
+
f"{metadata['heading_count']} headings"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
return markdown_text, metadata
|
| 152 |
+
|
| 153 |
+
except subprocess.TimeoutExpired as e:
|
| 154 |
+
logger.error(f"[Pandoc] Timeout for {file_path.name} (>120s)")
|
| 155 |
+
raise RuntimeError("Pandoc conversion timed out (>120 seconds)") from e
|
| 156 |
+
except Exception as e:
|
| 157 |
+
if isinstance(e, RuntimeError):
|
| 158 |
+
raise
|
| 159 |
+
logger.error(f"[Pandoc] Conversion failed for {file_path.name}: {e}")
|
| 160 |
+
raise RuntimeError(f"Pandoc conversion failed: {e}") from e
|
| 161 |
+
|
| 162 |
+
def _replace_images_with_placeholders(self, markdown: str) -> str:
|
| 163 |
+
"""Replace any image references with alt-text placeholders."""
|
| 164 |
+
import re
|
| 165 |
+
|
| 166 |
+
image_count = 0
|
| 167 |
+
|
| 168 |
+
def _replace_match(match: re.Match) -> str:
|
| 169 |
+
nonlocal image_count
|
| 170 |
+
image_count += 1
|
| 171 |
+
alt_text = match.group(1).strip()
|
| 172 |
+
if alt_text and alt_text.lower() not in ("image", "img", "figure", ""):
|
| 173 |
+
return f"![Image: {alt_text}]()"
|
| 174 |
+
return f"![Image: embedded image #{image_count} in document]()"
|
| 175 |
+
|
| 176 |
+
markdown = re.sub(
|
| 177 |
+
r"!\[([^\]]*)\]\([^\)]+\)",
|
| 178 |
+
_replace_match,
|
| 179 |
+
markdown,
|
| 180 |
+
)
|
| 181 |
+
return markdown
|
warraqa/engines/office_com.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Office COM Pre-Converter
|
| 3 |
+
|
| 4 |
+
Uses Microsoft Word and PowerPoint via pywin32 COM automation to convert
|
| 5 |
+
legacy binary Office formats (.doc, .ppt) into their modern XML
|
| 6 |
+
equivalents (.docx, .pptx). Required because MarkItDown and Pandoc cannot
|
| 7 |
+
read the 1990s-era binary formats directly.
|
| 8 |
+
|
| 9 |
+
Windows + Microsoft Office required. The Office applications are launched
|
| 10 |
+
invisible and kept alive for the duration of the Warraqa session — opening
|
| 11 |
+
Word for every file would be very slow in a large batch.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import atexit
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import tempfile
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("warraqa.engines.office_com")
|
| 23 |
+
|
| 24 |
+
# Word file format constants (https://docs.microsoft.com/office/vba/api/word.wdsaveformat)
|
| 25 |
+
WD_FORMAT_XML_DOCUMENT = 12 # .docx
|
| 26 |
+
|
| 27 |
+
# PowerPoint file format constants (https://docs.microsoft.com/office/vba/api/powerpoint.ppsaveasfiletype)
|
| 28 |
+
PP_SAVE_AS_OPEN_XML_PRESENTATION = 24 # .pptx
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class OfficeCOMConverter:
|
| 32 |
+
"""Legacy → modern Office file pre-converter via MS Office COM."""
|
| 33 |
+
|
| 34 |
+
def __init__(self) -> None:
|
| 35 |
+
self._word = None
|
| 36 |
+
self._powerpoint = None
|
| 37 |
+
self._com_initialized = False
|
| 38 |
+
self._available = self._check_available()
|
| 39 |
+
if self._available:
|
| 40 |
+
atexit.register(self.close)
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
def _check_available() -> bool:
|
| 44 |
+
"""Verify pywin32 is importable. Does not start any COM app."""
|
| 45 |
+
try:
|
| 46 |
+
import win32com.client # noqa: F401
|
| 47 |
+
return True
|
| 48 |
+
except ImportError:
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
def is_available(self) -> bool:
|
| 52 |
+
return self._available
|
| 53 |
+
|
| 54 |
+
# --- Public conversion API ---
|
| 55 |
+
|
| 56 |
+
def convert_doc_to_docx(self, src: Path) -> Path:
|
| 57 |
+
"""Convert a binary .doc to a .docx in a temp file. Returns the temp path."""
|
| 58 |
+
if not src.is_file():
|
| 59 |
+
raise FileNotFoundError(src)
|
| 60 |
+
word = self._ensure_word()
|
| 61 |
+
out = self._make_temp_path(src.stem, ".docx")
|
| 62 |
+
|
| 63 |
+
logger.info(f"[OfficeCOM] Converting .doc → .docx: {src.name}")
|
| 64 |
+
doc = None
|
| 65 |
+
try:
|
| 66 |
+
doc = word.Documents.Open(
|
| 67 |
+
str(src.resolve()),
|
| 68 |
+
ReadOnly=True,
|
| 69 |
+
ConfirmConversions=False,
|
| 70 |
+
AddToRecentFiles=False,
|
| 71 |
+
)
|
| 72 |
+
doc.SaveAs2(str(out.resolve()), FileFormat=WD_FORMAT_XML_DOCUMENT)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
raise RuntimeError(f"Word COM failed on {src.name}: {e}") from e
|
| 75 |
+
finally:
|
| 76 |
+
if doc is not None:
|
| 77 |
+
try:
|
| 78 |
+
doc.Close(SaveChanges=False)
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
if not out.is_file() or out.stat().st_size == 0:
|
| 83 |
+
raise RuntimeError(f"Word produced no output for {src.name}")
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
def convert_ppt_to_pptx(self, src: Path) -> Path:
|
| 87 |
+
"""Convert a binary .ppt to a .pptx in a temp file. Returns the temp path."""
|
| 88 |
+
if not src.is_file():
|
| 89 |
+
raise FileNotFoundError(src)
|
| 90 |
+
ppt = self._ensure_powerpoint()
|
| 91 |
+
out = self._make_temp_path(src.stem, ".pptx")
|
| 92 |
+
|
| 93 |
+
logger.info(f"[OfficeCOM] Converting .ppt → .pptx: {src.name}")
|
| 94 |
+
pres = None
|
| 95 |
+
try:
|
| 96 |
+
pres = ppt.Presentations.Open(
|
| 97 |
+
str(src.resolve()),
|
| 98 |
+
ReadOnly=True,
|
| 99 |
+
WithWindow=False,
|
| 100 |
+
)
|
| 101 |
+
pres.SaveAs(
|
| 102 |
+
str(out.resolve()),
|
| 103 |
+
PP_SAVE_AS_OPEN_XML_PRESENTATION,
|
| 104 |
+
)
|
| 105 |
+
except Exception as e:
|
| 106 |
+
raise RuntimeError(f"PowerPoint COM failed on {src.name}: {e}") from e
|
| 107 |
+
finally:
|
| 108 |
+
if pres is not None:
|
| 109 |
+
try:
|
| 110 |
+
pres.Close()
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
if not out.is_file() or out.stat().st_size == 0:
|
| 115 |
+
raise RuntimeError(f"PowerPoint produced no output for {src.name}")
|
| 116 |
+
return out
|
| 117 |
+
|
| 118 |
+
def close(self) -> None:
|
| 119 |
+
"""Shutdown Word and PowerPoint. Safe to call multiple times."""
|
| 120 |
+
if self._word is not None:
|
| 121 |
+
try:
|
| 122 |
+
self._word.Quit()
|
| 123 |
+
logger.debug("[OfficeCOM] Word closed")
|
| 124 |
+
except Exception:
|
| 125 |
+
pass
|
| 126 |
+
self._word = None
|
| 127 |
+
if self._powerpoint is not None:
|
| 128 |
+
try:
|
| 129 |
+
self._powerpoint.Quit()
|
| 130 |
+
logger.debug("[OfficeCOM] PowerPoint closed")
|
| 131 |
+
except Exception:
|
| 132 |
+
pass
|
| 133 |
+
self._powerpoint = None
|
| 134 |
+
if self._com_initialized:
|
| 135 |
+
try:
|
| 136 |
+
import pythoncom
|
| 137 |
+
pythoncom.CoUninitialize()
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
self._com_initialized = False
|
| 141 |
+
|
| 142 |
+
# --- Internals ---
|
| 143 |
+
|
| 144 |
+
def _ensure_com_init(self) -> None:
|
| 145 |
+
if self._com_initialized:
|
| 146 |
+
return
|
| 147 |
+
import pythoncom
|
| 148 |
+
pythoncom.CoInitialize()
|
| 149 |
+
self._com_initialized = True
|
| 150 |
+
|
| 151 |
+
def _ensure_word(self):
|
| 152 |
+
if self._word is not None:
|
| 153 |
+
return self._word
|
| 154 |
+
if not self._available:
|
| 155 |
+
raise RuntimeError("pywin32 is not installed — cannot use Office COM")
|
| 156 |
+
self._ensure_com_init()
|
| 157 |
+
import win32com.client
|
| 158 |
+
try:
|
| 159 |
+
self._word = win32com.client.Dispatch("Word.Application")
|
| 160 |
+
self._word.Visible = False
|
| 161 |
+
self._word.DisplayAlerts = 0 # wdAlertsNone
|
| 162 |
+
logger.info(f"[OfficeCOM] Word {self._word.Version} initialized")
|
| 163 |
+
except Exception as e:
|
| 164 |
+
raise RuntimeError(
|
| 165 |
+
f"Failed to start Word via COM (is Microsoft Word installed?): {e}"
|
| 166 |
+
) from e
|
| 167 |
+
return self._word
|
| 168 |
+
|
| 169 |
+
def _ensure_powerpoint(self):
|
| 170 |
+
if self._powerpoint is not None:
|
| 171 |
+
return self._powerpoint
|
| 172 |
+
if not self._available:
|
| 173 |
+
raise RuntimeError("pywin32 is not installed — cannot use Office COM")
|
| 174 |
+
self._ensure_com_init()
|
| 175 |
+
import win32com.client
|
| 176 |
+
try:
|
| 177 |
+
self._powerpoint = win32com.client.Dispatch("PowerPoint.Application")
|
| 178 |
+
# PowerPoint's Visible property is read-only in newer versions
|
| 179 |
+
# when there's no UI thread; just leave default.
|
| 180 |
+
logger.info(
|
| 181 |
+
f"[OfficeCOM] PowerPoint {self._powerpoint.Version} initialized"
|
| 182 |
+
)
|
| 183 |
+
except Exception as e:
|
| 184 |
+
raise RuntimeError(
|
| 185 |
+
f"Failed to start PowerPoint via COM (is PowerPoint installed?): {e}"
|
| 186 |
+
) from e
|
| 187 |
+
return self._powerpoint
|
| 188 |
+
|
| 189 |
+
@staticmethod
|
| 190 |
+
def _make_temp_path(stem: str, suffix: str) -> Path:
|
| 191 |
+
"""Generate a unique temp file path. Caller is responsible for cleanup."""
|
| 192 |
+
safe_stem = "".join(c if c.isalnum() or c in "_-" else "_" for c in stem)[:60]
|
| 193 |
+
token = os.urandom(6).hex()
|
| 194 |
+
return Path(tempfile.gettempdir()) / f"warraqa_{token}_{safe_stem}{suffix}"
|
warraqa/engines/office_markitdown.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MarkItDown Office Engine
|
| 3 |
+
|
| 4 |
+
Primary Office document conversion engine using Microsoft's MarkItDown
|
| 5 |
+
library. Handles modern XML-based Office formats: .docx and .pptx. Legacy
|
| 6 |
+
.doc and .ppt are pre-converted to the modern format by the
|
| 7 |
+
OfficeCOMConverter before this engine runs.
|
| 8 |
+
|
| 9 |
+
Provides clean preservation of headings, lists, tables, links, slides,
|
| 10 |
+
speaker notes, and embedded content.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from warraqa.engines.base import BaseEngine
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("warraqa.engines.markitdown")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MarkItDownEngine(BaseEngine):
|
| 25 |
+
"""Office (DOCX/PPTX) to Markdown converter using Microsoft's MarkItDown."""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self._converter = None
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def name(self) -> str:
|
| 32 |
+
return "MarkItDown"
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def supported_types(self) -> list[str]:
|
| 36 |
+
# Modern formats only. Legacy .doc / .ppt are normalized upstream
|
| 37 |
+
# by the OfficeCOMConverter into .docx / .pptx before reaching here.
|
| 38 |
+
return ["docx", "pptx"]
|
| 39 |
+
|
| 40 |
+
def is_available(self) -> bool:
|
| 41 |
+
"""Check if markitdown is installed."""
|
| 42 |
+
try:
|
| 43 |
+
from markitdown import MarkItDown # noqa: F401
|
| 44 |
+
return True
|
| 45 |
+
except ImportError:
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
def _ensure_initialized(self) -> None:
|
| 49 |
+
"""Lazy-initialize the MarkItDown converter."""
|
| 50 |
+
if self._converter is not None:
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
if not self.is_available():
|
| 54 |
+
raise RuntimeError(
|
| 55 |
+
"MarkItDown is not installed. Run: pip install 'markitdown[all]'"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
from markitdown import MarkItDown
|
| 59 |
+
self._converter = MarkItDown()
|
| 60 |
+
logger.debug("MarkItDown engine initialized")
|
| 61 |
+
|
| 62 |
+
def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
|
| 63 |
+
"""
|
| 64 |
+
Convert an Office document (.docx or .pptx) to Markdown using
|
| 65 |
+
MarkItDown.
|
| 66 |
+
|
| 67 |
+
Legacy .doc / .ppt files should be pre-converted to their modern
|
| 68 |
+
equivalents by the OfficeCOMConverter before reaching this engine
|
| 69 |
+
— MarkItDown's bundled converters do not parse the binary formats.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
file_path: Path to the Office document.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
(markdown_text, metadata) tuple.
|
| 76 |
+
"""
|
| 77 |
+
self._validate_input(file_path)
|
| 78 |
+
self._ensure_initialized()
|
| 79 |
+
|
| 80 |
+
logger.info(f"[MarkItDown] Converting: {file_path.name}")
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
# Perform the conversion
|
| 84 |
+
result = self._converter.convert(str(file_path))
|
| 85 |
+
|
| 86 |
+
# MarkItDown returns result.text_content with the markdown
|
| 87 |
+
markdown_text = result.text_content if result.text_content else ""
|
| 88 |
+
|
| 89 |
+
if not markdown_text.strip():
|
| 90 |
+
logger.warning(
|
| 91 |
+
f"[MarkItDown] Empty output for {file_path.name}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Post-process: replace image references with alt-text placeholders
|
| 95 |
+
markdown_text = self._replace_images_with_placeholders(markdown_text)
|
| 96 |
+
|
| 97 |
+
# Build metadata
|
| 98 |
+
metadata: dict[str, Any] = {
|
| 99 |
+
"page_count": 0, # Word docs don't have fixed page counts
|
| 100 |
+
"images_detected": 0,
|
| 101 |
+
"tables_detected": 0,
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# Count structural elements
|
| 105 |
+
element_counts = self._count_markdown_elements(markdown_text)
|
| 106 |
+
metadata.update(element_counts)
|
| 107 |
+
|
| 108 |
+
# Estimate table count from markdown
|
| 109 |
+
metadata["tables_detected"] = markdown_text.count("|---|") + markdown_text.count("| ---")
|
| 110 |
+
|
| 111 |
+
logger.info(
|
| 112 |
+
f"[MarkItDown] Success: {file_path.name} — "
|
| 113 |
+
f"{metadata['word_count']} words, "
|
| 114 |
+
f"{metadata['heading_count']} headings"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
return markdown_text, metadata
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(
|
| 121 |
+
f"[MarkItDown] Conversion failed for {file_path.name}: {e}"
|
| 122 |
+
)
|
| 123 |
+
raise RuntimeError(f"MarkItDown conversion failed: {e}") from e
|
| 124 |
+
|
| 125 |
+
def _replace_images_with_placeholders(self, markdown: str) -> str:
|
| 126 |
+
"""Replace any image references with alt-text placeholders."""
|
| 127 |
+
import re
|
| 128 |
+
|
| 129 |
+
image_count = 0
|
| 130 |
+
|
| 131 |
+
def _replace_match(match: re.Match) -> str:
|
| 132 |
+
nonlocal image_count
|
| 133 |
+
image_count += 1
|
| 134 |
+
alt_text = match.group(1).strip()
|
| 135 |
+
if alt_text and alt_text.lower() not in ("image", "img", "figure", ""):
|
| 136 |
+
return f"![Image: {alt_text}]()"
|
| 137 |
+
return f"![Image: embedded image #{image_count} in document]()"
|
| 138 |
+
|
| 139 |
+
markdown = re.sub(
|
| 140 |
+
r"!\[([^\]]*)\]\([^\)]+\)",
|
| 141 |
+
_replace_match,
|
| 142 |
+
markdown,
|
| 143 |
+
)
|
| 144 |
+
return markdown
|
warraqa/engines/pdf_marker.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Marker PDF Engine
|
| 3 |
+
|
| 4 |
+
Primary PDF conversion engine using the Marker library — a deep learning
|
| 5 |
+
pipeline that handles complex layouts, tables, equations, multi-column
|
| 6 |
+
formats, headers/footers, and image detection.
|
| 7 |
+
|
| 8 |
+
Marker's purpose-built ML models outperform general-purpose vision LLMs
|
| 9 |
+
for structural document extraction.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from warraqa.engines.base import BaseEngine
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("warraqa.engines.marker")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class MarkerPDFEngine(BaseEngine):
|
| 24 |
+
"""High-fidelity PDF to Markdown converter using Marker."""
|
| 25 |
+
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self._converter = None
|
| 28 |
+
self._initialized = False
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def name(self) -> str:
|
| 32 |
+
return "Marker"
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def supported_types(self) -> list[str]:
|
| 36 |
+
return ["pdf"]
|
| 37 |
+
|
| 38 |
+
def is_available(self) -> bool:
|
| 39 |
+
"""Check if marker-pdf is installed."""
|
| 40 |
+
try:
|
| 41 |
+
import marker # noqa: F401
|
| 42 |
+
return True
|
| 43 |
+
except ImportError:
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
def _ensure_initialized(self) -> None:
|
| 47 |
+
"""
|
| 48 |
+
Lazy-initialize the Marker converter.
|
| 49 |
+
|
| 50 |
+
Marker loads several large ML models into memory on first use.
|
| 51 |
+
We do this lazily to avoid slow startup when the engine isn't needed,
|
| 52 |
+
but cache the converter so models are loaded only once.
|
| 53 |
+
"""
|
| 54 |
+
if self._initialized:
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
if not self.is_available():
|
| 58 |
+
raise RuntimeError(
|
| 59 |
+
"Marker is not installed. Run: pip install marker-pdf"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
from marker.converters.pdf import PdfConverter
|
| 64 |
+
from marker.models import create_model_dict
|
| 65 |
+
|
| 66 |
+
# Initialize Marker ML models (downloads if first run)
|
| 67 |
+
logger.info("Loading Marker ML models (this may take a few minutes on first run)...")
|
| 68 |
+
models = create_model_dict()
|
| 69 |
+
|
| 70 |
+
self._converter = PdfConverter(artifact_dict=models)
|
| 71 |
+
self._initialized = True
|
| 72 |
+
logger.info("Marker engine initialized (models loaded)")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Failed to initialize Marker engine: {e}")
|
| 75 |
+
raise RuntimeError(f"Marker initialization failed: {e}") from e
|
| 76 |
+
|
| 77 |
+
def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
|
| 78 |
+
"""
|
| 79 |
+
Convert a PDF file to Markdown using Marker.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
file_path: Path to the PDF file.
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
(markdown_text, metadata) tuple.
|
| 86 |
+
"""
|
| 87 |
+
self._validate_input(file_path)
|
| 88 |
+
self._ensure_initialized()
|
| 89 |
+
|
| 90 |
+
logger.info(f"[Marker] Converting: {file_path.name}")
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
# Run the conversion
|
| 94 |
+
rendered = self._converter(str(file_path))
|
| 95 |
+
|
| 96 |
+
# Extract the markdown text from the rendered output
|
| 97 |
+
markdown_text = rendered.markdown
|
| 98 |
+
|
| 99 |
+
import fitz
|
| 100 |
+
try:
|
| 101 |
+
with fitz.open(str(file_path)) as doc:
|
| 102 |
+
page_count = len(doc)
|
| 103 |
+
except Exception:
|
| 104 |
+
page_count = 0
|
| 105 |
+
|
| 106 |
+
# Build metadata from conversion results
|
| 107 |
+
metadata: dict[str, Any] = {
|
| 108 |
+
"page_count": page_count,
|
| 109 |
+
"images_detected": 0,
|
| 110 |
+
"tables_detected": 0,
|
| 111 |
+
"equations_detected": 0,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Count images from rendered output
|
| 115 |
+
if hasattr(rendered, 'images') and rendered.images:
|
| 116 |
+
metadata["images_detected"] = len(rendered.images)
|
| 117 |
+
|
| 118 |
+
# Post-process: replace image references with alt-text placeholders
|
| 119 |
+
markdown_text = self._replace_images_with_placeholders(
|
| 120 |
+
markdown_text, metadata
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Count structural elements from the markdown itself
|
| 124 |
+
element_counts = self._count_markdown_elements(markdown_text)
|
| 125 |
+
metadata.update(element_counts)
|
| 126 |
+
|
| 127 |
+
# Count tables and equations from the markdown
|
| 128 |
+
metadata["tables_detected"] = markdown_text.count("|---|") + markdown_text.count("| ---")
|
| 129 |
+
metadata["equations_detected"] = (
|
| 130 |
+
markdown_text.count("$$") // 2 + markdown_text.count("\\(")
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
logger.info(
|
| 134 |
+
f"[Marker] Success: {file_path.name} — "
|
| 135 |
+
f"{metadata['word_count']} words, "
|
| 136 |
+
f"{metadata['page_count']} pages"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
return markdown_text, metadata
|
| 140 |
+
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.error(f"[Marker] Conversion failed for {file_path.name}: {e}")
|
| 143 |
+
raise RuntimeError(f"Marker conversion failed: {e}") from e
|
| 144 |
+
|
| 145 |
+
def _replace_images_with_placeholders(
|
| 146 |
+
self, markdown: str, metadata: dict
|
| 147 |
+
) -> str:
|
| 148 |
+
"""
|
| 149 |
+
Replace image references with descriptive alt-text placeholders.
|
| 150 |
+
|
| 151 |
+
Converts patterns like:
|
| 152 |
+

|
| 153 |
+

|
| 154 |
+
To:
|
| 155 |
+
![Image: figure/diagram detected on this page]()
|
| 156 |
+
"""
|
| 157 |
+
import re
|
| 158 |
+
|
| 159 |
+
image_count = 0
|
| 160 |
+
|
| 161 |
+
def _replace_match(match: re.Match) -> str:
|
| 162 |
+
nonlocal image_count
|
| 163 |
+
image_count += 1
|
| 164 |
+
alt_text = match.group(1).strip()
|
| 165 |
+
if alt_text and alt_text.lower() not in ("image", "img", "figure"):
|
| 166 |
+
# Keep meaningful alt text
|
| 167 |
+
return f"![Image: {alt_text}]()"
|
| 168 |
+
return f"![Image: figure/diagram #{image_count} detected in document]()"
|
| 169 |
+
|
| 170 |
+
# Match markdown image syntax: 
|
| 171 |
+
markdown = re.sub(
|
| 172 |
+
r"!\[([^\]]*)\]\([^\)]+\)",
|
| 173 |
+
_replace_match,
|
| 174 |
+
markdown,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
metadata["images_detected"] = max(metadata.get("images_detected", 0), image_count)
|
| 178 |
+
|
| 179 |
+
return markdown
|
warraqa/engines/pdf_pymupdf.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PyMuPDF4LLM PDF Engine
|
| 3 |
+
|
| 4 |
+
Fallback PDF conversion engine — fast, lightweight, CPU-only.
|
| 5 |
+
Uses PyMuPDF's LLM-optimized markdown export for text-heavy documents.
|
| 6 |
+
No ML model loading means instant startup and minimal resource usage.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from warraqa.engines.base import BaseEngine
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("warraqa.engines.pymupdf")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PyMuPDFEngine(BaseEngine):
|
| 21 |
+
"""Fast, lightweight PDF to Markdown converter using PyMuPDF4LLM."""
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def name(self) -> str:
|
| 25 |
+
return "PyMuPDF"
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def supported_types(self) -> list[str]:
|
| 29 |
+
return ["pdf"]
|
| 30 |
+
|
| 31 |
+
def is_available(self) -> bool:
|
| 32 |
+
"""Check if pymupdf4llm is installed."""
|
| 33 |
+
try:
|
| 34 |
+
import pymupdf4llm # noqa: F401
|
| 35 |
+
return True
|
| 36 |
+
except ImportError:
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
def convert(self, file_path: Path) -> tuple[str, dict[str, Any]]:
|
| 40 |
+
"""
|
| 41 |
+
Convert a PDF file to Markdown using PyMuPDF4LLM.
|
| 42 |
+
|
| 43 |
+
This is a fast, single-function conversion that works best on
|
| 44 |
+
text-heavy, simple-layout PDFs. It does not use ML models.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
file_path: Path to the PDF file.
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
(markdown_text, metadata) tuple.
|
| 51 |
+
"""
|
| 52 |
+
self._validate_input(file_path)
|
| 53 |
+
|
| 54 |
+
if not self.is_available():
|
| 55 |
+
raise RuntimeError(
|
| 56 |
+
"PyMuPDF4LLM is not installed. Run: pip install pymupdf4llm"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
logger.info(f"[PyMuPDF] Converting: {file_path.name}")
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
import pymupdf # fitz
|
| 63 |
+
import pymupdf4llm
|
| 64 |
+
|
| 65 |
+
# Get page count first
|
| 66 |
+
doc = pymupdf.open(str(file_path))
|
| 67 |
+
page_count = len(doc)
|
| 68 |
+
doc.close()
|
| 69 |
+
|
| 70 |
+
# Convert to markdown — single function call
|
| 71 |
+
markdown_text = pymupdf4llm.to_markdown(
|
| 72 |
+
str(file_path),
|
| 73 |
+
write_images=False, # We use alt-text placeholders, not image files
|
| 74 |
+
show_progress=False,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Post-process: replace any image references with placeholders
|
| 78 |
+
markdown_text = self._replace_images_with_placeholders(markdown_text)
|
| 79 |
+
|
| 80 |
+
# Build metadata
|
| 81 |
+
metadata: dict[str, Any] = {
|
| 82 |
+
"page_count": page_count,
|
| 83 |
+
"images_detected": 0,
|
| 84 |
+
"tables_detected": 0,
|
| 85 |
+
"equations_detected": 0,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# Count structural elements
|
| 89 |
+
element_counts = self._count_markdown_elements(markdown_text)
|
| 90 |
+
metadata.update(element_counts)
|
| 91 |
+
|
| 92 |
+
logger.info(
|
| 93 |
+
f"[PyMuPDF] Success: {file_path.name} — "
|
| 94 |
+
f"{metadata['word_count']} words, "
|
| 95 |
+
f"{page_count} pages"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return markdown_text, metadata
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"[PyMuPDF] Conversion failed for {file_path.name}: {e}")
|
| 102 |
+
raise RuntimeError(f"PyMuPDF conversion failed: {e}") from e
|
| 103 |
+
|
| 104 |
+
def _replace_images_with_placeholders(self, markdown: str) -> str:
|
| 105 |
+
"""Replace any image references with alt-text placeholders."""
|
| 106 |
+
import re
|
| 107 |
+
|
| 108 |
+
image_count = 0
|
| 109 |
+
|
| 110 |
+
def _replace_match(match: re.Match) -> str:
|
| 111 |
+
nonlocal image_count
|
| 112 |
+
image_count += 1
|
| 113 |
+
alt_text = match.group(1).strip()
|
| 114 |
+
if alt_text and alt_text.lower() not in ("image", "img", "figure", ""):
|
| 115 |
+
return f"![Image: {alt_text}]()"
|
| 116 |
+
return f"![Image: figure/diagram #{image_count} detected in document]()"
|
| 117 |
+
|
| 118 |
+
markdown = re.sub(
|
| 119 |
+
r"!\[([^\]]*)\]\([^\)]+\)",
|
| 120 |
+
_replace_match,
|
| 121 |
+
markdown,
|
| 122 |
+
)
|
| 123 |
+
return markdown
|
warraqa/models.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warraqa Data Models
|
| 3 |
+
|
| 4 |
+
Core data structures used throughout the Warraqa agent for representing
|
| 5 |
+
file information, conversion results, and batch reports.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class FileInfo:
|
| 18 |
+
"""Metadata about an input file to be converted."""
|
| 19 |
+
|
| 20 |
+
path: Path
|
| 21 |
+
file_type: str # "pdf", "docx", "doc"
|
| 22 |
+
size_bytes: int
|
| 23 |
+
mime_type: str
|
| 24 |
+
filename: str = ""
|
| 25 |
+
|
| 26 |
+
def __post_init__(self):
|
| 27 |
+
self.path = Path(self.path)
|
| 28 |
+
if not self.filename:
|
| 29 |
+
self.filename = self.path.name
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class ConversionResult:
|
| 34 |
+
"""Complete result of a single file conversion."""
|
| 35 |
+
|
| 36 |
+
# Source information
|
| 37 |
+
source_file: FileInfo
|
| 38 |
+
|
| 39 |
+
# Output
|
| 40 |
+
output_path: Path | None = None
|
| 41 |
+
markdown_content: str = ""
|
| 42 |
+
|
| 43 |
+
# Quality assessment
|
| 44 |
+
confidence_score: int = 0
|
| 45 |
+
grade: str = "F"
|
| 46 |
+
|
| 47 |
+
# Engine details
|
| 48 |
+
engine_used: str = ""
|
| 49 |
+
fallback_used: bool = False
|
| 50 |
+
defer_to_ocr: bool = False # Phase-1 batch flag: poor PyMuPDF result, defer to Phase 2 OCR
|
| 51 |
+
|
| 52 |
+
# Performance
|
| 53 |
+
processing_time_seconds: float = 0.0
|
| 54 |
+
|
| 55 |
+
# Issues
|
| 56 |
+
warnings: list[str] = field(default_factory=list)
|
| 57 |
+
error: str | None = None
|
| 58 |
+
|
| 59 |
+
# Metadata from the engine
|
| 60 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 61 |
+
|
| 62 |
+
# Timestamp
|
| 63 |
+
timestamp: datetime = field(default_factory=datetime.now)
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def success(self) -> bool:
|
| 67 |
+
"""Whether the conversion was successful (not failed)."""
|
| 68 |
+
return self.error is None and self.confidence_score >= 40
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def moved_to(self) -> str:
|
| 72 |
+
"""Where the original file was moved to."""
|
| 73 |
+
return "processed" if self.success else "failed"
|
| 74 |
+
|
| 75 |
+
def to_report_dict(self) -> dict[str, Any]:
|
| 76 |
+
"""Serialize to a JSON-compatible dictionary for reporting."""
|
| 77 |
+
return {
|
| 78 |
+
"agent": "Warraqa v1.0.0",
|
| 79 |
+
"timestamp": self.timestamp.isoformat(),
|
| 80 |
+
"source": {
|
| 81 |
+
"filename": self.source_file.filename,
|
| 82 |
+
"path": str(self.source_file.path),
|
| 83 |
+
"type": self.source_file.file_type,
|
| 84 |
+
"size_bytes": self.source_file.size_bytes,
|
| 85 |
+
"mime_type": self.source_file.mime_type,
|
| 86 |
+
},
|
| 87 |
+
"result": {
|
| 88 |
+
"output_path": str(self.output_path) if self.output_path else None,
|
| 89 |
+
"engine_used": self.engine_used,
|
| 90 |
+
"fallback_used": self.fallback_used,
|
| 91 |
+
"confidence_score": self.confidence_score,
|
| 92 |
+
"grade": self.grade,
|
| 93 |
+
"processing_time_seconds": round(self.processing_time_seconds, 2),
|
| 94 |
+
"warnings": self.warnings,
|
| 95 |
+
"error": self.error,
|
| 96 |
+
"metadata": self.metadata,
|
| 97 |
+
},
|
| 98 |
+
"moved_to": self.moved_to,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class BatchReport:
|
| 104 |
+
"""Summary of a batch conversion run."""
|
| 105 |
+
|
| 106 |
+
results: list[ConversionResult] = field(default_factory=list)
|
| 107 |
+
start_time: datetime = field(default_factory=datetime.now)
|
| 108 |
+
end_time: datetime | None = None
|
| 109 |
+
|
| 110 |
+
@property
|
| 111 |
+
def total_files(self) -> int:
|
| 112 |
+
return len(self.results)
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def successful(self) -> int:
|
| 116 |
+
return sum(1 for r in self.results if r.success)
|
| 117 |
+
|
| 118 |
+
@property
|
| 119 |
+
def failed(self) -> int:
|
| 120 |
+
return sum(1 for r in self.results if not r.success)
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def average_score(self) -> float:
|
| 124 |
+
if not self.results:
|
| 125 |
+
return 0.0
|
| 126 |
+
return sum(r.confidence_score for r in self.results) / len(self.results)
|
| 127 |
+
|
| 128 |
+
@property
|
| 129 |
+
def average_time(self) -> float:
|
| 130 |
+
if not self.results:
|
| 131 |
+
return 0.0
|
| 132 |
+
return sum(r.processing_time_seconds for r in self.results) / len(self.results)
|
| 133 |
+
|
| 134 |
+
@property
|
| 135 |
+
def total_time_seconds(self) -> float:
|
| 136 |
+
if self.end_time and self.start_time:
|
| 137 |
+
return (self.end_time - self.start_time).total_seconds()
|
| 138 |
+
return sum(r.processing_time_seconds for r in self.results)
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def grade_distribution(self) -> dict[str, int]:
|
| 142 |
+
dist: dict[str, int] = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
|
| 143 |
+
for r in self.results:
|
| 144 |
+
if r.grade in dist:
|
| 145 |
+
dist[r.grade] += 1
|
| 146 |
+
return dist
|
| 147 |
+
|
| 148 |
+
def summary(self) -> dict[str, Any]:
|
| 149 |
+
"""Generate a summary dictionary for the batch."""
|
| 150 |
+
return {
|
| 151 |
+
"total_files": self.total_files,
|
| 152 |
+
"successful": self.successful,
|
| 153 |
+
"failed": self.failed,
|
| 154 |
+
"average_score": round(self.average_score, 1),
|
| 155 |
+
"average_time_seconds": round(self.average_time, 2),
|
| 156 |
+
"total_time_seconds": round(self.total_time_seconds, 2),
|
| 157 |
+
"grade_distribution": self.grade_distribution,
|
| 158 |
+
}
|
warraqa/scoring/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Warraqa quality scoring — multi-dimensional conversion quality assessment."""
|
| 2 |
+
|
| 3 |
+
from warraqa.scoring.scorer import ConversionScorer # noqa: F401
|
| 4 |
+
|
| 5 |
+
__all__ = ["ConversionScorer"]
|
warraqa/scoring/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (353 Bytes). View file
|
|
|
warraqa/scoring/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (345 Bytes). View file
|
|
|
warraqa/scoring/__pycache__/scorer.cpython-313.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
warraqa/scoring/__pycache__/scorer.cpython-314.pyc
ADDED
|
Binary file (16.3 kB). View file
|
|
|
warraqa/scoring/scorer.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warraqa Conversion Scorer
|
| 3 |
+
|
| 4 |
+
Multi-dimensional quality assessment engine that evaluates conversions
|
| 5 |
+
across 5 weighted dimensions: completeness, structure, encoding, density, readability.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import re
|
| 12 |
+
import unicodedata
|
| 13 |
+
from collections import Counter
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from warraqa.models import FileInfo
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("warraqa.scoring")
|
| 19 |
+
|
| 20 |
+
EXPECTED_WORDS_PER_KB = {"pdf": 40.0, "docx": 80.0, "doc": 30.0}
|
| 21 |
+
|
| 22 |
+
GARBLED_PATTERNS = [
|
| 23 |
+
r"[□■◆◇►◄▲▼●○★☆♠♣♥♦]{3,}",
|
| 24 |
+
r"[\ufffd]{2,}",
|
| 25 |
+
r"[^\x00-\x7F]{10,}",
|
| 26 |
+
r"(?:[\x80-\x9f]){3,}",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
GRADE_MAP = [(90, "A"), (75, "B"), (60, "C"), (40, "D"), (0, "F")]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ConversionScorer:
|
| 33 |
+
"""Scores document-to-Markdown conversions on a 0-100 scale with A-F grades."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, fail_threshold: int = 40, warn_threshold: int = 70):
|
| 36 |
+
self.fail_threshold = fail_threshold
|
| 37 |
+
self.warn_threshold = warn_threshold
|
| 38 |
+
|
| 39 |
+
def score(
|
| 40 |
+
self, markdown: str, source_info: FileInfo, metadata: dict[str, Any] | None = None
|
| 41 |
+
) -> tuple[int, str, list[str]]:
|
| 42 |
+
"""Score a conversion. Returns (score, grade, warnings)."""
|
| 43 |
+
if metadata is None:
|
| 44 |
+
metadata = {}
|
| 45 |
+
warnings: list[str] = []
|
| 46 |
+
|
| 47 |
+
if not markdown or not markdown.strip():
|
| 48 |
+
warnings.append("Conversion produced empty output")
|
| 49 |
+
return 0, "F", warnings
|
| 50 |
+
|
| 51 |
+
completeness = self._score_text_completeness(markdown, source_info, metadata, warnings)
|
| 52 |
+
structure = self._score_structure_integrity(markdown, metadata, warnings)
|
| 53 |
+
encoding = self._score_encoding_quality(markdown, warnings)
|
| 54 |
+
density = self._score_content_density(markdown, warnings)
|
| 55 |
+
readability = self._score_readability(markdown, warnings)
|
| 56 |
+
|
| 57 |
+
composite = (
|
| 58 |
+
completeness * 0.30 + structure * 0.25 + encoding * 0.20
|
| 59 |
+
+ density * 0.15 + readability * 0.10
|
| 60 |
+
)
|
| 61 |
+
final_score = max(0, min(100, round(composite)))
|
| 62 |
+
grade = self._score_to_grade(final_score)
|
| 63 |
+
|
| 64 |
+
if final_score < self.fail_threshold:
|
| 65 |
+
warnings.insert(0, f"Score {final_score} below fail threshold ({self.fail_threshold})")
|
| 66 |
+
elif final_score < self.warn_threshold:
|
| 67 |
+
warnings.insert(0, f"Score {final_score} below warning threshold ({self.warn_threshold})")
|
| 68 |
+
|
| 69 |
+
logger.debug(
|
| 70 |
+
f"Scoring: Completeness={completeness:.0f} Structure={structure:.0f} "
|
| 71 |
+
f"Encoding={encoding:.0f} Density={density:.0f} Readability={readability:.0f} "
|
| 72 |
+
f"→ {final_score} ({grade})"
|
| 73 |
+
)
|
| 74 |
+
return final_score, grade, warnings
|
| 75 |
+
|
| 76 |
+
def _score_text_completeness(
|
| 77 |
+
self, markdown: str, source_info: FileInfo, metadata: dict, warnings: list[str]
|
| 78 |
+
) -> float:
|
| 79 |
+
"""Text Completeness (30%): word count vs expected density for file size."""
|
| 80 |
+
word_count = len(markdown.split())
|
| 81 |
+
if word_count < 10:
|
| 82 |
+
warnings.append(f"Extremely short output: only {word_count} words")
|
| 83 |
+
return 5.0
|
| 84 |
+
if word_count < 50:
|
| 85 |
+
warnings.append(f"Very short output: only {word_count} words")
|
| 86 |
+
return 20.0
|
| 87 |
+
|
| 88 |
+
file_size_kb = max(source_info.size_bytes / 1024, 0.001)
|
| 89 |
+
expected_density = EXPECTED_WORDS_PER_KB.get(source_info.file_type, 40.0)
|
| 90 |
+
expected_words = max(file_size_kb * expected_density, 1)
|
| 91 |
+
ratio = word_count / expected_words
|
| 92 |
+
|
| 93 |
+
if ratio >= 0.8:
|
| 94 |
+
return 100.0
|
| 95 |
+
elif ratio >= 0.5:
|
| 96 |
+
return 70.0 + (ratio - 0.5) * 100.0
|
| 97 |
+
elif ratio >= 0.2:
|
| 98 |
+
return 30.0 + (ratio - 0.2) * 133.3
|
| 99 |
+
elif ratio >= 0.05:
|
| 100 |
+
warnings.append(
|
| 101 |
+
f"Word count ({word_count}) low for file size ({file_size_kb:.0f} KB)"
|
| 102 |
+
)
|
| 103 |
+
return 10.0 + (ratio - 0.05) * 133.3
|
| 104 |
+
else:
|
| 105 |
+
warnings.append(
|
| 106 |
+
f"Word count ({word_count}) very low for file size ({file_size_kb:.0f} KB)"
|
| 107 |
+
)
|
| 108 |
+
return ratio * 200.0
|
| 109 |
+
|
| 110 |
+
def _score_structure_integrity(
|
| 111 |
+
self, markdown: str, metadata: dict, warnings: list[str]
|
| 112 |
+
) -> float:
|
| 113 |
+
"""Structure Integrity (25%): presence of headings, lists, tables, formatting."""
|
| 114 |
+
lines = markdown.split("\n")
|
| 115 |
+
total_lines = len(lines)
|
| 116 |
+
if total_lines == 0:
|
| 117 |
+
return 0.0
|
| 118 |
+
|
| 119 |
+
heading_count = sum(1 for ln in lines if ln.strip().startswith("#"))
|
| 120 |
+
list_count = sum(
|
| 121 |
+
1 for ln in lines
|
| 122 |
+
if ln.strip().startswith(("- ", "* ", "+ "))
|
| 123 |
+
or (len(ln.strip()) > 2 and ln.strip()[0].isdigit() and ". " in ln.strip()[:5])
|
| 124 |
+
)
|
| 125 |
+
table_rows = sum(
|
| 126 |
+
1 for ln in lines
|
| 127 |
+
if ln.strip().startswith("|") and ln.strip().endswith("|")
|
| 128 |
+
)
|
| 129 |
+
bold_italic = len(re.findall(r"\*\*[^*]+\*\*|\*[^*]+\*|__[^_]+__|_[^_]+_", markdown))
|
| 130 |
+
code_blocks = markdown.count("```")
|
| 131 |
+
|
| 132 |
+
structure_elements = heading_count + list_count + table_rows + bold_italic + code_blocks
|
| 133 |
+
word_count = len(markdown.split())
|
| 134 |
+
|
| 135 |
+
if word_count < 50:
|
| 136 |
+
return 80.0 if structure_elements > 0 else 50.0
|
| 137 |
+
|
| 138 |
+
structure_ratio = (structure_elements / word_count) * 100
|
| 139 |
+
if structure_ratio >= 2.0:
|
| 140 |
+
score = 100.0
|
| 141 |
+
elif structure_ratio >= 1.0:
|
| 142 |
+
score = 80.0
|
| 143 |
+
elif structure_ratio >= 0.5:
|
| 144 |
+
score = 65.0
|
| 145 |
+
elif structure_ratio >= 0.1:
|
| 146 |
+
score = 45.0
|
| 147 |
+
else:
|
| 148 |
+
score = 25.0
|
| 149 |
+
if word_count > 200:
|
| 150 |
+
warnings.append("Output is a wall of plain text with minimal formatting")
|
| 151 |
+
|
| 152 |
+
if heading_count >= 3:
|
| 153 |
+
score = min(100.0, score + 10.0)
|
| 154 |
+
elif heading_count >= 1:
|
| 155 |
+
score = min(100.0, score + 5.0)
|
| 156 |
+
return score
|
| 157 |
+
|
| 158 |
+
def _score_encoding_quality(self, markdown: str, warnings: list[str]) -> float:
|
| 159 |
+
"""Encoding Quality (20%): garbled chars, mojibake, broken Unicode."""
|
| 160 |
+
total_chars = len(markdown)
|
| 161 |
+
if total_chars == 0:
|
| 162 |
+
return 0.0
|
| 163 |
+
|
| 164 |
+
issues = 0
|
| 165 |
+
for pattern in GARBLED_PATTERNS:
|
| 166 |
+
issues += len(re.findall(pattern, markdown))
|
| 167 |
+
|
| 168 |
+
replacement_chars = markdown.count("\ufffd")
|
| 169 |
+
if replacement_chars > 0:
|
| 170 |
+
issues += replacement_chars
|
| 171 |
+
warnings.append(f"Found {replacement_chars} Unicode replacement characters")
|
| 172 |
+
|
| 173 |
+
non_printable = sum(
|
| 174 |
+
1 for c in markdown
|
| 175 |
+
if unicodedata.category(c).startswith("C") and c not in "\n\r\t"
|
| 176 |
+
)
|
| 177 |
+
if non_printable > total_chars * 0.01:
|
| 178 |
+
issues += non_printable
|
| 179 |
+
warnings.append(f"Found {non_printable} non-printable characters")
|
| 180 |
+
|
| 181 |
+
alnum_count = sum(1 for c in markdown if c.isalnum() or c.isspace())
|
| 182 |
+
alnum_ratio = alnum_count / total_chars
|
| 183 |
+
if alnum_ratio < 0.5:
|
| 184 |
+
issues += int((0.5 - alnum_ratio) * 100)
|
| 185 |
+
warnings.append(f"Low alphanumeric ratio ({alnum_ratio:.1%})")
|
| 186 |
+
|
| 187 |
+
issue_ratio = issues / total_chars
|
| 188 |
+
if issue_ratio == 0:
|
| 189 |
+
return 100.0
|
| 190 |
+
elif issue_ratio < 0.001:
|
| 191 |
+
return 95.0
|
| 192 |
+
elif issue_ratio < 0.005:
|
| 193 |
+
return 80.0
|
| 194 |
+
elif issue_ratio < 0.01:
|
| 195 |
+
return 65.0
|
| 196 |
+
elif issue_ratio < 0.05:
|
| 197 |
+
return 40.0
|
| 198 |
+
else:
|
| 199 |
+
return 15.0
|
| 200 |
+
|
| 201 |
+
def _score_content_density(self, markdown: str, warnings: list[str]) -> float:
|
| 202 |
+
"""Content Density (15%): meaningful text vs whitespace and noise."""
|
| 203 |
+
lines = markdown.split("\n")
|
| 204 |
+
total_lines = len(lines)
|
| 205 |
+
if total_lines == 0:
|
| 206 |
+
return 0.0
|
| 207 |
+
|
| 208 |
+
blank_lines = sum(1 for ln in lines if not ln.strip())
|
| 209 |
+
blank_ratio = blank_lines / total_lines
|
| 210 |
+
short_lines = sum(1 for ln in lines if 0 < len(ln.strip()) < 3)
|
| 211 |
+
short_ratio = short_lines / total_lines
|
| 212 |
+
|
| 213 |
+
line_counts = Counter(ln.strip() for ln in lines if ln.strip())
|
| 214 |
+
most_common_count = line_counts.most_common(1)[0][1] if line_counts else 0
|
| 215 |
+
repetition_ratio = most_common_count / total_lines if total_lines > 0 else 0
|
| 216 |
+
|
| 217 |
+
score = 100.0
|
| 218 |
+
if blank_ratio > 0.5:
|
| 219 |
+
score -= (blank_ratio - 0.5) * 100
|
| 220 |
+
warnings.append(f"Excessive blank lines: {blank_ratio:.0%} of output is empty")
|
| 221 |
+
elif blank_ratio > 0.3:
|
| 222 |
+
score -= (blank_ratio - 0.3) * 50
|
| 223 |
+
|
| 224 |
+
if short_ratio > 0.3:
|
| 225 |
+
score -= (short_ratio - 0.3) * 80
|
| 226 |
+
warnings.append(f"Many very short lines ({short_ratio:.0%}) — possible OCR artifacts")
|
| 227 |
+
|
| 228 |
+
if repetition_ratio > 0.1 and most_common_count > 5:
|
| 229 |
+
score -= repetition_ratio * 60
|
| 230 |
+
warnings.append(f"Repetitive content: one line appears {most_common_count} times")
|
| 231 |
+
|
| 232 |
+
return max(0.0, min(100.0, score))
|
| 233 |
+
|
| 234 |
+
def _score_readability(self, markdown: str, warnings: list[str]) -> float:
|
| 235 |
+
"""Readability (10%): line length, paragraph structure, whitespace balance."""
|
| 236 |
+
lines = markdown.split("\n")
|
| 237 |
+
non_empty = [ln for ln in lines if ln.strip()]
|
| 238 |
+
if not non_empty:
|
| 239 |
+
return 0.0
|
| 240 |
+
|
| 241 |
+
avg_len = sum(len(ln) for ln in non_empty) / len(non_empty)
|
| 242 |
+
paragraphs, in_para = 0, False
|
| 243 |
+
for line in lines:
|
| 244 |
+
if line.strip():
|
| 245 |
+
if not in_para:
|
| 246 |
+
paragraphs += 1
|
| 247 |
+
in_para = True
|
| 248 |
+
else:
|
| 249 |
+
in_para = False
|
| 250 |
+
|
| 251 |
+
score = 100.0
|
| 252 |
+
total_words = len(markdown.split())
|
| 253 |
+
|
| 254 |
+
if avg_len > 500:
|
| 255 |
+
score -= 30
|
| 256 |
+
warnings.append(f"Very long average line length ({avg_len:.0f} chars)")
|
| 257 |
+
elif avg_len > 300:
|
| 258 |
+
score -= 15
|
| 259 |
+
|
| 260 |
+
if paragraphs < 2 and total_words > 200:
|
| 261 |
+
score -= 20
|
| 262 |
+
warnings.append("Document appears to be a single block of text")
|
| 263 |
+
elif paragraphs < 3 and total_words > 500:
|
| 264 |
+
score -= 10
|
| 265 |
+
|
| 266 |
+
if paragraphs >= 5 and total_words > 200:
|
| 267 |
+
score = min(100.0, score + 10)
|
| 268 |
+
|
| 269 |
+
return max(0.0, min(100.0, score))
|
| 270 |
+
|
| 271 |
+
@staticmethod
|
| 272 |
+
def _score_to_grade(score: int) -> str:
|
| 273 |
+
for threshold, grade in GRADE_MAP:
|
| 274 |
+
if score >= threshold:
|
| 275 |
+
return grade
|
| 276 |
+
return "F"
|
warraqa/utils.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warraqa Utilities
|
| 3 |
+
|
| 4 |
+
File detection, folder picker dialog, path helpers, and logging configuration.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import mimetypes
|
| 11 |
+
import re
|
| 12 |
+
import shutil
|
| 13 |
+
import tkinter as tk
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from tkinter import filedialog
|
| 16 |
+
|
| 17 |
+
from rich.console import Console
|
| 18 |
+
from rich.logging import RichHandler
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("warraqa")
|
| 21 |
+
console = Console()
|
| 22 |
+
|
| 23 |
+
# Supported file extensions and their type mapping
|
| 24 |
+
EXTENSION_TYPE_MAP: dict[str, str] = {
|
| 25 |
+
".pdf": "pdf",
|
| 26 |
+
".docx": "docx",
|
| 27 |
+
".doc": "doc",
|
| 28 |
+
".pptx": "pptx",
|
| 29 |
+
".ppt": "ppt",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# MIME type fallback mapping
|
| 33 |
+
MIME_TYPE_MAP: dict[str, str] = {
|
| 34 |
+
"application/pdf": "pdf",
|
| 35 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
| 36 |
+
"application/msword": "doc",
|
| 37 |
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
| 38 |
+
"application/vnd.ms-powerpoint": "ppt",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def detect_file_type(path: Path) -> str | None:
|
| 43 |
+
"""
|
| 44 |
+
Detect file type using extension first, then MIME type as fallback.
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
File type string ("pdf", "docx", "doc") or None if unsupported.
|
| 48 |
+
"""
|
| 49 |
+
# Try extension first (most reliable for common formats)
|
| 50 |
+
ext = path.suffix.lower()
|
| 51 |
+
if ext in EXTENSION_TYPE_MAP:
|
| 52 |
+
return EXTENSION_TYPE_MAP[ext]
|
| 53 |
+
|
| 54 |
+
# Fallback to MIME type detection
|
| 55 |
+
mime_type, _ = mimetypes.guess_type(str(path))
|
| 56 |
+
if mime_type and mime_type in MIME_TYPE_MAP:
|
| 57 |
+
return MIME_TYPE_MAP[mime_type]
|
| 58 |
+
|
| 59 |
+
# Try python-magic if available
|
| 60 |
+
try:
|
| 61 |
+
import magic # type: ignore
|
| 62 |
+
|
| 63 |
+
mime_type = magic.from_file(str(path), mime=True)
|
| 64 |
+
if mime_type in MIME_TYPE_MAP:
|
| 65 |
+
return MIME_TYPE_MAP[mime_type]
|
| 66 |
+
except ImportError:
|
| 67 |
+
pass
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.debug(f"python-magic detection failed for {path}: {e}")
|
| 70 |
+
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_mime_type(path: Path) -> str:
|
| 75 |
+
"""Get the MIME type of a file."""
|
| 76 |
+
# Try python-magic first for accuracy
|
| 77 |
+
try:
|
| 78 |
+
import magic # type: ignore
|
| 79 |
+
|
| 80 |
+
return magic.from_file(str(path), mime=True)
|
| 81 |
+
except (ImportError, Exception):
|
| 82 |
+
pass
|
| 83 |
+
|
| 84 |
+
# Fallback to mimetypes module
|
| 85 |
+
mime_type, _ = mimetypes.guess_type(str(path))
|
| 86 |
+
return mime_type or "application/octet-stream"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def open_folder_picker(title: str = "Select Input Folder") -> Path | None:
|
| 90 |
+
"""
|
| 91 |
+
Open a native folder picker dialog.
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
Selected folder path, or None if cancelled.
|
| 95 |
+
"""
|
| 96 |
+
root = tk.Tk()
|
| 97 |
+
root.withdraw() # Hide the root window
|
| 98 |
+
root.attributes("-topmost", True) # Bring dialog to front
|
| 99 |
+
|
| 100 |
+
folder_path = filedialog.askdirectory(title=title)
|
| 101 |
+
|
| 102 |
+
root.destroy()
|
| 103 |
+
|
| 104 |
+
if folder_path:
|
| 105 |
+
return Path(folder_path)
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def ensure_directories(*dirs: Path | str) -> None:
|
| 110 |
+
"""Create directories if they don't exist."""
|
| 111 |
+
for d in dirs:
|
| 112 |
+
Path(d).mkdir(parents=True, exist_ok=True)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def safe_move(src: Path, dest_dir: Path) -> Path:
|
| 116 |
+
"""
|
| 117 |
+
Move a file to a destination directory, handling name collisions.
|
| 118 |
+
|
| 119 |
+
If a file with the same name already exists in dest_dir, appends
|
| 120 |
+
_1, _2, etc. to the filename.
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
The final destination path.
|
| 124 |
+
"""
|
| 125 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
dest = dest_dir / src.name
|
| 127 |
+
|
| 128 |
+
if dest.exists():
|
| 129 |
+
stem = src.stem
|
| 130 |
+
suffix = src.suffix
|
| 131 |
+
counter = 1
|
| 132 |
+
while dest.exists():
|
| 133 |
+
dest = dest_dir / f"{stem}_{counter}{suffix}"
|
| 134 |
+
counter += 1
|
| 135 |
+
|
| 136 |
+
shutil.move(str(src), str(dest))
|
| 137 |
+
logger.debug(f"Moved: {src.name} → {dest}")
|
| 138 |
+
return dest
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def sanitize_filename(name: str) -> str:
|
| 142 |
+
"""
|
| 143 |
+
Clean a filename for use as a markdown output filename.
|
| 144 |
+
|
| 145 |
+
Removes special characters, replaces spaces with underscores,
|
| 146 |
+
and ensures the name is filesystem-safe.
|
| 147 |
+
"""
|
| 148 |
+
# Remove file extension if present
|
| 149 |
+
name = Path(name).stem
|
| 150 |
+
|
| 151 |
+
# Replace common separators with underscores
|
| 152 |
+
name = re.sub(r"[\s\-]+", "_", name)
|
| 153 |
+
|
| 154 |
+
# Remove characters that are problematic on filesystems
|
| 155 |
+
name = re.sub(r"[^\w\.\-]", "", name)
|
| 156 |
+
|
| 157 |
+
# Collapse multiple underscores
|
| 158 |
+
name = re.sub(r"_+", "_", name)
|
| 159 |
+
|
| 160 |
+
# Strip leading/trailing underscores
|
| 161 |
+
name = name.strip("_")
|
| 162 |
+
|
| 163 |
+
# Ensure we have something
|
| 164 |
+
if not name:
|
| 165 |
+
name = "untitled"
|
| 166 |
+
|
| 167 |
+
return name
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def find_supported_files(directory: Path) -> list[Path]:
|
| 171 |
+
"""
|
| 172 |
+
Find all supported files in a directory (non-recursive).
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
List of paths to supported files, sorted alphabetically.
|
| 176 |
+
"""
|
| 177 |
+
supported = []
|
| 178 |
+
if not directory.is_dir():
|
| 179 |
+
logger.warning(f"Not a directory: {directory}")
|
| 180 |
+
return supported
|
| 181 |
+
|
| 182 |
+
for file_path in sorted(directory.iterdir()):
|
| 183 |
+
if file_path.is_file() and file_path.suffix.lower() in EXTENSION_TYPE_MAP:
|
| 184 |
+
supported.append(file_path)
|
| 185 |
+
|
| 186 |
+
return supported
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
|
| 190 |
+
"""
|
| 191 |
+
Configure structured logging with rich console output.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
| 195 |
+
log_file: Optional path to a log file
|
| 196 |
+
"""
|
| 197 |
+
log_level = getattr(logging, level.upper(), logging.INFO)
|
| 198 |
+
|
| 199 |
+
# Configure root warraqa logger
|
| 200 |
+
warraqa_logger = logging.getLogger("warraqa")
|
| 201 |
+
warraqa_logger.setLevel(log_level)
|
| 202 |
+
|
| 203 |
+
# Clear existing handlers
|
| 204 |
+
warraqa_logger.handlers.clear()
|
| 205 |
+
|
| 206 |
+
# Rich console handler
|
| 207 |
+
rich_handler = RichHandler(
|
| 208 |
+
console=console,
|
| 209 |
+
show_time=True,
|
| 210 |
+
show_path=False,
|
| 211 |
+
markup=True,
|
| 212 |
+
rich_tracebacks=True,
|
| 213 |
+
)
|
| 214 |
+
rich_handler.setLevel(log_level)
|
| 215 |
+
rich_handler.setFormatter(logging.Formatter("%(message)s"))
|
| 216 |
+
warraqa_logger.addHandler(rich_handler)
|
| 217 |
+
|
| 218 |
+
# File handler (if specified)
|
| 219 |
+
if log_file:
|
| 220 |
+
log_path = Path(log_file)
|
| 221 |
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 222 |
+
file_handler = logging.FileHandler(str(log_path), encoding="utf-8")
|
| 223 |
+
file_handler.setLevel(log_level)
|
| 224 |
+
file_handler.setFormatter(
|
| 225 |
+
logging.Formatter(
|
| 226 |
+
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
| 227 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 228 |
+
)
|
| 229 |
+
)
|
| 230 |
+
warraqa_logger.addHandler(file_handler)
|
| 231 |
+
|
| 232 |
+
warraqa_logger.debug("Logging initialized at level %s", level)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def format_file_size(size_bytes: int) -> str:
|
| 236 |
+
"""Format a file size in bytes to a human-readable string."""
|
| 237 |
+
for unit in ("B", "KB", "MB", "GB"):
|
| 238 |
+
if size_bytes < 1024:
|
| 239 |
+
return f"{size_bytes:.1f} {unit}"
|
| 240 |
+
size_bytes /= 1024 # type: ignore
|
| 241 |
+
return f"{size_bytes:.1f} TB"
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# Unpaired-surrogate range — invalid in well-formed Unicode; appear in engine
|
| 245 |
+
# output when a PDF has malformed text encodings or custom font cmaps that
|
| 246 |
+
# don't round-trip cleanly. Python's UTF-8 encoder refuses them, which
|
| 247 |
+
# crashes `Path.write_text` and breaks the whole batch.
|
| 248 |
+
_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def sanitize_markdown(text: str) -> tuple[str, int]:
|
| 252 |
+
"""
|
| 253 |
+
Replace lone Unicode surrogates with U+FFFD (replacement char).
|
| 254 |
+
|
| 255 |
+
Returns a (cleaned_text, replacement_count) tuple so callers can warn
|
| 256 |
+
about how much was sanitized.
|
| 257 |
+
"""
|
| 258 |
+
if not text:
|
| 259 |
+
return text, 0
|
| 260 |
+
count = 0
|
| 261 |
+
|
| 262 |
+
def _sub(_match: re.Match) -> str:
|
| 263 |
+
nonlocal count
|
| 264 |
+
count += 1
|
| 265 |
+
return "�"
|
| 266 |
+
|
| 267 |
+
cleaned = _SURROGATE_RE.sub(_sub, text)
|
| 268 |
+
return cleaned, count
|
| 269 |
+
|
| 270 |
+
class PDFAnalyzer:
|
| 271 |
+
"""Analyzes PDFs to determine structural properties before conversion."""
|
| 272 |
+
|
| 273 |
+
@staticmethod
|
| 274 |
+
def is_native_text(file_path: Path, sample_pages: int = 5, threshold: int = 100) -> bool:
|
| 275 |
+
"""
|
| 276 |
+
Determines if a PDF is natively digital (contains embedded text)
|
| 277 |
+
or if it is a scanned image.
|
| 278 |
+
"""
|
| 279 |
+
try:
|
| 280 |
+
import fitz
|
| 281 |
+
total_chars = 0
|
| 282 |
+
pages_checked = 0
|
| 283 |
+
|
| 284 |
+
with fitz.open(str(file_path)) as doc:
|
| 285 |
+
pages_to_check = min(sample_pages, len(doc))
|
| 286 |
+
|
| 287 |
+
if pages_to_check == 0:
|
| 288 |
+
return False
|
| 289 |
+
|
| 290 |
+
for i in range(pages_to_check):
|
| 291 |
+
page = doc[i]
|
| 292 |
+
text = page.get_text()
|
| 293 |
+
total_chars += len(text.strip())
|
| 294 |
+
pages_checked += 1
|
| 295 |
+
|
| 296 |
+
if pages_checked == 0:
|
| 297 |
+
return False
|
| 298 |
+
|
| 299 |
+
avg_chars_per_page = total_chars / pages_checked
|
| 300 |
+
|
| 301 |
+
if avg_chars_per_page > threshold:
|
| 302 |
+
logger.debug(f"Document classified as NATIVE TEXT (avg {avg_chars_per_page:.0f} chars/page).")
|
| 303 |
+
return True
|
| 304 |
+
else:
|
| 305 |
+
logger.debug(f"Document classified as SCANNED IMAGE (avg {avg_chars_per_page:.0f} chars/page).")
|
| 306 |
+
return False
|
| 307 |
+
|
| 308 |
+
except Exception as e:
|
| 309 |
+
logger.warning(f"Failed to analyze PDF {file_path.name}: {e}. Defaulting to SCANNED.")
|
| 310 |
+
return False
|