feat(parser): add OpenDataLoader, PaddleOCR-VL engines, and parser improvements

Introduce opendataloader and PaddleOCR-VL parser engines with tenant-level
settings UI, replace liteparse, and harden Excel/PPT/Markdown parsing.
Optional odl-hybrid sidecar stays local-build only and is excluded from
default dev-start and full profiles.
This commit is contained in:
wizardchen
2026-06-03 12:00:09 +08:00
committed by lyingbug
parent 7b1bb1054f
commit ef1047bf67
50 changed files with 4352 additions and 304 deletions
+94 -4
View File
@@ -13,6 +13,14 @@ import pandas as pd
from docreader.models.document import Chunk, Document
from docreader.parser.base_parser import BaseParser
from docreader.parser.excel_convert import (
convert_excel_to_xlsx_bytes,
detect_excel_format,
engine_for_format,
normalize_excel_bytes,
)
from docreader.parser.xlsx_merge import fill_merged_cells_xlsx
from docreader.parser.xlsx_repair import repair_xlsx_bytes
logger = logging.getLogger(__name__)
@@ -60,13 +68,11 @@ class ExcelParser(BaseParser):
text: List[str] = []
start, end = 0, 0
# Load Excel file from bytes into pandas ExcelFile object
excel_file = pd.ExcelFile(BytesIO(content))
excel_file = _open_excel_file(content, file_type=self.file_type)
# Process each sheet in the Excel file
for excel_sheet_name in excel_file.sheet_names:
# Parse the sheet into a DataFrame
df = excel_file.parse(sheet_name=excel_sheet_name)
df = _read_sheet_dataframe(excel_file, excel_sheet_name)
# Remove rows where all values are NaN (completely empty rows)
df.dropna(how="all", inplace=True)
@@ -97,6 +103,90 @@ class ExcelParser(BaseParser):
return Document(content="".join(text), chunks=chunks)
def _read_sheet_dataframe(excel_file: pd.ExcelFile, sheet_name: str) -> pd.DataFrame:
"""Read a worksheet into a DataFrame with stable column labels."""
from openpyxl.utils import get_column_letter
# XLSX is preprocessed (merge fill); use A/B/C column letters and keep row 1 as data.
if excel_file.engine == "openpyxl":
df = excel_file.parse(sheet_name=sheet_name, header=None)
df.columns = [get_column_letter(idx + 1) for idx in range(len(df.columns))]
return df
df = excel_file.parse(sheet_name=sheet_name, header=0)
if df.empty:
df = excel_file.parse(sheet_name=sheet_name, header=None)
df.columns = [get_column_letter(idx + 1) for idx in range(len(df.columns))]
elif any(str(col).startswith("Unnamed:") for col in df.columns):
df = excel_file.parse(sheet_name=sheet_name, header=None)
df.columns = [get_column_letter(idx + 1) for idx in range(len(df.columns))]
return df
def _prepare_xlsx_bytes(data: bytes) -> bytes:
repaired = repair_xlsx_bytes(data)
if repaired is not None:
data = repaired
return fill_merged_cells_xlsx(data)
def _open_excel_file(content: bytes, file_type: str | None = None) -> pd.ExcelFile:
"""Open an Excel workbook with explicit engine selection and fallbacks."""
data = content
converted_via_soffice = False
while True:
ext = detect_excel_format(data)
if ext is None:
if converted_via_soffice:
raise ValueError(
"Excel file format cannot be determined, you must specify an engine manually."
)
try:
data = normalize_excel_bytes(data, file_type=file_type)
except ValueError as exc:
raise ValueError(
"Excel file format cannot be determined, you must specify an engine manually."
) from exc
converted_via_soffice = True
continue
if ext == "ods":
converted = convert_excel_to_xlsx_bytes(data, suffix=".ods")
if converted:
data = converted
continue
engine = engine_for_format(ext)
if ext == "xlsx":
data = _prepare_xlsx_bytes(data)
engine = "openpyxl"
try:
return pd.ExcelFile(BytesIO(data), engine=engine)
except ImportError as exc:
raise ValueError(
f"Excel engine {engine!r} is not available for .{ext} files"
) from exc
except KeyError as exc:
if "sharedStrings.xml" not in str(exc) or engine != "openpyxl":
raise
repaired = repair_xlsx_bytes(data)
if repaired is None:
raise
logger.info("Repaired XLSX sharedStrings packaging before parse")
data = _prepare_xlsx_bytes(repaired)
continue
except ValueError as exc:
if converted_via_soffice or "cannot be determined" not in str(exc):
raise
try:
data = normalize_excel_bytes(content, file_type=file_type)
except ValueError:
raise
converted_via_soffice = True
continue
if __name__ == "__main__":
# Example usage: Parse an Excel file and display results
logging.basicConfig(level=logging.DEBUG)