115 lines
4.0 KiB
Python
115 lines
4.0 KiB
Python
"""
|
|
Office-family extractor: docx, pptx, xlsx.
|
|
|
|
Each format is handled with its own lazy import so a missing dependency degrades
|
|
only that format. These formats carry core properties (author, created/modified
|
|
dates, title) which go straight into metadata.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from .base import ExtractionResult
|
|
|
|
MAX_SHEET_ROWS = 200
|
|
|
|
|
|
def _core_props(props) -> dict:
|
|
"""Common OOXML core properties → metadata (only non-empty ones).
|
|
|
|
python-docx/pptx expose `.author`/`.last_modified_by`; openpyxl uses
|
|
`.creator`/`.lastModifiedBy`. Each target checks both spellings.
|
|
"""
|
|
# dst -> candidate source attribute names (first non-empty wins)
|
|
fields = {
|
|
"title": ("title",),
|
|
"author": ("author", "creator"),
|
|
"created": ("created",),
|
|
"modified_doc": ("modified",),
|
|
"last_modified_by": ("last_modified_by", "lastModifiedBy"),
|
|
}
|
|
out = {}
|
|
for dst, sources in fields.items():
|
|
for src in sources:
|
|
val = getattr(props, src, None)
|
|
if val:
|
|
out[dst] = str(val)
|
|
break
|
|
return out
|
|
|
|
|
|
def _extract_docx(source: Path) -> ExtractionResult:
|
|
import docx # python-docx
|
|
doc = docx.Document(str(source))
|
|
paras = [p.text for p in doc.paragraphs]
|
|
content = "\n\n".join(p for p in paras if p.strip()) + "\n"
|
|
metadata = _core_props(doc.core_properties)
|
|
metadata["paragraphs"] = len([p for p in paras if p.strip()])
|
|
return ExtractionResult(content=content, metadata=metadata, extractor="office/python-docx@1")
|
|
|
|
|
|
def _extract_pptx(source: Path) -> ExtractionResult:
|
|
from pptx import Presentation
|
|
prs = Presentation(str(source))
|
|
blocks = []
|
|
for i, slide in enumerate(prs.slides, 1):
|
|
texts = []
|
|
for shape in slide.shapes:
|
|
if shape.has_text_frame:
|
|
for para in shape.text_frame.paragraphs:
|
|
line = "".join(run.text for run in para.runs)
|
|
if line.strip():
|
|
texts.append(line)
|
|
blocks.append(f"## Slide {i}\n\n" + "\n".join(texts))
|
|
content = "\n\n".join(blocks) + "\n"
|
|
metadata = _core_props(prs.core_properties)
|
|
metadata["slides"] = len(prs.slides)
|
|
return ExtractionResult(content=content, metadata=metadata, extractor="office/python-pptx@1")
|
|
|
|
|
|
def _extract_xlsx(source: Path) -> ExtractionResult:
|
|
from openpyxl import load_workbook
|
|
wb = load_workbook(str(source), read_only=True, data_only=True)
|
|
warnings = []
|
|
blocks = []
|
|
for ws in wb.worksheets:
|
|
rows = []
|
|
for r, row in enumerate(ws.iter_rows(values_only=True)):
|
|
if r > MAX_SHEET_ROWS:
|
|
warnings.append(f"sheet '{ws.title}' truncated to {MAX_SHEET_ROWS} rows")
|
|
break
|
|
rows.append(["" if c is None else str(c) for c in row])
|
|
blocks.append(f"## {ws.title}\n\n" + _rows_to_md(rows))
|
|
wb.close()
|
|
content = "\n\n".join(blocks) + "\n"
|
|
metadata = _core_props(wb.properties)
|
|
metadata["sheets"] = [ws.title for ws in wb.worksheets]
|
|
return ExtractionResult(content=content, metadata=metadata,
|
|
warnings=warnings, extractor="office/openpyxl@1")
|
|
|
|
|
|
def _rows_to_md(rows: list) -> str:
|
|
if not rows:
|
|
return "_(empty)_\n"
|
|
width = max(len(r) for r in rows)
|
|
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
|
|
def esc(c):
|
|
return str(c).replace("|", "\\|").replace("\n", " ")
|
|
|
|
lines = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
|
|
"| " + " | ".join(["---"] * width) + " |"]
|
|
for r in rows[1:]:
|
|
lines.append("| " + " | ".join(esc(c) for c in r) + " |")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def extract(source: Path, options: dict) -> ExtractionResult:
|
|
ext = source.suffix.lower().lstrip(".")
|
|
if ext == "docx":
|
|
return _extract_docx(source)
|
|
if ext == "pptx":
|
|
return _extract_pptx(source)
|
|
if ext == "xlsx":
|
|
return _extract_xlsx(source)
|
|
return ExtractionResult(extractor="office/unhandled",
|
|
warnings=[f"office extractor got unexpected '.{ext}'"])
|