Files
AgentLab/backend/tests/test_xlsx_scan.py
T
ThearchyHelios 8059cd5147 Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本
连接层
- 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检
- 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询
- 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因

解析(新 xlsx_scan + tabular)
- 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个
- 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收
- 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖
- 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执
- 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完

版本底座(新 table_versions、raw_store)
- 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查
- 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断)
- 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥
- 上传接口的解析移到线程池

运行固定数据版本
- 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询
- 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version

证据层
- 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变
- 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出

前端
- 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存
- 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2026-10-01 13:42:04 +08:00

796 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""xlsx 版式元数据的流式扫描:openpyxl read_only 拿不到的,这里要拿全、拿准。
导入的第二遍按这里算出的边界读值(tabular.load_into),所以边界错了就是数据错了:
`<dimension>` 陈旧时少读一截、离群格把区域撑成几千万格。夹具都用 openpyxl 现造,
需要 openpyxl 写不出来的形态(缓存值、无坐标的单元格、DTD)时直接改压缩包里的 XML。
"""
from __future__ import annotations
import io
import re
import zipfile
from collections.abc import Callable
from dataclasses import asdict
import pytest
from openpyxl import Workbook
from app.data import xlsx_scan
from app.data.xlsx_scan import UnsupportedTable, scan
def save(book: Workbook) -> bytes:
buf = io.BytesIO()
book.save(buf)
return buf.getvalue()
def patch(raw: bytes, edits: dict[str, Callable[[str], str]]) -> bytes:
"""改压缩包里的部件:{路径: 文本 → 文本}。"""
src = zipfile.ZipFile(io.BytesIO(raw))
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
for info in src.infolist():
data = src.read(info.filename)
if info.filename in edits:
data = edits[info.filename](data.decode()).encode()
dst.writestr(info, data)
return out.getvalue()
def cache(values: dict[str, str]) -> Callable[[str], str]:
"""给 openpyxl 写出的公式补上缓存值(模拟 Excel 保存过)。"""
def edit(text: str) -> str:
def fill(m: re.Match[str]) -> str:
ref = m.group(1)
return f'<c r="{ref}"><f>{m.group(2)}</f><v>{values[ref]}</v></c>' if ref in values else m.group(0)
return re.sub(r'<c r="([A-Z]+\d+)"><f>([^<]*)</f><v></v></c>', fill, text)
return edit
SHEET1 = "xl/worksheets/sheet1.xml"
# --------------------------------------------------------------------------
# 边界
# --------------------------------------------------------------------------
def test_bounds_come_from_cells_not_from_dimension():
"""<dimension> 是写文件的程序自己填的,可能是陈旧的:真实边界只能从非空格算。"""
book = Workbook()
ws = book.active
ws["C5"] = "地区"
ws["D5"] = "金额"
for r in range(6, 26):
ws.cell(r, 3, f"分区{r}")
ws.cell(r, 4, r)
raw = patch(save(book), {SHEET1: lambda t: re.sub(r'<dimension ref="[^"]*"/>', '<dimension ref="A1:B2"/>', t)})
sheet = scan(raw).sheets[0]
assert sheet.dimension == "A1:B2" # 记下来,但不信
assert sheet.bounds is not None
assert sheet.bounds.a1() == "C5:D25"
assert sheet.nonempty == 42
def test_missing_dimension_is_fine():
"""openpyxl 的 write_only 根本不写 <dimension>。"""
book = Workbook(write_only=True)
ws = book.create_sheet("明细")
ws.append(["a", "b"])
for i in range(30):
ws.append([i, i * 2])
sheet = scan(save(book)).sheets[0]
assert sheet.dimension is None
assert sheet.bounds.a1() == "A1:B31"
def test_blank_strings_do_not_count_but_formulas_do():
"""只有空白的文本算空(导入时也按空值存);有公式的格不算空,哪怕没有缓存值。"""
book = Workbook()
ws = book.active
ws["A1"] = "x"
ws["B1"] = " " # 共享字符串里的空白
ws["E9"] = "=A1" # 没有缓存值的公式
sheet = scan(save(book)).sheets[0]
assert sheet.nonempty == 2
assert sheet.bounds.a1() == "A1:E9"
def test_inline_strings_and_cells_without_coordinates():
"""有的程序写内联字符串、省略行号和单元格坐标:位置按顺序推出来。"""
book = Workbook()
book.active["A1"] = "占位"
raw = save(book)
data = ('<sheetData><row><c t="inlineStr"><is><t>甲</t></is></c><c><v>1</v></c></row>'
'<row><c t="inlineStr"><is><t> </t></is></c><c t="inlineStr"><is><r><t>乙</t></r></is></c>'
'<c><v>2</v></c></row></sheetData>')
raw = patch(raw, {SHEET1: lambda t: re.sub(r"<sheetData>.*</sheetData>", data, t, flags=re.S)})
sheet = scan(raw).sheets[0]
assert sheet.bounds.a1() == "A1:C2"
assert sheet.nonempty == 4 # 「 」是空白,不算
def test_far_cells_name_the_outliers():
"""稀疏判定拒收时要说出是哪几个远处的格子:A 列一百行数据、XFD 列冒出来一个。"""
book = Workbook()
ws = book.active
for r in range(1, 101):
ws.cell(r, 1, r)
ws["XFD5"] = "误输入"
ws["A90000"] = 1
sheet = scan(save(book)).sheets[0]
assert sheet.bounds.a1() == "A1:XFD90000"
assert sheet.far_cells == ["XFD5", "A90000"]
def test_a_far_cell_jumping_both_ways_is_named_once():
"""右下角一个格子(XFD1048576)同时撑出了右边界和下边界:示例里只出现一次。"""
book = Workbook()
ws = book.active
for r in range(1, 201):
ws.cell(r, 1, r)
ws.cell(r, 2, r)
ws["XFD1048576"] = "x"
assert scan(save(book)).sheets[0].far_cells == ["XFD1048576"]
# --------------------------------------------------------------------------
# 版式
# --------------------------------------------------------------------------
def test_merged_hidden_and_autofilter():
book = Workbook()
ws = book.active
for r in range(1, 12):
ws.append([f"r{r}", r, r * 10, r * 100])
ws.merge_cells("A1:B1")
ws.merge_cells("C3:D4")
for r in (3, 4, 5, 9):
ws.row_dimensions[r].hidden = True
ws.column_dimensions["C"].hidden = True
ws.auto_filter.ref = "A1:D11"
ws.auto_filter.add_filter_column(0, ["r2"])
sheet = scan(save(book)).sheets[0]
assert sorted(sheet.merged) == ["A1:B1", "C3:D4"]
assert sheet.merged_total == 2
assert sheet.hidden_rows == [(3, 5), (9, 9)] # 连续的合成区间
assert sheet.hidden_cols == [(3, 3)]
assert sheet.autofilter == "A1:D11"
# <filterColumn> 不是公式:字节级查找 b"<f" 会误中,按元素名判断不会
assert sheet.formulas == 0
def test_sheet_states():
book = Workbook()
book.active.title = "可见"
book.active["A1"] = 1
hidden = book.create_sheet("隐藏")
hidden.sheet_state = "hidden"
very = book.create_sheet("配置")
very.sheet_state = "veryHidden"
very["A1"] = "口令"
sc = scan(save(book))
assert [(s.name, s.state) for s in sc.sheets] == [("可见", "visible"), ("隐藏", "hidden"), ("配置", "veryHidden")]
assert sc.sheet("隐藏").bounds is None
assert sc.sheet("配置").nonempty == 1
# --------------------------------------------------------------------------
# 公式与错误值
# --------------------------------------------------------------------------
def test_uncached_formulas_are_listed():
"""openpyxl 写的公式没有缓存值,data_only 读出来是空:要能逐格指出来。"""
book = Workbook()
ws = book.active
ws.append(["a", "b", "合计"])
for r in range(2, 6):
ws.append([r, r, f"=A{r}+B{r}"])
sheet = scan(save(book)).sheets[0]
assert sheet.formulas == 4
assert sheet.formulas_uncached.cells == ["C2", "C3", "C4", "C5"]
assert sheet.formulas_uncached.total == 4
assert sheet.formulas_uncached.max_row == 5
def test_cached_formulas_and_empty_string_results_are_not_uncached():
book = Workbook()
ws = book.active
ws["A1"] = 1
ws["A2"] = "=A1*2"
ws["A3"] = '=IF(A1>5,"x","")'
raw = save(book)
raw = patch(raw, {SHEET1: lambda t: cache({"A2": "2"})(t).replace('<c r="A3">', '<c r="A3" t="str">')})
sheet = scan(raw).sheets[0]
assert sheet.formulas == 2
# 公式返回空字符串时 Excel 写 t="str" 加空的 <v>:那是有缓存的空值
assert sheet.formulas_uncached.total == 0
def test_uncached_list_is_capped_but_counted():
book = Workbook()
ws = book.active
for r in range(1, 81):
ws.cell(r, 1, r)
ws.cell(r, 2, f"=A{r}")
cells = scan(save(book)).sheets[0].formulas_uncached
assert cells.total == 80
assert len(cells.cells) == xlsx_scan.MAX_CELLS
assert cells.max_row == 80 # 记不全坐标,也能精确判断某行以下有没有
def test_error_cells():
book = Workbook()
book.active["A1"] = 1
raw = patch(save(book), {SHEET1: lambda t: t.replace(
'<c r="A1" t="n"><v>1</v></c>',
'<c r="A1" t="n"><v>1</v></c><c r="B1" t="e"><f>1/0</f><v>#DIV/0!</v></c><c r="C1" t="e"><v>#N/A</v></c>')})
sheet = scan(raw).sheets[0]
assert sheet.errors.cells == ["B1", "C1"]
assert sheet.formulas == 1 and sheet.formulas_uncached.total == 0
@pytest.mark.parametrize(("formula", "cell", "expected"), [
("SUM(C22:C25)", "C26", True), # 表内合计
("SUM($C$22:$C$25)", "C26", True),
("SUBTOTAL(9,D2:D9)", "D10", True),
("B3*C3", "D3", False), # 逐行计算
("SUM(C$2:C3)", "D3", False), # 累计:区域含本行
("C5/SUM(C$5:C$20)", "D5", False), # 占比:区域不在上方
("SUM(C24:C25)", "D26", False), # 别的列
("SUM(Sheet2!C2:C9)", "C26", False), # 别的工作表
("C25", "C26", False), # 单格引用
("SUM(C25:C25)", "C26", False), # 只有一行
# 逐格相加的合计:单格引用至少两个、全在本格正上方的同一列
("B2+B3+B4", "B5", True),
("SUM(C2,C3,C4)", "C5", True),
("SUBTOTAL(109,$C$2,$C$3)", "C5", True),
("b2+b3", "B5", True), # 小写也认
("C2+B3", "C3", False), # 滚动余额:上一行余额加本行发生额
("B4+C4", "B5", False), # 引用了别的列
("B2+B5", "B5", False), # 含本格这一行
("B6+B7", "B5", False), # 在下方
("Sheet2!B2+Sheet2!B3", "B5", False), # 别的工作表
("'B2 表'!B2+'B2 表'!B3", "B5", False), # 带引号的工作表名
('"B2"&"B3"', "B5", False), # 字符串里的不是引用
("SUM(Sheet2!B2:B4)+B3", "B5", False), # 别的工作表的区域,抹掉后只剩一个单格
("LOG10(B2)+B3", "B5", True), # 函数名里的「G10」不是引用
("LOG10(C2)", "G11", False),
])
def test_formulas_aggregating_rows_above(formula, cell, expected):
row, col = xlsx_scan.parse_ref(cell)
assert xlsx_scan._aggregates_above(formula, row, col) is expected
def _sales_table(totals: int = 1) -> xlsx_scan.TableScan:
"""A1:C6:表头一行、明细四行(2 到 5 行)、汇总一行(第 6 行)。"""
return xlsx_scan.TableScan(name="销售表", ref="A1:C6" if totals else "A1:C5", sheet="s",
totals_row_count=totals, columns=["地区", "金额", "数量"])
@pytest.mark.parametrize(("formula", "cell", "expected"), [
("SUBTOTAL(109,销售表[金额])", "B6", True), # 表格对象的汇总行
("SUBTOTAL(109,销售表[[#Data],[数量]])", "C6", True),
("SUM(销售表[金额])", "C6", False), # 别的列
("SUM(销售表[[金额]:[数量]])", "C6", True), # 列区间覆盖本列
("SUM(销售表[])", "A6", True), # 不写列名:整张表的明细
("SUM(销售表[#All])", "B9", True), # 表格下方的合计
("SUM(销售表[#All])", "B6", False), # #All 含汇总行本身
("销售表[[#This Row],[金额]]*2", "B4", False), # 本行
("[@金额]*2", "B4", False),
("销售表[[#Totals],[金额]]", "B8", False), # 只引用汇总行:占比之类,不是合计
("销售表[[#Headers],[金额]]", "B8", False),
("B3/SUM(销售表[金额])", "B3", False), # 占比:明细含本行
("SUM(其他表[金额])", "B6", False), # 不是本工作表的表格对象
("SUM(销售表[金额)", "B6", False), # 括号不成对
])
def test_structured_references_aggregating_rows_above(formula, cell, expected):
row, col = xlsx_scan.parse_ref(cell)
tables = {"销售表": _sales_table()}
assert xlsx_scan._aggregates_above(formula, row, col, tables) is expected
def test_structured_reference_below_a_table_without_totals_row():
"""没勾汇总行、在表格下面自己写了一行合计:同样是合计行。"""
tables = {"销售表": _sales_table(totals=0)}
row, col = xlsx_scan.parse_ref("B6")
assert xlsx_scan._aggregates_above("SUM(销售表[金额])", row, col, tables) is True
assert xlsx_scan._aggregates_above("SUM(销售表[金额])", row, col) is False # 不知道表格位置就不猜
def test_table_totals_row_formula_is_recorded():
from openpyxl.worksheet.table import Table
book = Workbook()
ws = book.active
ws.append(["地区", "金额"])
for r in [["甲", 100], ["乙", 200], ["丙", 300]]:
ws.append(r)
ws.append(["汇总", "=SUBTOTAL(109,销售表[金额])"])
table = Table(displayName="销售表", ref="A1:B5")
table.totalsRowCount = 1
ws.add_table(table)
sc = scan(save(book))
assert sc.sheets[0].formulas_above.cells == ["B5"]
assert sc.tables[0].totals_rows() == (5, 5)
def test_formulas_above_are_recorded_with_text():
book = Workbook()
ws = book.active
for r in range(1, 5):
ws.append([f"x{r}", r])
ws["B5"] = "=SUM(B1:B4)"
above = scan(save(book)).sheets[0].formulas_above
assert above.cells == ["B5"]
assert above.texts == ["SUM(B1:B4)"]
# --------------------------------------------------------------------------
# 工作簿级
# --------------------------------------------------------------------------
def test_workbook_level_metadata():
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.table import Table
book = Workbook()
ws = book.active
ws.title = "月报"
ws.append(["地区", "金额"])
ws.append(["分区甲", 100])
ws.append(["分区乙", 200])
ws.append(["合计", 300])
table = Table(displayName="销售表", ref="A1:B4")
table.totalsRowCount = 1
ws.add_table(table)
book.defined_names["数据区"] = DefinedName("数据区", attr_text="月报!$A$1:$B$3")
ws.defined_names["本表区域"] = DefinedName("本表区域", attr_text="月报!$A$2:$B$3")
sc = scan(save(book))
# openpyxl 写文件时总带 fullCalcOnLoad;XlsxWriter 同样,还会把公式缓存写成 0
assert sc.full_calc_on_load is True
assert [asdict(t) for t in sc.tables] == [
{"name": "销售表", "ref": "A1:B4", "sheet": "月报", "totals_row_count": 1, "header_row_count": 1,
"columns": ["地区", "金额"]},
]
assert sc.tables[0].span() == (1, 1, 4, 2)
assert sc.tables[0].totals_rows() == (4, 4)
assert sc.defined_names["数据区"] == "月报!$A$1:$B$3"
assert sc.defined_names["月报!本表区域"] == "月报!$A$2:$B$3" # 工作表级的名称带上表名
def test_full_calc_flag_absent():
book = Workbook()
book.active["A1"] = 1
raw = patch(save(book), {"xl/workbook.xml": lambda t: t.replace(' fullCalcOnLoad="1"', "")})
assert scan(raw).full_calc_on_load is False
def test_strict_ooxml_namespaces():
"""Strict OOXML 换了命名空间:元素和关系都按本地名认。"""
book = Workbook()
book.active.append(["a", "b"])
book.active.append([1, 2])
strict_main = "http://purl.oclc.org/ooxml/spreadsheetml/main"
strict_rel = "http://purl.oclc.org/ooxml/officeDocument/relationships"
tran_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
tran_rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
def strict(text: str) -> str:
return text.replace(tran_main, strict_main).replace(tran_rel, strict_rel)
raw = patch(save(book), {"xl/workbook.xml": strict, SHEET1: strict, "xl/_rels/workbook.xml.rels": strict})
sheet = scan(raw).sheets[0]
assert sheet.bounds.a1() == "A1:B2"
def test_result_is_json_ready():
import json
book = Workbook()
book.active["A1"] = 1
json.dumps(asdict(scan(save(book))))
# --------------------------------------------------------------------------
# 拒收:坏文件、炸弹、DTD
# --------------------------------------------------------------------------
def test_not_a_zip():
with pytest.raises(UnsupportedTable) as info:
scan(b"not really an xlsx")
text = str(info.value)
assert text.startswith("无法打开该 Excel 文件")
assert "BadZipFile" not in text
def test_zip_without_workbook():
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("hello.txt", "x")
with pytest.raises(UnsupportedTable, match="无法打开该 Excel 文件"):
scan(buf.getvalue())
def test_truncated_xml():
book = Workbook()
book.active["A1"] = 1
raw = patch(save(book), {SHEET1: lambda t: t[: len(t) // 2]})
with pytest.raises(UnsupportedTable, match="无法打开该 Excel 文件"):
scan(raw)
def test_total_uncompressed_size_is_limited(monkeypatch):
book = Workbook()
ws = book.active
for r in range(1, 200):
ws.append(["一段比较长的文字" * 4] * 5)
raw = save(book)
monkeypatch.setattr(xlsx_scan, "MAX_UNCOMPRESSED", 20_000)
with pytest.raises(UnsupportedTable, match="解压后超过"):
scan(raw)
def test_single_part_size_is_limited(monkeypatch):
book = Workbook()
ws = book.active
for r in range(1, 400):
ws.append([r, r, r, r])
raw = save(book)
monkeypatch.setattr(xlsx_scan, "MAX_XML_BYTES", 10_000)
with pytest.raises(UnsupportedTable, match="解压后超过"):
scan(raw)
def test_reading_counts_actual_bytes(monkeypatch):
"""声明的大小先查一遍,读的时候再实数一遍:压缩包头里的大小可以造假,读出来的字节数造不了假。"""
book = Workbook()
ws = book.active
for r in range(1, 400):
ws.append([r, r, r, r])
raw = save(book)
budget_seen: list[int] = []
real_take = xlsx_scan._Budget.take
def take(self, n):
budget_seen.append(n)
real_take(self, n)
monkeypatch.setattr(xlsx_scan._Budget, "take", take)
scan(raw)
assert sum(budget_seen) > 10_000
def test_doctype_is_rejected():
"""OOXML 不需要 DTD;有 DTD 只可能是实体膨胀之类的攻击。"""
book = Workbook()
book.active["A1"] = 1
bomb = '<?xml version="1.0"?><!DOCTYPE lolz [<!ENTITY lol "lol">]>'
raw = patch(save(book), {SHEET1: lambda t: bomb + re.sub(r"^<\?xml[^>]*\?>", "", t)})
with pytest.raises(UnsupportedTable, match="DTD"):
scan(raw)
def _rewrite(raw: bytes, part: str, data: bytes) -> bytes:
src = zipfile.ZipFile(io.BytesIO(raw))
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
for info in src.infolist():
dst.writestr(info, data if info.filename == part else src.read(info.filename))
return out.getvalue()
@pytest.mark.parametrize("encoding", ["utf-16", "utf-16-le", "utf-16-be"])
def test_doctype_in_utf16_is_rejected(encoding):
"""换成 UTF-16 写,字节串「<!DOCTYPE」就对不上了;拦在解析器上,和编码无关。"""
book = Workbook()
book.active["A1"] = 1
raw = save(book)
body = re.sub(r"^<\?xml[^>]*\?>", "", zipfile.ZipFile(io.BytesIO(raw)).read(SHEET1).decode())
text = '<?xml version="1.0" encoding="UTF-16"?><!DOCTYPE x [<!ENTITY a "aaa">]>' + body
with pytest.raises(UnsupportedTable, match="DTD"):
scan(_rewrite(raw, SHEET1, text.encode(encoding)))
def test_utf16_parts_without_doctype_are_fine():
book = Workbook()
book.active["A1"] = 1
raw = save(book)
body = re.sub(r"^<\?xml[^>]*\?>", "", zipfile.ZipFile(io.BytesIO(raw)).read(SHEET1).decode())
text = '<?xml version="1.0" encoding="UTF-16"?>' + body
assert scan(_rewrite(raw, SHEET1, text.encode("utf-16"))).sheets[0].bounds.a1() == "A1:A1"
@pytest.mark.parametrize("part", ["xl/styles.xml", "docProps/core.xml", "[Content_Types].xml"])
def test_doctype_in_parts_the_scan_does_not_read_is_rejected(part):
"""样式、文档属性这些部件扫描不读、openpyxl 读:DTD 的拒收一样要覆盖到。"""
book = Workbook()
book.active["A1"] = 1
bomb = '<?xml version="1.0"?><!DOCTYPE x [<!ENTITY a "aaa">]>'
raw = patch(save(book), {part: lambda t: bomb + re.sub(r"^<\?xml[^>]*\?>", "", t)})
with pytest.raises(UnsupportedTable, match="DTD"):
scan(raw)
def test_unreadable_prolog_is_rejected():
"""序言都读不过去(编码不认识):别的解析器读出来是什么样说不准,拒收。"""
book = Workbook()
book.active["A1"] = 1
raw = patch(save(book), {"xl/styles.xml": lambda t: '<?xml version="1.0" encoding="x-no-such-encoding"?>'
+ re.sub(r"^<\?xml[^>]*\?>", "", t)})
with pytest.raises(UnsupportedTable) as info:
scan(raw)
assert str(info.value).startswith("无法打开该 Excel 文件")
# --------------------------------------------------------------------------
# 拒收:超出 Excel 自身上限的结构(压缩比极高的小文件解出来撑爆内存)
# --------------------------------------------------------------------------
def _one_sheet(data: str) -> bytes:
"""一张工作表,sheetData 换成给定的 XML。"""
book = Workbook()
book.active["A1"] = 1
return patch(save(book), {SHEET1: lambda t: re.sub(r"<sheetData>.*</sheetData>|<sheetData/>",
lambda _m: data, t, flags=re.S)})
def test_row_number_beyond_excel_is_rejected():
raw = _one_sheet('<sheetData><row r="1048577"><c r="A1048577"><v>1</v></c></row></sheetData>')
with pytest.raises(UnsupportedTable, match="行数上限"):
scan(raw)
def test_row_elements_beyond_excel_are_rejected(monkeypatch):
"""不写行号的空行一个个往下排:个数到了 Excel 的上限就拒收(几十 KB 的压缩包能解出几百万个)。"""
monkeypatch.setattr(xlsx_scan, "EXCEL_MAX_ROWS", 200)
with pytest.raises(UnsupportedTable, match="行数上限"):
scan(_one_sheet("<sheetData>" + "<row/>" * 201 + "</sheetData>"))
assert scan(_one_sheet("<sheetData>" + "<row/>" * 199 + '<row><c><v>1</v></c></row></sheetData>')
).sheets[0].bounds.a1() == "A200:A200"
def test_column_beyond_excel_is_rejected():
with pytest.raises(UnsupportedTable, match="列数上限"):
scan(_one_sheet('<sheetData><row r="1"><c r="XFE1"><v>1</v></c></row></sheetData>'))
assert scan(_one_sheet('<sheetData><row r="1"><c r="XFD1"><v>1</v></c></row></sheetData>')
).sheets[0].bounds.a1() == "XFD1:XFD1"
def test_elements_left_hanging_are_capped_but_cleared_rows_are_not_counted(monkeypatch):
"""行清空后它下面的单元格就释放了,不算;行以外一直挂着的元素(未知元素、合并区)算,超了拒收。"""
monkeypatch.setattr(xlsx_scan, "MAX_ALIVE", 500)
rich = '<c t="inlineStr"><is><r><rPr><b/><sz val="9"/></rPr><t>甲</t></r><r><t>乙</t></r></is></c>'
rows = "".join(f'<row r="{r}">{rich}<c><v>{r}</v></c><c><f>B{r}*2</f><v>{r * 2}</v></c></row>'
for r in range(1, 301))
# 300 个行壳加几个零碎:没超。单元格加起来有好几千个元素,要是没从计数里减掉就会超
assert scan(_one_sheet(f"<sheetData>{rows}</sheetData>")).sheets[0].nonempty == 900
with pytest.raises(UnsupportedTable, match="元素过多"):
scan(_one_sheet("<sheetData><row><c><v>1</v></c></row>" + "<x/>" * 600 + "</sheetData>"))
merges = "".join(f'<mergeCell ref="A{r}:B{r}"/>' for r in range(1, 601))
with pytest.raises(UnsupportedTable, match="元素过多"):
scan(_one_sheet(f'<sheetData><row><c><v>1</v></c></row></sheetData><mergeCells>{merges}</mergeCells>'))
def test_cell_text_and_formula_length_follow_excel():
def inline(n: int) -> bytes:
return _one_sheet(f'<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>{"字" * n}</t></is></c></row></sheetData>')
assert scan(inline(32_767)).sheets[0].nonempty == 1
with pytest.raises(UnsupportedTable, match="A1 中的文字超过"):
scan(inline(32_768))
with pytest.raises(UnsupportedTable, match="文字超过"):
scan(_one_sheet(f'<sheetData><row r="1"><c r="A1" t="str"><v>{"x" * 32_768}</v></c></row></sheetData>'))
with pytest.raises(UnsupportedTable, match="公式超过"):
scan(_one_sheet(f'<sheetData><row r="1"><c r="A1"><f>{"1+" * 4097}1</f><v>1</v></c></row></sheetData>'))
SST_REL = ('<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" '
'Target="sharedStrings.xml" Id="rId99"/>')
SST_CT = ('<Override PartName="/xl/sharedStrings.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>')
def _with_sst(items: str, *, ct_part: str | None = "/xl/sharedStrings.xml") -> bytes:
"""给工作簿加一张共享字符串表(openpyxl 写的是内联字符串,没有这张表)。"""
raw = _one_sheet('<sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData>')
sst = ('<?xml version="1.0" encoding="UTF-8"?><sst xmlns="http://schemas.openxmlformats.org/'
f'spreadsheetml/2006/main">{items}</sst>')
out = io.BytesIO()
src = zipfile.ZipFile(io.BytesIO(raw))
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
for info in src.infolist():
data = src.read(info.filename).decode()
if info.filename == "xl/_rels/workbook.xml.rels":
data = data.replace("</Relationships>", SST_REL + "</Relationships>")
elif info.filename == "[Content_Types].xml" and ct_part:
data = data.replace("</Types>", SST_CT.replace("/xl/sharedStrings.xml", ct_part) + "</Types>")
dst.writestr(info, data)
dst.writestr("xl/sharedStrings.xml", sst)
return out.getvalue()
def test_shared_strings_follow_excel_limits(monkeypatch):
assert scan(_with_sst("<si><t>甲</t></si>")).sheets[0].nonempty == 1
with pytest.raises(UnsupportedTable, match="一项文字超过"):
scan(_with_sst(f"<si><t>{'x' * 32_768}</t></si>"))
monkeypatch.setattr(xlsx_scan, "MAX_SHARED_STRINGS", 10)
with pytest.raises(UnsupportedTable, match="条目超过"):
scan(_with_sst("<si><t>甲</t></si>" * 11))
monkeypatch.setattr(xlsx_scan, "MAX_SI_ELEMENTS", 20)
with pytest.raises(UnsupportedTable, match="元素过多"):
scan(_with_sst("<si>" + "<r><t>甲</t></r>" * 30 + "</si>"))
def test_shared_strings_must_be_registered_consistently():
"""内容类型清单(openpyxl 按它找)和工作簿关系文件(扫描按它找)指的共享字符串表不同:拒收。"""
with pytest.raises(UnsupportedTable, match="共享字符串表不一致"):
scan(_with_sst("<si><t>甲</t></si>", ct_part="/xl/other.xml"))
STYLES = "xl/styles.xml"
def test_small_parts_have_their_own_limits(monkeypatch):
"""样式、主题这些部件 openpyxl 整份建树:字节数、元素个数、格式条目数都有上限。图片这类二进制不受影响。"""
book = Workbook()
book.active["A1"] = 1
raw = save(book)
padded = patch(raw, {STYLES: lambda t: t.replace("</styleSheet>", "<extLst/>" * 3000 + "</styleSheet>")})
monkeypatch.setattr(xlsx_scan, "MAX_PART_BYTES", 20_000)
with pytest.raises(UnsupportedTable, match="解压后超过"):
scan(padded)
# 100 KB 的图片:不是 XML,不按小部件的上限管
assert scan(_with_part(raw, "xl/media/image1.png", bytes(range(256)) * 400)).sheets[0].nonempty == 1
monkeypatch.setattr(xlsx_scan, "MAX_PART_BYTES", 16 * 1024 ** 2)
monkeypatch.setattr(xlsx_scan, "MAX_PART_ELEMENTS", 2000)
with pytest.raises(UnsupportedTable, match="元素超过"):
scan(padded)
# 计算链、批注这类没人解析的部件可以很大(和公式数成正比),不按小部件管
chain = '<calcChain xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' + '<c r="A1"/>' * 3000 + "</calcChain>"
assert scan(_with_part(raw, "xl/calcChain.xml", chain)).sheets[0].nonempty == 1
monkeypatch.setattr(xlsx_scan, "MAX_XF", 3)
many_xf = patch(raw, {STYLES: lambda t: re.sub(r"<cellXfs[^>]*>", lambda m: m.group(0) + '<xf numFmtId="0"/>' * 5, t)})
with pytest.raises(UnsupportedTable, match="单元格格式超过"):
scan(many_xf)
def test_entry_count_is_limited(monkeypatch):
book = Workbook()
book.active["A1"] = 1
monkeypatch.setattr(xlsx_scan, "MAX_ENTRIES", 5)
with pytest.raises(UnsupportedTable, match="部件超过"):
scan(save(book))
# --------------------------------------------------------------------------
# 拒收:两处登记对不上的工作簿、同名工作表
# --------------------------------------------------------------------------
WORKBOOK = "xl/workbook.xml"
def _two_sheets() -> bytes:
book = Workbook()
book.active.title = "明细"
book.active["A1"] = "甲"
book.create_sheet("备注")["A1"] = "乙"
return save(book)
@pytest.mark.parametrize("first, second", [("明细", "明细"), ("mingxi", "MINGXI")])
def test_duplicate_sheet_names_are_rejected(first, second):
"""两个同名工作表(前一个隐藏):按名字取表会拿到隐藏的那个。名字不区分大小写。"""
def edit(t: str) -> str:
t = t.replace('name="明细" sheetId="1" state="visible"', f'name="{first}" sheetId="1" state="veryHidden"')
return t.replace('name="备注"', f'name="{second}"')
with pytest.raises(UnsupportedTable, match="两个工作表同名"):
scan(patch(_two_sheets(), {WORKBOOK: edit}))
def _with_part(raw: bytes, path: str, data: str) -> bytes:
out = io.BytesIO()
src = zipfile.ZipFile(io.BytesIO(raw))
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
for info in src.infolist():
dst.writestr(info, src.read(info.filename))
dst.writestr(path, data)
return out.getvalue()
def test_workbook_part_must_be_registered_consistently():
"""包关系文件(扫描按它找)和内容类型清单(openpyxl 按它找)指向不同的工作簿主部件:拒收。"""
raw = _two_sheets()
decoy = zipfile.ZipFile(io.BytesIO(raw)).read(WORKBOOK).decode()
raw = _with_part(raw, "xl/decoy.xml", decoy)
raw = _with_part(raw, "xl/_rels/decoy.xml.rels",
zipfile.ZipFile(io.BytesIO(raw)).read("xl/_rels/workbook.xml.rels").decode())
pointed = patch(raw, {"_rels/.rels": lambda t: t.replace('Target="xl/workbook.xml"', 'Target="xl/decoy.xml"')})
with pytest.raises(UnsupportedTable, match="工作簿主部件不一致"):
scan(pointed)
main = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
twice = patch(raw, {"[Content_Types].xml": lambda t: t.replace(
"</Types>", f'<Override PartName="/xl/decoy.xml" ContentType="{main}"/></Types>')})
with pytest.raises(UnsupportedTable, match="不止一个工作簿主部件"):
scan(twice)
assert [s.name for s in scan(raw).sheets] == ["明细", "备注"] # 多一个没人指的部件不要紧
# --------------------------------------------------------------------------
# 拒收:改了名的部件里的 DTD
# --------------------------------------------------------------------------
def _rename(raw: bytes, old: str, new: str) -> bytes:
"""把一个部件改名,关系文件和内容类型清单里的引用跟着改。"""
out = io.BytesIO()
src = zipfile.ZipFile(io.BytesIO(raw))
tail_old, tail_new = old.rsplit("/", 1)[-1], new.rsplit("/", 1)[-1]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst:
for info in src.infolist():
data = src.read(info.filename)
name = new if info.filename == old else info.filename
if info.filename.endswith(".rels") or info.filename == "[Content_Types].xml":
data = data.decode().replace(tail_old, tail_new).encode()
dst.writestr(name, data)
return out.getvalue()
ENTITY = '<?xml version="1.0"?><!DOCTYPE worksheet [<!ENTITY e "展开">]>'
@pytest.mark.parametrize("new", ["xl/worksheets/sheet1.bin", "xl/worksheets/sheet1", "xl/worksheets/sheet1.svg"])
def test_doctype_in_a_renamed_part_is_rejected(new):
"""工作表改名成不以 .xml 结尾:关系文件照样指着它,扫描和 openpyxl 照样当 XML 解析。DTD 一样拒收。"""
book = Workbook()
book.active["A1"] = 1
raw = _rename(save(book), SHEET1, new)
assert scan(raw).sheets[0].bounds.a1() == "A1:A1" # 光改名不影响导入
bombed = _rewrite(raw, new, (ENTITY + re.sub(r"^<\?xml[^>]*\?>", "", zipfile.ZipFile(io.BytesIO(raw))
.read(new).decode())).encode())
with pytest.raises(UnsupportedTable, match="DTD"):
scan(bombed)
def test_binary_parts_and_plain_svg_doctype_are_fine():
"""图片、打印设置、宏这些二进制部件解析不了是正常的;SVG 带不含内部子集的 DOCTYPE 也是正常的。"""
book = Workbook()
book.active["A1"] = 1
raw = save(book)
raw = _with_part(raw, "xl/media/image1.png", "\x89PNG\r\n\x1a\n" + "\x00" * 64)
raw = _with_part(raw, "xl/vbaProject.bin", "\xd0\xcf\x11\xe0" + "\x01" * 64)
svg = ('<?xml version="1.0"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" '
'"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg"/>')
assert scan(_with_part(raw, "xl/media/image2.svg", svg)).sheets[0].nonempty == 1
subset = '<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY e "x">]><svg xmlns="http://www.w3.org/2000/svg"/>'
with pytest.raises(UnsupportedTable, match="DTD"):
scan(_with_part(raw, "xl/media/image3.svg", subset))
def test_chartsheets_and_their_charts_count_as_small_parts(monkeypatch):
"""图表工作表 openpyxl 会整份解析,连同它引用的绘图和图表:这些部件同样按小部件设限。"""
from openpyxl.chart import BarChart, Reference
book = Workbook()
ws = book.active
for r in range(1, 6):
ws.append([r])
chart = BarChart()
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=5))
book.create_chartsheet("图").add_chart(chart)
raw = save(book)
charts = [n for n in zipfile.ZipFile(io.BytesIO(raw)).namelist() if n.startswith("xl/charts/")]
assert charts and scan(raw).sheets[0].nonempty == 5
padded = patch(raw, {charts[0]: lambda t: t.replace("</chartSpace>", "<extLst/>" * 3000 + "</chartSpace>")})
monkeypatch.setattr(xlsx_scan, "MAX_PART_ELEMENTS", 2000)
with pytest.raises(UnsupportedTable, match="元素超过"):
scan(padded)