1. 先想清楚:你要的"转换"到底是哪一种
同事把一份 xlsx 甩过来,说"帮我放到内部页面上,手机上也能看"。很多人第一反应是打开 Excel,点"文件 - 另存为 - 网页",然后打开生成的 htm 一看,傻眼了:表格能看,但样式全是乱的,还多出一堆莫名其妙的空行和固定宽度。这个流程我踩过太多次,后来才明白,问题的根子在于——大家嘴里的"excel转html"其实是四个完全不同的需求,混在一起谈,选型必然出错。
先说第一种,静态展示型。表格数据是死的,几个月都不改一次,做出来就是贴在某个页面或者文档里给人看。比如年度考勤表、设备参数表、课程安排表。这种需求最不值钱,但也最容易做过头,有人非得上 Vue 组件加接口,纯属给自己找活干。
第二种,动态数据型。数据源还是 Excel,但每周甚至每天更新,页面必须跟着变。这时候你需要的不是"转换器",而是一条从表格文件到页面的数据管道:谁维护文件、什么时候重新生成、生成失败怎么办,这些流程问题比代码本身重要得多。
第三种,在线可编辑型。用户希望网页上的表格能像 Excel 一样直接改、能加行删列,甚至能算公式。这类需求的正确工具是 Handsontable、Luckysheet 这类前端表格组件,或者干脆嵌一个在线表格服务,而不是把 Word 表格转成 HTML 再硬撑可编辑能力。
第四种,Word 文档内嵌表格型。很多单位要求"网页版通知公告",正文是排版好的 Word,中间夹着几张大表。这种场景下单独转一张表没有意义,得整篇文档一起转,还要保住标题层级、加粗、编号这些结构。这就是 mammoth、pandoc 这类工具的主场。
把这四类分清楚,后面的工具选择就顺了。我见过太多人拿"另存为网页"去应付动态数据需求,结果每周手动重导一次,累得半死还容易出错。
1.1 四种典型需求,路子完全不同
我把这四类需求对应的技术路线和适用工具列一下,你对号入座会快很多。
| 需求类型 | 数据是否变动 | 推荐路线 | 典型工具 |
|---|---|---|---|
| 静态展示 | 基本不变 | 导出后手工清理 | Excel 另存为 htm、在线转换 |
| 动态数据 | 经常更新 | 脚本自动生成 | Python + pandas + 模板 |
| 在线可编辑 | 用户实时改 | 前端表格组件 | Handsontable、Luckysheet |
| 整篇文档 | 正文带表格 | 文档转换工具 | mammoth、pandoc |
需要说明的是,上面这张表是基于常见实践归纳出来的,不是硬性规定。真实项目里经常混用,比如用 Python 生成 HTML 骨架,再在前端交给 DataTables 做排序和分页。关键是先判断"数据会不会变",这一条基本决定了一半的架构。
另外还有个容易被忽视的维度:表格的复杂度。一张 5 列 20 行的规整表,什么工具都能干得不错;但一张带三级合并表头、斜线表头、单元格内换行、脚注的复杂表,任何自动化工具都会掉链子,最后往往要靠人工修。所以拿到文件的第一件事,是先打开看一眼结构有多复杂,再决定投入多少精力。
1.2 从"五分钟交差"到"长期可维护"
我按投入成本把方案排了个顺序,你可以按实际情况往右走。
五分钟级:Excel 另存为网页,改个字符编码,套一段自己的 CSS。适合临时给领导看一眼的场景,缺点是产物脏,维护成本高。
半小时级:用 Python 脚本读表格,输出干净的语义化 HTML 表格,套统一模板。这是我个人用得最多的档位,一次写完脚本,后面所有类似的表都能复用。
半天级:前端方案。xlsx 直接由浏览器解析成 JSON,交给表格组件渲染,支持排序、搜索、导出、分页。适合真的要做成一个"页面功能"而不是"一份文档"的情况。
一天以上:做成一整套流水线。放进定时任务,表格更新自动重建页面,还带异常告警。单位里那种"每周一早上要出新版数据看板"的需求,就该走这条路。
我个人的经验是:先按最低档位交差,再根据被复用的次数决定要不要升级。如果一个转换需求三个月内被提了四次以上,那它就值得脚本化;如果只是偶尔一次,手改反而更快。
1.3 一个判断标准:数据会不会变
这条标准我反复跟同事强调。数据不变,你怎么折腾都行,甚至可以直接截图贴上去;数据会变,那你一定要让"重新生成"这个动作足够廉价,最好是一行命令或者点一个按钮。
很多人失败在这里:花两天做了一个漂亮的网页表格,结果第二个月数据更新了,发现当初是手工调的对齐和配色,改起来比重做还累,最后这个页面就烂在那儿了。所以只要有一点点"以后还会更新"的可能性,我就会选择脚本化生成,哪怕第一版看起来朴素一点。
提示:判断"会不会变"的时候别问提需求的人,问维护这份表的人。提需求的人通常会说"就这一版",而真正做表的人心里清楚下个月还要再报一次。
2. 零代码路线:Excel/Word 另存为网页,以及它的真实产物
这条路争议最大。有人觉得它是玩具,有人天天在用。我的看法是:它适合的场景比你想的多,但你必须知道它生成的到底是什么东西,否则清理起来比重做还费劲。
Excel 里点"另存为",文件类型选"网页(.htm;.html)",会弹两个选项:"整个工作簿"和"选择"。选"整个工作簿"会把每个工作表单独存成一个 htm 页面,再附一个总的导航页;选"选择"只导出当前选中区域。保存完之后你会发现磁盘上多了一个同名文件夹,比如报表.htm和报表.files。
这个.files文件夹里装着sheet001.htm、sheet002.htm、tabstrip.htm、stylesheet.css、filelist.xml,如果有图片还有image001.png。也就是说,Excel 不是简单地把你看到的表格打印成 HTML,而是生成了一整套"网页版 Excel 查看器",包括底部的工作表标签页。这就是为什么打开以后会长得那么奇怪。
2.1 Excel 另存为 htm 之后,文件夹里到底生了什么
我用文本编辑器打开过生成的 sheet001.htm,内容大致是这样的结构:开头是 HTML 4.01 Transitional 的声明,然后是<meta name=ProgId content=Excel.Sheet>和<meta name=Generator content="Microsoft Excel 11">这类标记,接着是一大段<style>,里面塞满了td { mso-number-format:...; }、.xl24 { ... }这类只在 Office 里才有意义的样式定义。
表格本身是普通的<table>,但每个单元格上挂着x:str、x:num这样的自定义属性,用来告诉 Office "这一格是文本、那一格是数字"。表头之类的地方还会用<x:ExcelWorkbook>把一份 XML 数据岛嵌在页面里,浏览器根本不认识,只是当普通文本晾在那儿。
还有几个烦人的点:一是宽度被写死成像素,比如width=86,你换个屏幕就错位;二是大量使用 来撑空单元格,代码体积大得离谱;三是字符编码,老版本 Excel 导出默认可能是 gb2312,你直接上传到服务器,浏览器按 utf-8 解析,中文就全变乱码了。
2.2 Word 另存为网页,多出来的那些 Mso 样式
Word 的导出思路和 Excel 类似,但更"文档化"。你点"另存为 - 网页",同样会生成.files文件夹,里面是filelist.xml、header.htm、样式表和图片。正文里的段落会变成<p class=MsoNormal>这种带 Mso 前缀的类名,标题变成<h1>、<h2>,表格变成<table class=MsoTableGrid>。
它比 Excel 稍微友好一点的地方在于,Word 会替你保留大致的文档结构,标题层级、加粗、斜体、列表基本都还在。麻烦的是样式全靠外部 CSS 类,脱离了那个.files文件夹,页面就变成一堆没有样式的裸文本。而且 Word 表格的列宽是用<td width=...>直接写在标签上的,这就是为什么很多人抱怨"word 表格列宽无法拖动"——它压根就不是用 CSS 控制的,宽度被硬编码了。
另外一个常见问题是,Word 里能看到的分页符、分节符,在导出的 HTML 里会变成莫名其妙的<br clear=all style='page-break-before:always'>,页面上就是一大片空白。还有文本框、艺术字这些,会被转成v:shape加 VML,现代浏览器基本不认。
2.3 三步清理法:把导出结果洗成能看的样子
我通常按三步走,能应付大部分临时需求。
第一步,换编码、干掉 meta。把<meta http-equiv=Content-Type content="text/html; charset=gb2312">整行换成<meta charset="utf-8">,然后用编辑器另存为 UTF-8。这一步不做,后面全白干。
第二步,剥皮。用正则把没用的东西批量删掉。我在 VS Code 里常用的几条:
# 删除 mso 相关的 style 块(跨行,需要在支持正则的编辑器里操作) <style[^>]*>[\s\S]*?<\/style> # 删除 xml 数据岛和 v: 开头的 VML 标签 <x:ExcelWorkbook[\s\S]*?<\/x:ExcelWorkbook> <v:[^>]*>|<\/v:[^>]*> # 删除 style 属性里的 mso 声明 \s*mso-[a-z-]+:[^;'"]+;? # 把 换成普通空格 正则这块要小心,别一刀切把有用的style也删了。我一般先在副本上跑一遍,用浏览器打开对照原表检查有没有丢内容。
第三步,套模板。把自己的 CSS 覆盖上去,把width属性全删掉,改成table-layout: auto或者干脆用百分比。这一步做完,页面至少能看了。
注意:如果你的 Excel 在导出时卡住不动,或者"另存为"对话框里根本选不到网页格式,先检查是不是装了什么第三方加载项。我遇到过 excel 加载项冲突导致导出菜单灰掉的情况,把加载项全部禁用再试一次通常就好了。
2.4 LibreOffice 命令行:批量转换的省事选择
如果你不想装 Office,或者要在 Linux 服务器上批量处理,LibreOffice 的 headless 模式很好用。装好之后一条命令就能转换:
# 单个文件转换 soffice --headless --convert-to html --outdir ./output ./报表.xlsx # 批量转换当前目录下所有 xlsx for f in *.xlsx; do soffice --headless --convert-to html --outdir ./output "$f" done出来的 HTML 比 Excel 自己导出的干净不少,至少没有那一堆x:str和 XML 数据岛,样式也简单得多。缺点是对复杂格式(图表、条件格式)还原度一般,而且第一次运行会初始化用户配置,比较慢,跑批的时候最好加个-env:UserInstallation指定临时配置目录,避免多进程互相抢配置。
这条路我一般用在"客户给了一堆 xlsx,要求先出个初步预览"的场景,快速出结果,后续再决定要不要精修。
3. 脚本路线:用 Python 把 xlsx 和 docx 洗成干净 HTML
只要你写过一点 Python,这条路就比手工清理靠谱得多。核心思路是:用专门的库把表格读成结构化数据,再用模板自己拼 HTML,而不是让 Office 帮你生成一堆带 Mso 前缀的垃圾。
3.1 工具选型:pandas、openpyxl、python-docx、mammoth 各管一段
这几个库的关系很多人搞不清,我用一句话概括各自的分工。
pandas负责"读数据、算数据"。read_excel一行就能把表格读成 DataFrame,做筛选、透视、汇总都方便,最后to_html直接出表格 HTML。缺点是对格式信息(合并、颜色、列宽)几乎全丢,只保值和结构。
openpyxl负责"读格式"。它能拿到工作表的合并单元格范围、单元格字体、填充色、甚至列宽,需要保留原表样式的时候必须用它。速度比 pandas 慢,但对 .xlsx 支持最完整。
python-docx负责读 Word 的结构。段落、样式名、表格、单元格文本都能拿到,适合需要精确控制输出结构的场景。
mammoth负责"把 docx 转成简洁 HTML"。它走的是语义化路线,只保留标题、加粗、列表、表格这些结构,把 Word 的样式全部丢掉,映射成干净的标签。我自己最常用的就是它,配合 style map 还能把自定义样式名映射成 class。
还有个挺实用的组合:先用 mammoth 出一版干净的 HTML 骨架,再用 BeautifulSoup 做后处理,把表格加上 class、把宽高属性删掉。这套打法我用了两三年,处理通知公告类文档特别顺手。
3.2 Excel 转 HTML 的最小可用实现
先说最简单的版本,用 pandas,五行代码:
import pandas as pd df = pd.read_excel("报表.xlsx", sheet_name="Sheet1", dtype=str) html = df.to_html(index=False, border=0, escape=False, classes="data-table") with open("output.html", "w", encoding="utf-8") as f: f.write(f"""<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>报表</title> <link rel="stylesheet" href="table.css"> </head> <body> {html} </body> </html>""")几个关键点必须说清楚。dtype=str很重要,不加的话 pandas 会自作聪明地把身份证号、订单号这类长数字转成科学计数法或者浮点数,出过事故。escape=False要谨慎,它允许单元格内容里的 HTML 标签被渲染,如果数据来源不可信,会有注入风险,安全场景下应该保持 True,把<转义。index=False去掉左侧的行号列,除非你真的需要。
to_html生成的表格默认带border="1"这种老式属性,用border=0去掉,样式交给 CSS。另外它会给每个单元格加text-align: right之类的内联样式(数字列右对齐),如果你想要纯 CSS 控制,可以自己遍历 DataFrame 生成表格,代码也不长:
def df_to_table(df): head = "".join(f"<th>{c}</th>" for c in df.columns) rows = [] for _, row in df.iterrows(): cells = "".join(f"<td>{v}</td>" for v in row) rows.append(f"<tr>{cells}</tr>") return (f"<table class='data-table'><thead><tr>{head}</tr></thead>" f"<tbody>{''.join(rows)}</tbody></table>")这段代码没有任何依赖,输出干净,适合放进模板文件反复用。
3.3 合并单元格怎么还原:openpyxl 的 merged_cells
这是脚本路线里最容易翻车的地方。pandas 读表格时,合并单元格只有左上角有值,其余位置是NaN,导出后就是一片空的<td>,表头会变得没法看。
正确做法是用 openpyxl 拿到合并范围,然后在输出时给对应的<td>加上rowspan和colspan。核心代码是这样:
from openpyxl import load_workbook from openpyxl.utils import get_column_letter wb = load_workbook("报表.xlsx") ws = wb["Sheet1"] # 建立一个"被合并覆盖"的坐标集合,这些格子不需要输出 covered = set() spans = {} # (row, col) -> (rowspan, colspan) for rng in ws.merged_cells.ranges: min_row, min_col = rng.min_row, rng.min_col max_row, max_col = rng.max_row, rng.max_col spans[(min_row, min_col)] = (max_row - min_row + 1, max_col - min_col + 1) for r in range(min_row, max_row + 1): for c in range(min_col, max_col + 1): if (r, c) != (min_row, min_col): covered.add((r, c)) for r in range(1, ws.max_row + 1): cells = [] for c in range(1, ws.max_column + 1): if (r, c) in covered: continue value = ws.cell(row=r, column=c).value value = "" if value is None else str(value) if (r, c) in spans: rs, cs = spans[(r, c)] attr = "" if rs > 1: attr += f' rowspan="{rs}"' if cs > 1: attr += f' colspan="{cs}"' cells.append(f"<td{attr}>{value}</td>") else: cells.append(f"<td>{value}</td>") print(f"<tr>{''.join(cells)}</tr>")逻辑不复杂,但有两个细节要注意。一是合并范围的坐标是 1-based,跟 openpyxl 的cell(row=..., column=...)一致,别拿 0-based 的索引去套。二是判断顺序,先判断"是否被覆盖"再判断"是否是合并起点",顺序反了会出错。
我还碰到过嵌套合并的情况,也就是一个合并区域里又套了小的合并区域,openpyxl 的merged_cells.ranges在正常情况下不会返回重叠区域,但如果文件是别的工具生成的,可能会出现异常。稳妥的办法是先对 ranges 排序,按面积从大到小处理,发现重叠就跳过并记一条日志。
3.4 Word 表格转 HTML:python-docx 与 mammoth 的取舍
Word 这块要分两种情况。如果只是提取里面的表格,用 python-docx 更直接:
from docx import Document from bs4 import BeautifulSoup doc = Document("通知.docx") soup = BeautifulSoup("<html><body></body></html>", "html.parser") for t_idx, table in enumerate(doc.tables): html_table = soup.new_tag("table", **{"class": "doc-table"}) for row in table.rows: tr = soup.new_tag("tr") for cell in row.cells: td = soup.new_tag("td") td.string = cell.text.strip() tr.append(td) html_table.append(tr) soup.body.append(html_table) print(soup.prettify())这里有个坑:python-docx 对合并单元格的处理比较粗糙,row.cells会把被合并覆盖的格子也返回一遍,值重复。如果表格里有合并,得配合cell._tc底层的gridSpan、vMerge属性来判断,代码会复杂不少。所以简单表格用它,复杂表格我一般换工具。
如果目标是整篇文档转 HTML,直接上 mammoth:
import mammoth with open("通知.docx", "rb") as f: result = mammoth.convert_to_html( f, style_map=""" p[style-name='标题一'] => h1:fresh p[style-name='标题二'] => h2:fresh p[style-name='正文缩进'] => p.indent """ ) with open("notice.html", "w", encoding="utf-8") as out: out.write(f"""<!DOCTYPE html><html lang="zh-cn"><head> <meta charset="utf-8"><title>通知</title> </head><body>{result.value}</body></html>""") for msg in result.messages: print(msg.type, msg.message)style_map是 mammoth 的精髓,把 Word 里的中文样式名映射成 HTML 标签和 class,这样出来的页面结构干净,后期套 CSS 特别方便。result.messages里会列出它丢弃了哪些不支持的样式,跑完记得看一眼,能发现不少被悄悄忽略的内容。
3.5 批量处理脚本:扫目录、出报告、写日志
实际工作里很少只转一个文件。我常用的批处理骨架大概长这样:
import os import traceback from pathlib import Path SRC = Path("./input") OUT = Path("./output") OUT.mkdir(exist_ok=True) success, failed = [], [] for path in sorted(SRC.glob("*.xlsx")): try: html = convert_excel(path) # 前面写的转换函数 target = OUT / f"{path.stem}.html" target.write_text(html, encoding="utf-8") success.append(path.name) except Exception as e: failed.append((path.name, str(e))) traceback.print_exc() print(f"成功 {len(success)} 个,失败 {len(failed)} 个") for name, err in failed: print(f" - {name}: {err}")几个实践经验。跳过带~$前缀的临时文件,那是 Excel 打开文件时生成的锁文件,读到会报错。输出文件名要用path.stem而不是直接拼字符串,避免路径里有中文和空格时出问题。日志建议写文件,尤其是放进定时任务之后,出问题只能靠日志回溯。
4. 前端路线:把数据交给页面渲染,而不是塞整段 HTML
如果你的表格最终要变成一个真正"能用"的页面——能排序、能搜索、能翻页、能导出——那就别在后端生成死 HTML 了,直接把数据喂给前端。这条路看起来麻烦,实际维护成本最低。
4.1 为什么"数据 + 模板"比"整段 HTML"活得久
我做过对比。方案 A 是后端生成完整 HTML 表格,前端直接插进页面;方案 B 是后端只吐 JSON,前端用组件渲染。半年后回头看,A 方案的页面基本没人敢动,因为改一个表头要动 Python 代码、重新部署;B 方案的页面改个列顺序,前端同学几分钟就搞定。
更重要的是数据与展示分离。同一份 JSON,PC 端用表格渲染,移动端换成卡片列表,打印时再换一版布局,都不用重新处理数据。这就是为什么我后来所有的表格需求都往这个方向走。
还有个现实好处:Excel 文件本身可以直接由浏览器端的 SheetJS 解析,不用经过后端。这样连服务器都不用,把文件拖进页面就能出结果,特别适合做内部小工具。
4.2 SheetJS 五分钟上手:xlsx 直接读成 JSON
SheetJS 的社区版叫 xlsx,引入一个脚本文件就能用:
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>表格预览</title> <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script> </head> <body> <input type="file" id="file" accept=".xlsx,.xls"> <div id="result"></div> <script> document.getElementById('file').addEventListener('change', async (e) => { const f = e.target.files[0]; const buf = await f.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; // header:1 表示第一行不当作表头,返回二维数组 const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); render(rows); }); function render(rows) { const [head, ...body] = rows; let html = '<table class="data-table"><thead><tr>'; head.forEach(c => html += `<th>${esc(c)}</th>`); html += '</tr></thead><tbody>'; body.forEach(r => { html += '<tr>'; head.forEach((_, i) => html += `<td>${esc(r[i] ?? '')}</td>`); html += '</tr>'; }); html += '</tbody></table>'; document.getElementById('result').innerHTML = html; } function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } </script> </body> </html>这段代码从头到尾只用了浏览器能力,双击文件就能运行。几个要点:header: 1让返回结果是二维数组而不是对象数组,处理合并单元格和空列时更直观;defval: ''保证空单元格不变成undefined,否则渲染时会出现 "undefined" 字样;转义函数必须写,表格内容里出现<或&会直接把页面结构搞崩。
如果你想要的是"把数据直接导出成 HTML 字符串",SheetJS 也提供了XLSX.utils.sheet_to_html(ws),输出的 HTML 带x:str那套 Office 风格属性,我一般不用它,自己拼更可控。
4.3 大表格的性能账:分页、虚拟滚动与 DataTables
表格一上千行,浏览器就开始卡了。这时候有三种处理方式,适用场景不一样。
分页最简单,DataTables 一行初始化就能搞定:
$('#myTable').DataTable({ paging: true, pageLength: 50, lengthMenu: [20, 50, 100, 200], searching: true, ordering: true, language: { search: '搜索:', lengthMenu: '每页 _MENU_ 条', info: '第 _START_ 到 _END_ 条,共 _TOTAL_ 条' } });DataTables 的坑主要在两个地方。一是列宽抖动:初始化完成后表格宽度会跳一下,解决办法是给 table 加style="width:100%; table-layout:fixed",并在每一列上用width或者 CSS 指定基础宽度。二是中文字体排序:默认的字符串排序对中文是按 Unicode 码点来的,跟拼音顺序不一样,需要引入dataTables.chineseSort之类的插件,或者自己在columns.render里处理。
虚拟滚动适合"必须一屏看到很多行"的场景,只渲染可视区域内的行,滚动时动态替换。前端表格组件 Handsontable 内置了这个能力。缺点是滚动条位置和内容高度要自己算,代码复杂度直线上升,不是特别必要我不建议上。
服务端分页适合十万行以上的场景,前端只请求当前页数据。这时候 Excel 就不该进浏览器了,应该在后端解析入库,前端走接口。
4.4 表头冻结、列宽、换行这几个细节的处理
这几个细节决定了表格"能不能用"。表头冻结用 CSS 就能做:
.data-table thead th { position: sticky; top: 0; z-index: 2; background: #f5f7fa; }注意sticky生效的前提是父容器不能有overflow: hidden,而且要有可滚动的祖先元素。我踩过最深的坑是给外层套了个overflow: auto的 div,结果 sticky 失效,排查了半天。
列宽这块,我一般先用table-layout: fixed让浏览器按设定值分配,再针对特定列用min-width和max-width约束。单元格内换行用word-break: break-all处理超长英文和数字,用overflow-wrap: anywhere兼顾中文。但如果单元格里有很长的连续数字(比如流水号),强行断行会很难看,这时候改成white-space: nowrap加横向滚动更好。
4.5 行合并与树形结构:前端怎么补回来
前面说过,Excel 里的合并单元格在数据层面会变成"只有第一个格子有值"。前端拿到 JSON 之后,需要自己判断哪几行的第一列相同,然后把它们合并。思路是遍历数据,记录连续相同值区间的起点和长度:
function mergeFirstColumn(rows) { const spans = []; let start = 0; for (let i = 1; i <= rows.length; i++) { if (i === rows.length || rows[i][0] !== rows[start][0]) { spans.push({ start, count: i - start }); start = i; } } return spans; }拿到 spans 之后,渲染时对每个区间的第一行加rowspan,其余行跳过该列。逻辑不复杂,但要注意数据必须先按合并列排序,否则相同值不连续,合并不出来。这也是为什么很多在线表格工具在导入时会提示"请先按合并列排序"。
如果你用的是 Ant Design Vue 这类组件库,行合并是通过customRender里返回{ rowSpan: n }实现的,原理一样,只是写法不同。我个人的建议是:如果原表里有大量合并,先在数据层把合并信息展开成独立列再处理,比如把"一级部门/二级部门"拆成两列,每行都填满。展示时再按需合并,这样数据处理逻辑会简单很多,导出回 Excel 也不会丢信息。
5. 样式与打印:别让页面输在"看起来不专业"
内容转对了只是及格,样式不行照样被退回。这一节讲几个我反复用的技巧。
5.1 一套能反复用的表格 CSS
我有一份用了好几年的基础样式,几乎所有表格项目都从它开始:
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; line-height: 1.6; table-layout: auto; } .data-table th, .data-table td { border: 1px solid #dcdfe6; padding: 8px 12px; text-align: left; vertical-align: top; word-break: break-word; } .data-table thead th { background: #f5f7fa; font-weight: 600; white-space: nowrap; } .data-table tbody tr:nth-child(even) { background: #fafbfc; } .data-table tbody tr:hover { background: #eef4ff; } .data-table td.num { text-align: right; font-variant-numeric: tabular-nums; }几个细节值得说明。border-collapse: collapse让相邻边框合并,比默认的separate好看得多,也能避免单元格之间出现缝隙。font-variant-numeric: tabular-nums让数字等宽,一列数字对齐后视觉上整齐很多,这个小技巧知道的人不多但效果明显。斑马纹和 hover用来解决"一行太长看串行"的问题,行数超过十行就建议加上。
如果你要为打印单独出一版,把这些样式包在@media screen里,打印样式另写一份,避免屏幕上的 hover 效果被印出来。
5.2 打印成 PDF 的分页控制
表格打印最烦的是表头只在第一页出现,后面几页不知道每列是什么。CSS 里有个属性专门解决这个问题:
@media print { @page { size: A4 landscape; margin: 12mm 10mm; } .data-table thead { display: table-header-group; } .data-table tr { break-inside: avoid; page-break-inside: avoid; } .no-print { display: none !important; } }display: table-header-group让表头在每一页重复,这是最关键的一条。break-inside: avoid防止一行被从中间劈开。@page里用landscape横向打印,宽表会舒服很多。
打印中文的时候还有个常见问题:字体渲染出来发虚或者变形。我的经验是显式指定中文字体栈,别依赖系统默认:
body { font-family: "PingFang SC", "Microsoft YaHei", "Source Han Sans SC", "Noto Sans CJK SC", sans-serif; }另外,如果你是在浏览器里按 Ctrl+P 直接打印,记得在打印对话框里关掉"页眉和页脚",否则每一页会多出网址和日期,很不专业。
5.3 手机上怎么让它别那么难用
移动端看宽表格基本没救,除非横向滚动。我的做法是给表格套一个滚动容器:
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-wrap .data-table { min-width: 720px; }给表格设一个最小宽度,让它撑开容器触发滚动,而不是把列压得只有几个字宽。同时在滚动容器上加一个右侧渐变遮罩,提示用户"右边还有内容",这个细节体验提升很明显。
如果列数不多(五六列以内),我更推荐在窄屏下把表格转成卡片列表,每行变成一张小卡片,字段名做标签。转换不复杂,用媒体查询加 CSS 或者前端渲染时切换模板都行。唯一要注意的是,卡片布局下"一行"的概念消失了,排序和对比会变难,所以只适合"逐条查看"的场景。
6. 踩坑实录:那些年把我卡住的表格问题
这一节全是血泪。我把高频问题整理成速查表,再单独讲几个排查起来特别费劲的。
6.1 常见问题速查表
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 中文显示为乱码 | 源文件是 gb2312,页面按 utf-8 解析 | 统一转 UTF-8,声明<meta charset="utf-8"> |
| 长数字变成 1.23E+11 | pandas 自动推断为数值型 | 读取时加dtype=str |
| 表头不见了 | 用header=1却没设表头行 | 明确header=0或自己拼 thead |
| 单元格内容被当标签渲染 | 未做 HTML 转义 | 输出前转义& < > " |
| 表格宽度撑破页面 | 存在硬编码 width 属性 | 删除 width,改用 CSS 控制 |
| 合并单元格显示为空 | 只有左上角有值 | 用 openpyxl 读 merged_cells 补 rowspan |
| 打印时表头只在第一页 | 未设置 header-group | 加display: table-header-group |
| 导出后多出空白页 | Word 的分页符被转成 br | 删掉page-break-before相关标签 |
| Excel 打开很慢卡住 | 单元格里有大量公式或条件格式 | 先复制成数值粘贴再转换 |
| 复制到别处丢格式 | 用了 div 布局而非 table | 表格数据坚持用语义化 table 标签 |
这张表里前四条几乎占了日常问题的八成,遇到报错先对着查一遍,能省不少时间。
6.2 编码、特殊字符与"隐形空格"
编码问题我在前面提过,这里补充一个更隐蔽的情况:文件内容明明是 UTF-8,但开头多了个 BOM。Windows 上生成的 csv 和部分导出的 html 会带\ufeff,表现为页面顶部多出一个奇怪的字符,或者 JSON 解析直接报错。处理方式是读写时用utf-8-sig编码:
with open("data.csv", encoding="utf-8-sig") as f: content = f.read() with open("out.html", "w", encoding="utf-8-sig") as f: f.write(content)特殊字符里最坑的是三类。一是不换行空格 ,它在编辑器里看起来跟普通空格一样,但会导致字符串比较失败,处理 Excel 导出的内容时要先替换掉。二是各种连字符和引号,Word 会自动把直引号变成弯引号“”,代码里按直引号搜就搜不到。三是全角空格\u3000,常见于从网页复制到 Excel 的数据,肉眼几乎看不出来。
统一处理的办法是先清洗一遍:
import re def clean(text): if text is None: return "" text = str(text) text = text.replace("\u00a0", " ").replace("\u3000", " ") text = text.replace("\u201c", '"').replace("\u201d", '"') text = text.replace("\u2018", "'").replace("\u2019", "'") text = re.sub(r"[ \t]+", " ", text) return text.strip()这段清洗函数我几乎是复制粘贴到每个项目里的,加上它之后,很多"看起来莫名其妙"的问题自动消失了。
6.3 看起来像 bug、其实不是的几个现象
有几个现象我前几年反复以为是程序出错,后来才明白是正常行为。
一是 Excel 里的日期变成了 45000 这样的数字。这不是 bug,是 Excel 的日期序列号,1900 年 1 月 1 日是 1。读取时要么用parse_dates转换,要么手动算datetime(1899, 12, 30) + timedelta(days=n)。注意这个基准日是 1899-12-30 而不是 1 月 1 日,因为要补偿 Excel 早期的一个闰年 bug,套错基准日会差两天。
二是合并单元格读出来值是 None。前面讲过了,openpyxl 只在左上角存值。处理方式是读出来后向下、向右填充一遍,很多人以为是自己代码写错了。
三是 Word 导出的 HTML 里段落间距特别大。那是MsoNormal样式自带的margin在起作用,你的 CSS 没覆盖到它,因为内联样式的优先级更高。解决办法是在自己的样式里加!important,或者干脆把内联 style 属性全部剥掉。
四是明明文件里有表格,python-docx 读出来doc.tables是空的。大概率是表格被放在了文本框或嵌套表格里,python-docx 不递归查找。用 mammoth 或者直接解压 docx 看document.xml反而更快确认。
提示:排查文档类问题时,把
.docx或.xlsx后缀改成.zip然后解压,能看到里面的 XML 原始内容。这个技巧帮我定位过很多"库读不到"的疑难问题,尤其适合搞不清楚到底是文件损坏还是库不支持的时候。
7. 我自己的流转方案与几个延伸玩法
把这几年用过的方法串一下,就是我现在处理这类需求的固定流程。
拿到文件先看两件事:数据会不会更新、表格有多复杂。只在 Word 里看一眼就完事的,直接 mammoth 转一版,套上样式收工。要做成长期页面的,走 SheetJS 前端方案,文件放固定位置,页面刷新自动读取。要做成报表批量分发的,Python 脚本加定时任务,转完之后自动打包或者推送到内部平台。
中间还有几个我觉得挺有意思的延伸方向,简单提一下。
反向转换。日常除了 Excel 转 HTML,还有个高频需求是 markdown 表格转 Excel。我一般的做法是先用正则把 markdown 表格解析成二维数组,再用 openpyxl 写入,几十行代码搞定,比找在线工具靠谱,毕竟数据不用上传到别人服务器。
嵌入到桌面程序里。如果你用 PyQt5 之类的框架做工具,展示 HTML 表格可以用QWebEngineView或者QTextBrowser。前者是完整浏览器内核,样式支持好但打包体积大;后者轻量,只支持有限的 HTML 子集,复杂 CSS 会失效。数据量不大的配置表,用QTextBrowser足够了。
带公式的表格要怎么处理。Excel 里的公式在另存为网页时会变成计算后的值,公式本身丢失。如果业务上需要保留公式,那就不能走 HTML 转换这条路,得考虑把公式以文本形式单独输出一列,或者干脆保留原文件让用户下载。
移动端分发。如果表格要发到手机上看,很多人第一反应是通过聊天工具发文件,但接收方打开体验很差。更合适的做法是把生成的 HTML 页面挂到内部服务器上生成一个链接,直接发链接。这样手机上点开就是适配好的页面,不用装任何东西。
关于表格列宽那个老问题,再多说一句。不管你用哪种方案,尽量别在 HTML 里保留像素宽度。像素宽度是照着某台电脑的屏幕算出来的,换个分辨率就错位。用百分比、min-width配合自适应,或者干脆交给浏览器自动算,出问题的概率小得多。如果确实需要固定列宽,定义一组 CSS class 按内容类型分配(比如"名称列 30%、数量列 10%"),比逐格写宽度好维护一百倍。
我个人在这么多次转换里最深的体会是:转换本身从来不是难点,难的是判断哪种转换值得做。同样一份考勤表,发给领导看一眼和做成每月自动更新的看板,工作量和方案完全是两回事。先把需求类型分清楚,再动手,能省下大量返工。至于工具,Python 加一点前端,基本能覆盖你遇到的九成场景,真没必要去追那些花里胡哨的一键转换服务,数据传到别人服务器上,后面要处理的事情可能比转换本身更麻烦。