#!/usr/bin/env python3
"""Minimal XLSX reader/writer using only the Python standard library."""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree as ET
from zipfile import ZIP_DEFLATED, ZipFile
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {"m": MAIN_NS, "r": REL_NS, "p": PKG_REL_NS}
def _column_number(cell_ref: str) -> int:
letters = re.match(r"([A-Z]+)", cell_ref.upper())
if not letters:
return 0
value = 0
for letter in letters.group(1):
value = value * 26 + ord(letter) - 64
return value
def _column_letter(number: int) -> str:
result = ""
while number:
number, remainder = divmod(number - 1, 26)
result = chr(65 + remainder) + result
return result
def _shared_strings(archive: ZipFile) -> list[str]:
if "xl/sharedStrings.xml" not in archive.namelist():
return []
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
values = []
for item in root.findall("m:si", NS):
values.append("".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t")))
return values
def _relationships(archive: ZipFile) -> dict[str, str]:
root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
return {item.attrib["Id"]: item.attrib["Target"] for item in root}
def _cell_value(cell, shared: list[str]) -> str:
cell_type = cell.attrib.get("t")
inline = cell.find("m:is", NS)
if cell_type == "inlineStr" and inline is not None:
return "".join(node.text or "" for node in inline.iter(f"{{{MAIN_NS}}}t")).strip()
value_node = cell.find("m:v", NS)
raw = "" if value_node is None else (value_node.text or "")
if cell_type == "s" and raw:
return shared[int(raw)].strip()
return raw.strip()
def read_xlsx_rows(path: str | Path) -> list[dict[str, str]]:
"""Read the first worksheet as dictionaries keyed by header text."""
path = Path(path)
with ZipFile(path, "r") as archive:
shared = _shared_strings(archive)
relationships = _relationships(archive)
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
sheets = workbook.findall("m:sheets/m:sheet", NS)
if not sheets:
raise ValueError("Excel 文件中没有工作表")
relationship_id = sheets[0].attrib[f"{{{REL_NS}}}id"]
target = relationships[relationship_id].lstrip("/")
if not target.startswith("xl/"):
target = "xl/" + target
worksheet = ET.fromstring(archive.read(target))
rows: list[dict[int, str]] = []
for row in worksheet.findall("m:sheetData/m:row", NS):
values: dict[int, str] = {}
for cell in row.findall("m:c", NS):
number = _column_number(cell.attrib.get("r", "A1"))
value = _cell_value(cell, shared)
if value != "":
values[number] = value
rows.append(values)
header_row_index = -1
for index, values in enumerate(rows[:20]):
texts = set(values.values())
if "学号" in texts and "姓名" in texts:
header_row_index = index
break
if header_row_index < 0:
raise ValueError("Excel 中必须包含“学号”和“姓名”表头")
headers = rows[header_row_index]
result: list[dict[str, str]] = []
for values in rows[header_row_index + 1:]:
row = {headers.get(number, f"列{number}"): value for number, value in values.items()}
if any(str(value).strip() for value in row.values()):
result.append(row)
return result
def _xml_escape(value: object) -> str:
return (
str(value)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
def _sheet_xml(headers: list[str], records: list[dict[str, object]]) -> str:
all_headers = list(headers)
all_records = list(records)
row_xml: list[str] = []
header_cells = []
for column_number, header in enumerate(all_headers, 1):
reference = f"{_column_letter(column_number)}1"
header_cells.append(
f'