172 lines
5.4 KiB
Python
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}")
|