xlsx.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. # -*- coding: utf-8 -*-
  2. """
  3. 最小化 .xlsx 读取器(Python 标准库,无需 openpyxl)
  4. xlsx 是一个 zip 包:
  5. xl/sharedStrings.xml 共享字符串表
  6. xl/styles.xml 样式(用于识别日期格式)
  7. xl/workbook.xml + .rels 工作表清单
  8. xl/worksheets/sheetN.xml 单元格数据
  9. 只做读取,返回 rows: [[str, ...], ...](含表头),日期单元格转为 YYYY-MM-DD。
  10. """
  11. import io
  12. import re
  13. import zipfile
  14. import xml.etree.ElementTree as ET
  15. from datetime import datetime, timedelta
  16. NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
  17. NS_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
  18. NS_PKG_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
  19. # Excel 内置日期格式编号
  20. BUILTIN_DATE_FMTS = {14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47}
  21. EPOCH = datetime(1899, 12, 30)
  22. def _col_index(ref):
  23. """'BC12' -> 列号 54(0-based)"""
  24. letters = re.match(r"([A-Z]+)", ref or "")
  25. if not letters:
  26. return None
  27. n = 0
  28. for ch in letters.group(1):
  29. n = n * 26 + (ord(ch) - 64)
  30. return n - 1
  31. def _local(tag):
  32. return tag.rsplit("}", 1)[-1]
  33. def _parse_styles(z):
  34. """返回 numFmtId 集合中属于日期格式的 id 集合;以及 xf 索引 -> numFmtId 映射"""
  35. date_fmt_ids = set(BUILTIN_DATE_FMTS)
  36. xf_map = {}
  37. if "xl/styles.xml" not in z.namelist():
  38. return xf_map
  39. root = ET.fromstring(z.read("xl/styles.xml"))
  40. custom = {} # id -> 是否日期
  41. for nf in root.iter(NS + "numFmt"):
  42. try:
  43. fid = int(nf.get("numFmtId"))
  44. except (TypeError, ValueError):
  45. continue
  46. code = nf.get("formatCode") or ""
  47. # 排除纯数字带千分位等:含 y/m/d/h 才算日期
  48. if re.search(r"[ymdhs]", code, re.I) and not re.match(r"^[\d,#.\s]+$", code):
  49. custom[fid] = True
  50. date_fmt_ids.add(fid)
  51. cellXfs = root.find(NS + "cellXfs")
  52. if cellXfs is not None:
  53. for i, xf in enumerate(cellXfs.findall(NS + "xf")):
  54. try:
  55. xf_map[i] = int(xf.get("numFmtId") or 0)
  56. except ValueError:
  57. xf_map[i] = 0
  58. # 标记自定义日期格式
  59. for i, fid in xf_map.items():
  60. if fid in custom:
  61. xf_map[i] = -1 # 特殊标记:自定义日期
  62. return xf_map
  63. def _is_date_style(xf_map, s_attr):
  64. if not s_attr:
  65. return False
  66. try:
  67. fid = xf_map.get(int(s_attr))
  68. except ValueError:
  69. return False
  70. return fid in BUILTIN_DATE_FMTS or fid == -1
  71. def _serial_to_date(v):
  72. try:
  73. n = float(v)
  74. except ValueError:
  75. return None
  76. if n <= 0 or n > 60000:
  77. return None
  78. d = EPOCH + timedelta(days=n)
  79. if n % 1 == 0:
  80. return d.strftime("%Y-%m-%d")
  81. return d.strftime("%Y-%m-%d") # 时间部分舍弃,日粒度只到天
  82. def _shared_strings(z):
  83. if "xl/sharedStrings.xml" not in z.namelist():
  84. return []
  85. root = ET.fromstring(z.read("xl/sharedStrings.xml"))
  86. out = []
  87. for si in root.findall(NS + "si"):
  88. # 富文本 run 与纯文本 t 都归并为拼接
  89. out.append("".join(t.text or "" for t in si.iter(NS + "t")))
  90. return out
  91. def _sheet_paths(z):
  92. """按 workbook.xml 里的顺序返回各 sheet 的 zip 路径"""
  93. names = z.namelist()
  94. wb = ET.fromstring(z.read("xl/workbook.xml"))
  95. rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
  96. rel_map = {r.get("Id"): r.get("Target") for r in rels.findall(NS_PKG_REL + "Relationship")}
  97. out = []
  98. for sh in wb.find(NS + "sheets").findall(NS + "sheet"):
  99. rid = sh.get(NS_R + "id")
  100. target = rel_map.get(rid, "")
  101. target = target.lstrip("/")
  102. if not target.startswith("xl/"):
  103. target = "xl/" + target
  104. if target in names:
  105. out.append((sh.get("name") or "", target))
  106. return out
  107. def _sheet_rows(z, sheet_path, sst, xf_map):
  108. root = ET.fromstring(z.read(sheet_path))
  109. rows = []
  110. for row in root.iter(NS + "row"):
  111. cells = {}
  112. max_i = -1
  113. for c in row.findall(NS + "c"):
  114. ref = c.get("r") or ""
  115. i = _col_index(ref)
  116. t = c.get("t") or ""
  117. s_attr = c.get("s")
  118. val = None
  119. if t == "inlineStr":
  120. is_el = c.find(NS + "is")
  121. if is_el is not None:
  122. val = "".join(x.text or "" for x in is_el.iter(NS + "t"))
  123. else:
  124. v_el = c.find(NS + "v")
  125. raw = v_el.text if v_el is not None else None
  126. if raw is not None:
  127. if t == "s":
  128. try:
  129. val = sst[int(raw)]
  130. except (ValueError, IndexError):
  131. val = raw
  132. elif t == "b":
  133. val = "1" if raw.strip() == "1" else "0"
  134. elif t == "str":
  135. val = raw
  136. else:
  137. # 数字或日期
  138. if _is_date_style(xf_map, s_attr):
  139. val = _serial_to_date(raw) or raw
  140. else:
  141. val = raw
  142. if i is None:
  143. i = max_i + 1
  144. if val is not None:
  145. # 4.0 -> 4,去掉多余的 .0
  146. if re.match(r"^-?\d+\.0+$", val.strip()):
  147. val = val.strip().split(".")[0]
  148. cells[i] = val
  149. max_i = max(max_i, i)
  150. if cells:
  151. width = max(cells) + 1
  152. rows.append([cells.get(i, "") for i in range(width)])
  153. return rows
  154. def read_bytes(raw):
  155. """返回 (rows, sheet_names)。取第一个非空工作表。"""
  156. try:
  157. z = zipfile.ZipFile(io.BytesIO(raw))
  158. except zipfile.BadZipFile:
  159. raise ValueError("不是有效的 xlsx 文件(可能是旧版 .xls,请另存为 .xlsx)")
  160. sst = _shared_strings(z)
  161. xf_map = _parse_styles(z)
  162. sheets = _sheet_paths(z)
  163. if not sheets:
  164. raise ValueError("xlsx 中没有工作表")
  165. for name, path in sheets:
  166. rows = _sheet_rows(z, path, sst, xf_map)
  167. if rows:
  168. return rows, [n for n, _ in sheets]
  169. return [], [n for n, _ in sheets]
  170. def read_file(path):
  171. with open(path, "rb") as fp:
  172. return read_bytes(fp.read())