给「大配表看一眼」这个场景做的 Windows 查看器:只读、秒开、能全表搜,性能取向优先, 界面刻意做得和 Excel/WPS 接近,但查看本身没有任何编辑能力。
打开方式(三选一,可同时用):
- 双击
ExcelViewer.cmd,或把 xlsx / csv 拖到它上面 - 跑
viewer\publish\ExcelViewer.exe "某个表.xlsx" - 导入
注册双击打开.reg后,右键 xlsx 会出现「用查看器打开(只读)」和「用副本编辑」
配表要改的时候,不要让查看器和 Excel 去抢同一个文件。这里的做法是:
原文件 ──只读复制一次──▶ 工作区副本 ──Excel 打开──▶ 你编辑 ──保存关闭──▶ 自动弹出改动比对
▲ │
└──────────────── 只有你点「写回原文件」并二次确认,才会被覆盖 ◀──────────┘
具体保证:
- 原文件全程只被读一次,不会被 Excel 锁定 —— 实测在 Excel 开着副本时,
原文件仍能以独占模式打开(
FileShare.None)。 - 工作区在
%LOCALAPPDATA%\ExcelViewer\sessions\<时间戳>\,不在系统 TEMP 里, 不会被系统清理掉导致改动丢失;可用环境变量EXCELVIEWER_WORKSPACE改位置。 - Excel 保存是「写临时文件再替换」,所以比对等编辑器进程退出 + 文件大小/时间戳稳定之后才做, 不会读到半个文件。
- 写回前会自动在
%LOCALAPPDATA%\ExcelViewer\backup\留一份带时间戳的备份。 - 不喜欢写回就用「另存为…」,全程不碰原文件。
改动比对摊成一张清单表(类型 / 行号 / 列 / 原值 / 新值),可以直接搜索、复制:
- 先做行对齐,再比格子。挑一个「非空且互不重复」的列当行主键(配表的 ID 列), 再用保序匹配把两边的行对上;中间插入或删除一行只会报「新增 1 行 / 删除 1 行」, 不会把后面所有行都说成改过(详见下方「已修的坑」)。
- 对上的一对行逐格比较,报「修改 · 行号 · 列 · 原值 · 新值」。
- 只有一边有的行报「新增行 / 删除行」,并把整行内容放进「新值 / 原值」列, 底部预览条里能看全文。
查看器可以直接当 SVN 的外部 diff 工具用,--diff-svn 就是给这个场景准备的入口。
导入 注册SVN差异对比.reg(只写 HKCU,不需要管理员权限),它把
HKCU\Software\TortoiseSVN\DiffTools 下的 .xlsx / .xlsm / .xlsb / .xls 指向查看器;
全局的 Diff(Beyond Compare)和 Merge(UnityYAMLMerge)都不动。
也可以在界面上改:右键 → TortoiseSVN → 设置 → 差异查看器 → 高级 → 选中 .xlsx → 编辑,
把外部程序填成:
"…\excel2csv\viewer\publish\ExcelViewer.exe" --diff-svn %base %mine
之后右键任意 xlsx →「比较差异 / 与上一版本比较」就直接进查看器的改动对比视图,
标题栏会显示成 Item.xlsx (revision 12) 这种好认的名字。
想恢复 TortoiseSVN 自带的 Excel 脚本,把那几个值改回:
wscript.exe "C:\Program Files\TortoiseSVN\Diff-Scripts\diff-xls.js" %base %mine //E:javascript
# 单个文件;.xlsx 在 svn 眼里是二进制,必须加 --force 才会调用外部工具
svn diff --force --diff-cmd "…\viewer\publish\ExcelViewer.exe" -x "--diff-svn" 某个表.xlsx
# 想省掉 --force,就写进 %APPDATA%\Subversion\config:
# [helpers]
# diff-cmd = C:\...\viewer\publish\ExcelViewer.exe
# diff-extensions = --diff-svnsvn 传进来的旧版本文件是 .svn\pristine\XX\<sha1>.svn-base —— 没有扩展名,
而查看器是按扩展名挑解析器的,直接传进去只会得到「这个文件类型暂时看不了」。
所以 --diff-svn 会先跳过 -L "标签" 参数、取最后两个真实文件,再按文件头嗅探真实格式
(zip → xlsx / xlsm / xlsb,OLE → xls,其余按 csv),必要时复制一份带正确扩展名的临时副本到
%LOCALAPPDATA%\ExcelViewer\svn-diff\(超过 3 天的自动清理,可用 EXCELVIEWER_WORKSPACE 改位置)。
冲突合并(
Merge)这条线还没接:Excel 没法自动合并,硬做只会产生坏文件。 目前MergeTools里没有.xlsx,走的是全局 Merge,对 Excel 是错的——要处理冲突得人工挑。
| 方式 | 操作 |
|---|---|
| 右键菜单 | 导入 注册双击打开.reg 后,xlsx 右键 →「用副本编辑(不锁原文件)」 |
| 查看器内 | 打开文件后点工具栏「用副本编辑」,或菜单 文件 → 用副本编辑 |
| 命令行 | ExcelViewer.exe --edit "某个表.xlsx" |
改完想手动比对:菜单 文件 →「与原文件比对改动…」,或
ExcelViewer.exe --diff "原文件" "改过的文件"。
Windows 10/11 的默认打开方式存在 UserChoice 键里,带系统哈希保护,任何程序都无法静默改成默认
(这是系统防止程序篡改关联的机制,不是没做)。所以流程是:导入注册表 → 手动确认一次。
# 1) 双击导入(只写 HKCU,不需要管理员权限)
注册双击打开.reg
# 2) 手动把默认打开方式设成查看器,任选一种:
# a. 双击 设为默认打开方式.cmd,跳到系统设置页,搜索 .xlsx 后选「Excel 只读查看器」
# b. 右键任意 .xlsx → 打开方式 → 选择其他应用 → 选「Excel 只读查看器」→ 勾选「始终」注册表文件里做了什么:
- 注册 ProgId
ExcelViewer.Sheet,让它出现在「打开方式」列表里 - 给
.xlsx / .xlsm / .xlsb / .csv的OpenWithProgids加上它(不改默认值) - 给
.xlsx右键菜单加两项:只读打开、用副本编辑
早先那套「转 CSV 查看」(
xlsx2csv.py/convert_open.vbs/转换CSV.bat/装右键菜单.reg/设双击打开.reg/恢复Excel双击.reg)已经全部移除, 注册表里的excel2csvProgId、.xlsx默认值和那条右键项也一并清掉了。 现在.xlsx双击走系统默认(本机是 Excel),要只读看就用右键那两项或ExcelViewer.cmd。
注意:注册双击打开.reg 里的路径是按当前安装位置写死的(共 5 处)。
如果之后移动了工程目录,把里面的
C:\\Users\\18223\\Programs\\excel2csv\\viewer\\publish\\ExcelViewer.exe
整体替换成新路径即可。
卸载:删掉 HKCU\Software\Classes\ExcelViewer.Sheet、
HKCU\Software\Classes\.xlsx\shell\excelviewer、...\excelvieweredit,
以及各扩展名 OpenWithProgids 下的 ExcelViewer.Sheet 值。
WPS/Excel 打开配表慢,主要慢在「一次性把整个工作簿全部解析、全部建单元格对象、全部排版」。 这个查看器把这三件事全部拆开:
| 做法 | 效果 |
|---|---|
| 只解析当前工作表 | 打开 Languages.xlsx(3 个 sheet / 4.2 万行)只读首 sheet,切换 sheet 时才读下一个 |
| 列式存储 + 字符串池 | 相同值(GDE_IGNORE、空值、重复文案)全表只存一份,重复率高的配表内存省一大截 |
| 自绘虚拟化表格 | 只画可视区那几十行,滚动到第 3 万行与第 3 行的开销相同 |
| 不做公式计算 / 不建样式对象 | 只读文本,跳过 Excel 的公式引擎与格式系统 |
实测(本机,Release 构建,1400x700 视口,见下方「复现方法」):
| 文件 | 规模 | 首 sheet 加载 | 首次上屏 | 滚动重绘 | 全表搜索 |
|---|---|---|---|---|---|
| Languages.xlsx | 31363 行 x 9 列 | 927 ms | 38 ms | 中位 ~23 ms | 4–10 ms |
| Item.xlsx | 3668 行 x 13 列 | 184 ms | 31 ms | 中位 ~16 ms | 0.5–5 ms |
| Level.xlsx | 2305 行 x 71 列 | 284 ms | 26 ms | 中位 ~9 ms | 0.5–4 ms |
界面右上角会显示真实「打开耗时」,可以拿去和 WPS 直接对比。
- 只读:查看时不写任何东西回原文件,也不往用户目录写配置。
- 内容预览条(默认展开):配表里有些说明列是几千字的 JSON / 多行文案,格子宽度装不下。
底部常驻一条 Excel 风格的预览条,选中哪个格子就显示它的完整内容:自动换行 + 纵向滚动,
可以直接选中或用「复制」取原文;标题栏给出「N 字符 · M 行」和所在位置。
上面的分隔线拖动就能改高度(拖出来的高度会被记住);按
Ctrl+P收起后, 预览条会变成一行摘要(地址 + 内容开头 + 「展开 ▴」),点一下即可恢复,不会留下半截按钮。 - 格子只画一行:列宽不会再被长文案撑开,放不下就用
…截断(和 Excel 默认行为一致), 不会出现"多行文字叠在一起";要看全文就选中它,底部预览条里有完整内容。 - 全表搜索:输入即搜(后台线程 + 220ms 防抖),命中行整行淡黄、命中单元格亮黄,
当前命中行有描边;
F3/Shift+F3上下条跳转,状态栏显示「第 n / m 条」。 - 表格操作:冻结列标与表头行、行号栏、列宽拖拽、双击列边界自适应、键盘导航、
Ctrl+C复制选中区域、Ctrl+ +/-缩放字号。 - 自动识别表头行:项目配表的结构是
GDE_IGNORE/GDE_FIELD_NAMES/ 中文描述 /GDE_FIELD_TYPES, 查看器会在前 8 行里找GDE_FIELD_NAMES,把那一行标成表头(加粗 + 淡蓝底), 并在打开时自动滚到它附近。找不到标记就按普通表格处理(第一行当表头)。 - 改动比对:把两个工作簿的差异摊成清单表(类型 / 行号 / 列 / 原值 / 新值),
先做行对齐再比格子,插入/删除行不会被误报成"后面全改了";也能直接当 SVN 的外部 diff 工具(
--diff-svn)。 - 支持格式:
xlsx/xlsm/xlsb/xls/csv。
| 按键 | 作用 |
|---|---|
Ctrl+O / F5 |
打开文件 / 重新加载当前文件(重新导出后可刷新) |
Ctrl+F |
定位到搜索框 |
回车 / F3 |
下一个命中 |
Shift+F3 |
上一个命中 |
Tab |
切换工作表 |
方向键 / PageUp PageDown |
移动 / 翻页 |
Ctrl+Home / Ctrl+End |
跳到表格开头 / 末尾 |
Ctrl+C |
复制选中单元格(制表符分隔,可直接粘回 Excel) |
Ctrl+P |
收起 / 展开底部内容预览条(默认展开;收起后点一下那一行也能展开) |
Ctrl+ + / Ctrl+ - |
放大 / 缩小字号 |
Esc |
清除搜索 |
excel2csv/
├─ ExcelViewer.cmd 便捷启动器(可拖文件到它上面)
├─ ExcelViewerEdit.vbs 「用副本编辑」入口(右键菜单调用它)
├─ 注册双击打开.reg 注册 ProgId / 打开方式 / 右键菜单
├─ 注册SVN差异对比.reg 让 TortoiseSVN 用查看器比对 xlsx(--diff-svn)
├─ 设为默认打开方式.cmd 注册 + 跳到系统设置页设默认
├─ NuGet.config 离线还原配置(本机无法访问 nuget.org)
├─ viewer/ 查看器源码(C# / WPF)
│ ├─ publish/ExcelViewer.exe ← 直接运行这个
│ ├─ app.ico 应用图标(exe / 任务栏 / 标题栏)
│ ├─ Model/ 数据模型:字符串池、列式 Sheet、加载器、表头识别、改动比对
│ ├─ Controls/ 自绘虚拟化表格、布局模型、文本绘制、配色
│ ├─ Search/ 全表搜索
│ ├─ DiffView.cs 改动比对视图
│ ├─ SvnDiffInput.cs --diff-svn:认 SVN 传来的参数 + 按内容补扩展名
│ ├─ EditSession.cs 编辑器查找、无界面比对输出
│ └─ TempWorkspace.cs 工作区 / 副本 / 备份目录 / svn 临时副本
├─ bench/ 解析引擎基准与数据核对(复用 viewer 的加载器)
└─ tools/
├─ launch.ps1 启动器逻辑(中文提示放这里)
├─ install-open-with.ps1 写注册表 + 打开默认应用设置页
├─ Shot.csproj / Program.cs 窗口截图 / 图片放大(独立小程序)
├─ make_app_icon.ps1 AI 原图 → 去背景 / 留安全区 / 打多尺寸 .ico
└─ make_diff_fixture.py 造一份「改过几处」的测试数据
图标是「蓝底圆角方块 + 用表格格子拼出来的字母 E,中横条是高亮格」,
AI 原图(千问 qwen-image-2.0)放在 assets\icon\app-icon.png,
同目录 app-icon-256.png 是 256px 预览,几个没用上的候选留在 assets\icon-candidates\。
换图标或重新生成后,一条命令重打 viewer\app.ico:
pwsh -File tools\make_app_icon.ps1 -Source assets\icon\app-icon.png `
-Ico viewer\app.ico -Png assets\icon\app-icon-256.png脚本做三件事:把原图四周的纯色背景抠成透明(带一段渐变的 anti-alias 边)、 按 Windows 图标习惯让内容只占画布 80%(否则 16px 下比旁边的系统图标大一圈)、 输出 256/128/64/48/32/24/16 七个尺寸(每档内嵌 PNG,Vista 以后都认)。
生成物要重新编译才生效:
dotnet publish viewer\ExcelViewer.csproj -c Release -o viewer\publish
.cmd文件一律保持纯 ASCII(见下方「已修的坑」)。所以上面几个.cmd都很短, 真正的内容和中文提示都在tools\*.ps1里。
# 构建
dotnet build viewer\ExcelViewer.csproj -c Release
# 发布(生成 viewer\publish\ExcelViewer.exe)
dotnet publish viewer\ExcelViewer.csproj -c Release -o viewer\publish依赖:.NET 10 SDK + .NET 10 桌面运行时(本机已装)。发布物是框架依赖的独立 exe, 换机器需要装 .NET 10 Desktop Runtime;本机直接可用。
关于离线:这台机器访问 nuget.org 会 SSL 失败,所以
NuGet.config指向本地缓存。ExcelViewer.csproj里还把 apphost 指向了 SDK 自带的 packs 目录 (NuGet 缓存里没有 10.x 的Microsoft.NETCore.App.Host.win-x64), 这样 publish 不需要联网。
$exe = 'viewer\publish\ExcelViewer.exe'
# 数据管线自检:逐 sheet 加载、抽查首末行、搜索命中、字符串池统计
& $exe --selftest test\Languages.xlsx test\Item.xlsx
# 性能基准:加载 / 首屏 / 滚动重绘 / 搜索 / 内存
& $exe --bench test\Languages.xlsx --frames=200
# 布局探针:打印列偏移、离屏渲染成 PNG,并用像素断言检查文字有没有被行号栏裁掉
& $exe --layout test\Item.xlsx --png=out.png
# 界面探针:真的建出主窗口、真的打开一个文件,打印窗口里的实际状态并整窗离屏渲染
& $exe --probe 某个表.xlsx --png-window=整窗.png --png=表格.png
# 预览条两种状态各渲染一张(不加参数时就是默认的展开状态)
& $exe --probe 某个表.xlsx --preview --png-window=预览条展开.png
& $exe --probe 某个表.xlsx --preview-off --png-window=预览条收起.png
# 文本渲染器 A/B(TextFormatter 与 FormattedText 的落点对比)
& $exe --textprobe
# 改动比对自检:行对齐的典型用例(插一行 / 删一行 / 改一格 / 无主键 / 重复行)+ SVN 参数解析
# 注意:它会往 %LOCALAPPDATA%\ExcelViewer\svn-diff 写临时副本,沙箱里跑请先设 EXCELVIEWER_WORKSPACE
& $exe --diff-selftest
# 改动比对:无界面输出 JSON(退出码 1 = 有改动)与改动清单
& $exe --diff-json 原.xlsx 新.xlsx --out=diff.json
& $exe --diff-png 原.xlsx 新.xlsx --out=清单.csv --png=预览.png
# 走一遍完整编辑流程(复制副本 + 打开 Excel + 关闭后自动比对)
& $exe --edit test\Item.xlsx造一份「改过几处」的测试数据:
python tools\make_diff_fixture.py test\Item.xlsx $env:TEMP\edited\Item_edited.xlsx.cmd文件里不要出现任何非 ASCII 字符。cmd.exe 用控制台当前代码页(本机 936)解析.cmd,而文件是 UTF-8;chcp 65001只对它执行之后的行生效。所以哪怕只是注释里的一串中文, 字节也会被错误解码、切断后续行,症状非常迷惑:'xxx' is not recognized as an internal or external command、else was unexpected at this time, 甚至把reg import的参数吃掉只剩resolves。 正确做法:.cmd只留纯 ASCII,中文提示与中文路径都交给tools\*.ps1(PowerShell 按 UTF-8 读.ps1,中文安全)。 现在根目录只剩ExcelViewer.cmd和设为默认打开方式.cmd两个.cmd, 它们都只有一行powershell -File tools\*.ps1,实测没有解析问题。reg import的退出码不可靠:即使导入成功也可能返回 1,别用它判断成败, 导入后用reg query/Test-Path复核。- 不要用
TextFormatter画单元格。它的TextRunCache会按TextSource实例复用上一次的 排版结果,即使显式传入 cache 参数也一样:同一个实例连续画不同字符串时,宽度和字形对不上, 表现为「文字被画到别的列去了」。现在用FormattedText+ 按 (字符串, 对齐, 宽度) 的缓存, 既正确又快(滚动时基本命中缓存)。 - 列宽不能直接用全表最大文本宽度。说明性长文本列会把列宽顶到上限,导致所有列一样宽、 一屏看不到几个字段。现在只采样前 80 行(外加保证表头行被采样),且优先用真实字体测量。
PushClip之外还要注意绘制顺序:行号栏、列标是在单元格之后画的,改这块时别把 单元格文字画到行号栏左边去(用--layout --png的像素断言可以立刻发现)。UserChoice改不了:想静默把程序设成默认打开方式是不可能的,别在这上面浪费时间, 直接引导用户去系统设置页确认。- 不要在编辑器还开着时就比对文件:Excel 保存是「写临时文件再替换」, 必须等进程退出 + 文件稳定,否则会读到半个文件。
- 叠在主表格上面的第二张表,默认必须
Visibility.Collapsed:改动比对用的那张 自绘表格(DiffView._grid)是GridHost.Children.Add上去的第二层,Z 序在主表格之后。 它构造时如果是默认的Visible,就会用一张空白表把正常查看的表格整个盖住, 症状非常有迷惑性——状态栏显示「11 行 × 22 列 · 打开耗时 90 ms」,数据、搜索、内存全部正常, 但界面中间只有一句「把 Excel / CSV 拖到这里」,列标、行号栏、单元格一个都看不见 (ExcelGrid.OnRender里sheet is null只有它自己知道)。 定位方法:--probe会打印MainWindow._sheet与ExcelGrid.Model.Sheet, 两个都不为空却仍然画空提示,就是被上层表格盖住了;--png-window把整窗离屏渲染成 PNG,可以直接确认。 修法:构造时Collapsed,DiffView.Show()里Visible,DiffView.Hide()里再Collapsed。 DiffView.Hide()不能顺手把GridHost一起藏掉:GridHost是主表格的宿主, 比对表只是它里面的第二层。藏了它,关掉改动比对后整个数据区会一片空白 (看着像"文件没加载")。只藏DiffView._grid就够。- 比对必须先做行对齐,不能按行列下标硬比:早期版本
CompareSheets就是for r, for c逐格比, 只要中间插入或删除一行,后面所有行的内容都会错位。实测 200 行的表中间插一行会报出 307 处"改动", 配表几千行时 diff 完全没法看——这正是 SVN 对接前必须先解决的事。 现在的做法:先挑一个"非空且互不重复"的列当行主键(配表的 ID 列,采样前 400 行判断), 用「两边都唯一出现」的锚 + 最长上升子序列做保序匹配,锚之间的区间再按"相等的格子数"贪心配对; 没有主键的表退化成整行文本做锚。改这块时一定要跑--diff-selftest,六个用例都是踩过的坑。 - 整行全空的行要在比对前剔掉:配表中间常常夹着大片空行,它们既不是唯一锚、两边又都一样,
会导致"删一行 + 加一行"成对刷屏(实测 31k 行的表刷出 4,879 条假增删)。
现在
AlignRows先用Sheet.IsRowEmpty过滤出有内容的行号再对齐,报出来的行号仍然是原始行号。 - 区间过大时不能做 O(n²) 配对,但也不能不配:贪心配对的工作量上限是 4096 对,
超了要退回"按位置配 + 每对仍过相似度门槛"。如果直接跳过配对,两张完全相同的文件
都会因为重复行(不是唯一锚)而报出几千条假增删——
--diff-selftest里有这个用例。 - svn 传进来的旧版本文件没有扩展名:命令行
svn diff --diff-cmd给的是.svn\pristine\XX\<sha1>.svn-base,TortoiseSVN 的%base也不保证保留扩展名, 而WorkbookLoader.IsSupported是看扩展名的。所以--diff-svn必须按文件头嗅探格式 (zip 再看xl/workbook.bin分 xlsb、看xl/vbaProject.bin分 xlsm;OLE 头当 xls;其余 csv) 并复制成带扩展名的临时副本。另外 svn 会把参数拼成<程序> <额外参数> -L "标签1" -L "标签2" 文件1 文件2,文件路径是最后两个, 而且.xlsx被当成二进制,不加--force根本不会调用外部 diff 工具。 - 单元格文本必须限制成一行(
FormattedText.MaxLineCount = 1+Trimming = CharacterEllipsis): 行高是固定的 22px,而FormattedText只要设了MaxTextWidth就会按列宽自动折行, 多行文本块再被Draw垂直居中,于是第二行开始会画到上下相邻的行上去—— 症状是"格子里几行字叠成一团、糊得看不清"(说明性长文案列最明显)。 注意WorkbookLoader已经把值里的换行压成空格了,所以叠字不是换行引起的,是折行引起的; 网上常见的"把双行合并"的排查方向会白跑。 GridSplitter要拖的是"外面那一行":预览条的分隔线原先放在预览条内部的Grid里, 它拖的是那个内层 Grid 自己的行(Auto/*),而预览条的高度其实由外层行定义决定, 所以拖动完全没反应。正确做法:外层 Grid 开三行(表格*/ 分隔线Auto/ 预览Height),GridSplitter单独占中间那行,并显式写ResizeBehavior="PreviousAndNext"、ResizeDirection="Rows"。- 图标文件要同时声明两次才到处都对:
<ApplicationIcon>app.ico</ApplicationIcon>只负责 把图标嵌进 exe(资源管理器 / 任务栏用的就是它);窗口标题栏走的是 WPF 资源查找, 还得再<Resource Include="app.ico" />一份,否则MainWindow.xaml里的Icon="app.ico"直接以IOException: 找不到资源"app.ico"挂掉——窗口连构造函数都走不完, 现象是运行就崩、--probe也崩。注意加上<Resource>之后必须清一次obj\Release, 增量构建不会把新资源合进.g.resources(Assembly.GetManifestResourceStream("ExcelViewer.g.resources")里能列到app.ico才算真打进去了)。 --textprobe的画布高度要留够:两组样本(TF/FT 各 4 行 × 24px)加分隔线后超过 200px,Scan会越界读像素数组直接抛IndexOutOfRangeException;现在画布 260px,并且Scan自己会夹住 y 范围。