Files
2026-07-13 13:08:41 +08:00

172 lines
5.4 KiB
Python

import os
import re
import zipfile
from pathlib import Path
from xml.etree import ElementTree
class OfficeDocumentError(Exception):
pass
_DOCX_EXTENSIONS = {".docx", ".docm"}
_PPTX_EXTENSIONS = {".pptx", ".pptm"}
_XLSX_EXTENSIONS = {".xlsx", ".xlsm"}
_ODF_EXTENSIONS = {".odt", ".odp", ".ods"}
_TEXT_EXTENSIONS = {".csv", ".tsv"}
_UNSUPPORTED_LEGACY_EXTENSIONS = {".doc", ".ppt", ".xls", ".rtf"}
def _natural_key(value: str) -> list[object]:
return [
int(part) if part.isdigit() else part.lower()
for part in re.split(r"(\d+)", value)
]
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _read_xml(archive: zipfile.ZipFile, member: str) -> ElementTree.Element:
try:
return ElementTree.fromstring(archive.read(member))
except (KeyError, ElementTree.ParseError) as exc:
raise OfficeDocumentError(f"Could not read {member}") from exc
def _text_nodes(root: ElementTree.Element) -> list[str]:
return [
value
for element in root.iter()
if _local_name(element.tag) == "t"
and (value := (element.text or "").strip())
]
def _extract_docx(archive: zipfile.ZipFile) -> str:
root = _read_xml(archive, "word/document.xml")
paragraphs: list[str] = []
for paragraph in root.iter():
if _local_name(paragraph.tag) != "p":
continue
text = " ".join(_text_nodes(paragraph))
if text:
paragraphs.append(text)
return "\n".join(paragraphs)
def _extract_pptx(archive: zipfile.ZipFile) -> str:
slide_members = sorted(
(
name
for name in archive.namelist()
if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
),
key=_natural_key,
)
slides = [" ".join(_text_nodes(_read_xml(archive, member))) for member in slide_members]
return "\n\n".join(slide for slide in slides if slide)
def _extract_shared_strings(archive: zipfile.ZipFile) -> list[str]:
if "xl/sharedStrings.xml" not in archive.namelist():
return []
root = _read_xml(archive, "xl/sharedStrings.xml")
return [
" ".join(_text_nodes(item))
for item in root.iter()
if _local_name(item.tag) == "si"
]
def _extract_xlsx(archive: zipfile.ZipFile) -> str:
shared_strings = _extract_shared_strings(archive)
sheet_members = sorted(
(
name
for name in archive.namelist()
if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)
),
key=_natural_key,
)
sheets: list[str] = []
for member in sheet_members:
root = _read_xml(archive, member)
rows: list[str] = []
for row in root.iter():
if _local_name(row.tag) != "row":
continue
values: list[str] = []
for cell in row:
if _local_name(cell.tag) != "c":
continue
cell_type = cell.attrib.get("t")
if cell_type == "inlineStr":
value = " ".join(_text_nodes(cell))
else:
raw_value = next(
(
(element.text or "").strip()
for element in cell
if _local_name(element.tag) == "v"
),
"",
)
if cell_type == "s" and raw_value.isdigit():
index = int(raw_value)
value = (
shared_strings[index]
if index < len(shared_strings)
else raw_value
)
else:
value = raw_value
if value:
values.append(value)
if values:
rows.append("\t".join(values))
if rows:
sheets.append("\n".join(rows))
return "\n\n".join(sheets)
def _extract_odf(archive: zipfile.ZipFile) -> str:
root = _read_xml(archive, "content.xml")
values = [
" ".join(text.strip() for text in element.itertext() if text.strip())
for element in root.iter()
if _local_name(element.tag) in {"p", "h"}
]
return "\n".join(value for value in values if value)
def extract_office_document_text(file_path: str) -> str:
extension = Path(file_path).suffix.lower()
if extension in _TEXT_EXTENSIONS:
with open(file_path, "r", encoding="utf-8", errors="replace") as file:
return file.read()
if extension in _UNSUPPORTED_LEGACY_EXTENSIONS:
raise OfficeDocumentError(
f"{extension} files require an external office conversion engine; "
"save the document in a modern OOXML or OpenDocument format first"
)
try:
with zipfile.ZipFile(file_path) as archive:
if extension in _DOCX_EXTENSIONS:
return _extract_docx(archive)
if extension in _PPTX_EXTENSIONS:
return _extract_pptx(archive)
if extension in _XLSX_EXTENSIONS:
return _extract_xlsx(archive)
if extension in _ODF_EXTENSIONS:
return _extract_odf(archive)
except (OSError, zipfile.BadZipFile) as exc:
raise OfficeDocumentError(
f"Could not parse {os.path.basename(file_path)}"
) from exc
raise OfficeDocumentError(f"Unsupported office document format: {extension}")