Files
meetus/doocus/extractors/office.py
Mariano Gabriel 7b276e8f3d viewers
2026-07-05 11:26:45 -03:00

147 lines
5.3 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
# Generic core-property titles Office writes by default — worse than useless as a
# display title (they hide the real one), so we drop them and derive from content.
PLACEHOLDER_TITLES = {
"word document", "powerpoint presentation", "excel workbook",
"microsoft word document", "microsoft powerpoint presentation",
"microsoft excel worksheet", "presentation", "workbook", "document",
}
def _good_title(t) -> bool:
return bool(t) and str(t).strip().lower() not in PLACEHOLDER_TITLES
def _first_line(text: str, limit: int = 120) -> str:
for ln in text.splitlines():
s = ln.lstrip("#").strip()
if s:
return s[:limit]
return ""
def _core_props(props) -> dict:
"""Common OOXML core properties → metadata (only non-empty, non-placeholder).
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:
if dst == "title" and not _good_title(val):
continue # skip "Word Document" & friends; derive from content
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()])
if "title" not in metadata:
t = _first_line(content)
if t:
metadata["title"] = t
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)
if "title" not in metadata and prs.slides:
# first slide's title placeholder, if any
for shape in prs.slides[0].shapes:
if shape.has_text_frame and shape.text_frame.text.strip():
metadata["title"] = shape.text_frame.text.strip()[:120]
break
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}'"])