#!/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'{_xml_escape(header)}' ) row_xml.append(f'{"".join(header_cells)}') for row_number, record in enumerate(all_records, 2): cells = [] for column_number, header in enumerate(all_headers, 1): value = record.get(header, "") reference = f"{_column_letter(column_number)}{row_number}" if isinstance(value, bool): cells.append(f'{_xml_escape("是" if value else "否")}') elif isinstance(value, (int, float)) and not isinstance(value, bool): cells.append(f'{value}') else: text = "" if value is None else str(value) preserve = ' xml:space="preserve"' if text != text.strip() else "" cells.append( f'{_xml_escape(text)}' ) row_xml.append(f'{"".join(cells)}') column_xml = [] for number in range(1, len(all_headers) + 1): column_xml.append(f'') return f''' {"".join(column_xml)} {"".join(row_xml)} ''' def write_xlsx(sheets: list[tuple[str, list[str], list[dict[str, object]]]]) -> bytes: """Create a valid XLSX in memory. Each item is (sheet name, headers, records).""" import posixpath used_names = set() safe_names = [] for index, (name, _, _) in enumerate(sheets, 1): clean = re.sub(r"[\\/?*\\[\\]:]", "-", name).strip() or f"Sheet{index}" clean = clean[:31] candidate = clean suffix = 2 while candidate.lower() in used_names: tail = f"-{suffix}" candidate = clean[: 31 - len(tail)] + tail suffix += 1 used_names.add(candidate.lower()) safe_names.append(candidate) sheet_entries = [] workbook_entries = [] worksheet_files = [] for index, name in enumerate(safe_names, 1): filename = f"xl/worksheets/sheet{index}.xml" worksheet_files.append((filename, _sheet_xml(sheets[index - 1][1], sheets[index - 1][2]))) sheet_entries.append( f'' ) workbook_entries.append( f'' ) style_relationship_id = len(sheets) + 1 content_overrides = "".join( f'' for index in range(1, len(sheets) + 1) ) files = { "[Content_Types].xml": f''' {content_overrides} ''', "_rels/.rels": ''' ''', "xl/workbook.xml": f''' {"".join(sheet_entries)} ''', "xl/_rels/workbook.xml.rels": f''' {"".join(workbook_entries)} ''', "xl/styles.xml": ''' ''', } files.update(worksheet_files) import io output = io.BytesIO() with ZipFile(output, "w", ZIP_DEFLATED) as archive: for filename, content in files.items(): archive.writestr(filename, content.encode("utf-8")) return output.getvalue() def export_filename(prefix: str, class_name: str | None = None) -> str: timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") suffix = f"-{class_name}班" if class_name else "" return f"{prefix}{suffix}-{timestamp}.xlsx"