""" 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}'"])