| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- # -*- coding: utf-8 -*-
- """
- 最小化 .xlsx 读取器(Python 标准库,无需 openpyxl)
- xlsx 是一个 zip 包:
- xl/sharedStrings.xml 共享字符串表
- xl/styles.xml 样式(用于识别日期格式)
- xl/workbook.xml + .rels 工作表清单
- xl/worksheets/sheetN.xml 单元格数据
- 只做读取,返回 rows: [[str, ...], ...](含表头),日期单元格转为 YYYY-MM-DD。
- """
- import io
- import re
- import zipfile
- import xml.etree.ElementTree as ET
- from datetime import datetime, timedelta
- NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
- NS_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
- NS_PKG_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
- # Excel 内置日期格式编号
- BUILTIN_DATE_FMTS = {14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47}
- EPOCH = datetime(1899, 12, 30)
- def _col_index(ref):
- """'BC12' -> 列号 54(0-based)"""
- letters = re.match(r"([A-Z]+)", ref or "")
- if not letters:
- return None
- n = 0
- for ch in letters.group(1):
- n = n * 26 + (ord(ch) - 64)
- return n - 1
- def _local(tag):
- return tag.rsplit("}", 1)[-1]
- def _parse_styles(z):
- """返回 numFmtId 集合中属于日期格式的 id 集合;以及 xf 索引 -> numFmtId 映射"""
- date_fmt_ids = set(BUILTIN_DATE_FMTS)
- xf_map = {}
- if "xl/styles.xml" not in z.namelist():
- return xf_map
- root = ET.fromstring(z.read("xl/styles.xml"))
- custom = {} # id -> 是否日期
- for nf in root.iter(NS + "numFmt"):
- try:
- fid = int(nf.get("numFmtId"))
- except (TypeError, ValueError):
- continue
- code = nf.get("formatCode") or ""
- # 排除纯数字带千分位等:含 y/m/d/h 才算日期
- if re.search(r"[ymdhs]", code, re.I) and not re.match(r"^[\d,#.\s]+$", code):
- custom[fid] = True
- date_fmt_ids.add(fid)
- cellXfs = root.find(NS + "cellXfs")
- if cellXfs is not None:
- for i, xf in enumerate(cellXfs.findall(NS + "xf")):
- try:
- xf_map[i] = int(xf.get("numFmtId") or 0)
- except ValueError:
- xf_map[i] = 0
- # 标记自定义日期格式
- for i, fid in xf_map.items():
- if fid in custom:
- xf_map[i] = -1 # 特殊标记:自定义日期
- return xf_map
- def _is_date_style(xf_map, s_attr):
- if not s_attr:
- return False
- try:
- fid = xf_map.get(int(s_attr))
- except ValueError:
- return False
- return fid in BUILTIN_DATE_FMTS or fid == -1
- def _serial_to_date(v):
- try:
- n = float(v)
- except ValueError:
- return None
- if n <= 0 or n > 60000:
- return None
- d = EPOCH + timedelta(days=n)
- if n % 1 == 0:
- return d.strftime("%Y-%m-%d")
- return d.strftime("%Y-%m-%d") # 时间部分舍弃,日粒度只到天
- def _shared_strings(z):
- if "xl/sharedStrings.xml" not in z.namelist():
- return []
- root = ET.fromstring(z.read("xl/sharedStrings.xml"))
- out = []
- for si in root.findall(NS + "si"):
- # 富文本 run 与纯文本 t 都归并为拼接
- out.append("".join(t.text or "" for t in si.iter(NS + "t")))
- return out
- def _sheet_paths(z):
- """按 workbook.xml 里的顺序返回各 sheet 的 zip 路径"""
- names = z.namelist()
- wb = ET.fromstring(z.read("xl/workbook.xml"))
- rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
- rel_map = {r.get("Id"): r.get("Target") for r in rels.findall(NS_PKG_REL + "Relationship")}
- out = []
- for sh in wb.find(NS + "sheets").findall(NS + "sheet"):
- rid = sh.get(NS_R + "id")
- target = rel_map.get(rid, "")
- target = target.lstrip("/")
- if not target.startswith("xl/"):
- target = "xl/" + target
- if target in names:
- out.append((sh.get("name") or "", target))
- return out
- def _sheet_rows(z, sheet_path, sst, xf_map):
- root = ET.fromstring(z.read(sheet_path))
- rows = []
- for row in root.iter(NS + "row"):
- cells = {}
- max_i = -1
- for c in row.findall(NS + "c"):
- ref = c.get("r") or ""
- i = _col_index(ref)
- t = c.get("t") or ""
- s_attr = c.get("s")
- val = None
- if t == "inlineStr":
- is_el = c.find(NS + "is")
- if is_el is not None:
- val = "".join(x.text or "" for x in is_el.iter(NS + "t"))
- else:
- v_el = c.find(NS + "v")
- raw = v_el.text if v_el is not None else None
- if raw is not None:
- if t == "s":
- try:
- val = sst[int(raw)]
- except (ValueError, IndexError):
- val = raw
- elif t == "b":
- val = "1" if raw.strip() == "1" else "0"
- elif t == "str":
- val = raw
- else:
- # 数字或日期
- if _is_date_style(xf_map, s_attr):
- val = _serial_to_date(raw) or raw
- else:
- val = raw
- if i is None:
- i = max_i + 1
- if val is not None:
- # 4.0 -> 4,去掉多余的 .0
- if re.match(r"^-?\d+\.0+$", val.strip()):
- val = val.strip().split(".")[0]
- cells[i] = val
- max_i = max(max_i, i)
- if cells:
- width = max(cells) + 1
- rows.append([cells.get(i, "") for i in range(width)])
- return rows
- def read_bytes(raw):
- """返回 (rows, sheet_names)。取第一个非空工作表。"""
- try:
- z = zipfile.ZipFile(io.BytesIO(raw))
- except zipfile.BadZipFile:
- raise ValueError("不是有效的 xlsx 文件(可能是旧版 .xls,请另存为 .xlsx)")
- sst = _shared_strings(z)
- xf_map = _parse_styles(z)
- sheets = _sheet_paths(z)
- if not sheets:
- raise ValueError("xlsx 中没有工作表")
- for name, path in sheets:
- rows = _sheet_rows(z, path, sst, xf_map)
- if rows:
- return rows, [n for n, _ in sheets]
- return [], [n for n, _ in sheets]
- def read_file(path):
- with open(path, "rb") as fp:
- return read_bytes(fp.read())
|