Javascript 是如何解析 Excel 文件的?
最近要做一个导入导出 Excel 的功能,上一次做这个功能的时候,还是用的 Java Apache POI,这是一个用 Java 编写的免费开源的跨平台的 Java API,能够对 Microsoft Office 格式文件进行读和写。整体感受就是,做简单场景的 Excel 导入导出还可以,但是如果需要加一些复杂交互功能的话,前后端交互就比较繁琐了,可能需要来来回回好几次请求(比如字段映射,预览,隐藏行列等场景)。
去网上查了下,发下了一个叫做 sheetjs 的前端库,号称是一个用于多种电子表格格式的解析器和编写器,github 上的使用文档很全。大概使用了下,功能确实强大,感慨作者 NB 的同时,顺带看看这么神奇的功能是如何实现的。
本文为大家讲解 Excel 文档的解析方法,所涉及到的代码都来自 sheetjs 源码。
Excel 解析原理
我新建了一个名称为 “user.xlsx” 的 Excel 文件,在 Sheet1 上建立了如下表格,然后保存。
| A B C D E
__|_______________________________________________
1 | Name Age Birthday Hobby
__|_______________________________________________
2 | Tom 29 1901/7/7 Music Football
__|_______________________________________________
|
xlsx 文件是个二进制文件,使用文本解析的方式只能得到一大堆乱码,那 JS 是如何解析这个文件呢?微软的官方网站有一篇文章 Word, Excel, and PowerPoint Standards Support 中说明了 Word、 Excel 和 PowerPoint 遵循 Open Document Format (ODF) 和 Open XML 协议,并给出了协议的主要约定。
Excel(.xlsx)文件格式遵循的是 Office Open XML 规范,这个规范最初是 Office 2007 产品开发的技术规范,现已成为国际文档格式标准[ISO / IEC-29500-3]。
简而言之,Excel(.xlsx)文件是遵循某种规范的 XML 文件集合。
是不是感觉那里有点不对劲,xml 是个文本协议,而 xlsx 文件明明是个二进制文件,怎么都挨不到一块去。
那是因为, Excel(.xlsx)文件是 XML 文件集合经过 zip 打包后的结果。
Excel(.xlsx)文件可以通过压缩软件进行解压缩。以 user.xlsx 为例,解压缩后得到如下文件夹。
|-- user
|-- _rels
|-- .rels
|-- docProps
|-- app.xml
|-- core.xml
|-- xl
|-- _rels
|-- workbook.xml.rels
|-- printerSettings
|-- printerSettings1.bin
|-- printerSettings2.bin
|-- printerSettings3.bin
|-- theme
|-- theme1.xml
|-- worksheets
|-- sheet1.xml
|-- sheet2.xml
|-- sheet3.xml
|-- sharedStrings.xml
|-- styles.xml
|-- workbook.xml
|-- [Content_Types].xml
可以看出,Excel(.xlsx)文件经过解压后得到同名文件夹,文件夹中按照一定的规则放置了一些 XML 文档。XML 是一种标记语言,很类似 HTML。此时我们解析 xlsx 文件变成了按照 Open XML 协议的规定解析 XML 文件,难度大大降低。
我们回过头来看看 sheetjs 读取 Excel(.xlsx) 文件逻辑,上传一个 Excel 文件后,sheetjs 会调用 readSync 方法读取这个二进制流。通常,压缩文件包括文件头信息 + 文件压缩数据,其中,文件头信息可以用来确定这个文件的类型,ZIP 文件的文件头是 0X04034B50,只要满足这个头,那文件就是 ZIP 压缩文件。
function readSync(data, opts) {
switch((n = firstbyte(d, o))[0]) {
case 0x50: return (n[1] === 0x4B && n[2] < 0x09 && n[3] < 0x09) ? read_zip(d, o) : read_prn(data, d, o, str);
function read_zip(data, opts) {
var zip, d = data;
var o = opts||{};
if(!o.type) o.type = (has_buf && Buffer.isBuffer(data)) ? "buffer" : "base64";
zip = zip_read(d, o);
return parse_zip(zip, o);
所以,sheetjs 将这个 Excel 文件 zip 解压后,得到一个同名文件夹和若干 XML 文件,后续它会解析这些 XML 文件,这就是 JS 解析 Excel 文件的原理。
Excel 解析流程
Excel 文件解析流程实际上是 JavaScript 语言实现 Office Open XML 规范的过程 。目前这个规范已经是国际标准了,详见 ISO / IEC-29500-3 。文档的解析过程比较繁琐,流程大致如下(由于我的 Excel 没有使用函数,宏命令等高级功能,流程有可能不全)。
总体而言, Excel 解析遵循 由大到小,由总到分 的规则。
1. 解析 [Content_Types].xml 文件
[Content_Types].xml 文件是一个总体资源文件列表,该文件中描述了这个 excel 文件中的 xml 资源的路径,以及遵守的规范。我们截取一部分源码如下:
...
<Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
...
可以看出,在 Override 节点下,PartName 属性表示某个文件的路径,ContentType 表示该文件的类型,实质是该文件遵守的规范,比如 application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml 表示该文件用于描述 Excel Sheet,这个 XML 文件必须按照特定的规则来描述 sheet,否则就会解析错误。
解析 [Content_Types].xml 文件后得如下对象。该对象展示了当前 Excel 文件中不同作用的 XML 文件的路径。后续会根据这个文件中的路径信息,依次处理不同规则的 XML 文件。
{
TODO: [],
calcchain: "",
charts: [],
comments: [],
coreprops: ["/docProps/core.xml"],
custprops: [],
defaults: {
bin: "application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings",
rels: "application/vnd.openxmlformats-package.relationships+xml",
xml: "application/xml"
dialogs: [],
drawings: [],
extprops: ["/docProps/app.xml"],
links: [],
macros: [],
rels: Array(0),
sheets: (3) ["/xl/worksheets/sheet2.xml", "/xl/worksheets/sheet3.xml", "/xl/worksheets/sheet1.xml"],
sst: "/xl/sharedStrings.xml",
strs: ["/xl/sharedStrings.xml"],
style: "/xl/styles.xml",
styles: ["/xl/styles.xml"],
themes: ["/xl/theme/theme1.xml"],
vba: [],
workbooks: ["/xl/workbook.xml"],
xmlns: "http://schemas.openxmlformats.org/package/2006/content-types"
}
2. 解析 style, theme 以及 sharedStrings
theme 文件中包含了这个 Excel 中主题的默认配置,比如默认的字体,默认的字号,默认行间距等等。
style 记录当前工作簿的设置。
解析后同样会生成 theme 和 style 对象,就不细看了。
这里只关注下 sharedStrings,这个文件表示的是 sheet 中用到的一些字符串。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="7" uniqueCount="7">
<si><t>age</t></si>
<si><t>name</t></si>
<si><t>Tom</t></si>
<si><t>birthday</t></si>
<si><t>bobby</t></si>
<si><t>music</t></si>
<si><t>football</t></si>
</sst>
这里最终会转换为字符串数组。后续处理 Sheet 的 XML 文件的时候会用到。
3. 解析 workbook.xml 文件
workbook.xml 位于 "/xl/workbook.xml",该文件用于描述 Excel 工作簿的相关属性,比如当前工作簿大小(workbookView),包含几个工作表(sheets),每个工作表(sheets)的外键(用于关联到 sheet 的具体属性)是多少等等。我们可以
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<fileVersion appName="xl" lastEdited="4" lowestEdited="4" rupBuild="4505"/>
<workbookPr filterPrivacy="1" defaultThemeVersion="124226"/>
<bookViews>
<workbookView xWindow="0" yWindow="90" windowWidth="19200" windowHeight="11640"/>
</bookViews>
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
<sheet name="Sheet2" sheetId="2" r:id="rId2"/>
<sheet name="Sheet3" sheetId="3" r:id="rId3"/>
</sheets>
<calcPr calcId="124519"/>
</workbook>
解析 workbook.xml 后,可以得到一个 workbook 对象,如下所示,有个可以看出,该对象有挺多的默认属性的,我们绘制的 Excel 比较简单,很多没有用到。
{
AppVersion: {
appName: "xl",
lastEdited: "4",
CalcPr: {
calcCompleted: "true",
calcId: "124519"
Names: [],
Sheets: [
{name: "Sheet1", sheetId: "1", sheetid: "1", id: "rId1", Hidden: 0},
{name: "Sheet2", sheetId: "2", sheetid: "2", id: "rId2", Hidden: 0},
{name: "Sheet3", sheetId: "3", sheetid: "3", id: "rId3", Hidden: 0}
WBProps: {
CodeName: "",
allowRefreshQuery: false,
WBView: [
windowHeight: "11640",
windowWidth: "19200",
xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
}
4. 解析 Excel 配置
Excel 配置 包括 coreprops 和 custprops 两部分。本例中并没有用到 custprops,只有 coreprops,大概看下结构。其实就是一些配置,很简单。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:creator></dc:creator>
<cp:lastModifiedBy></cp:lastModifiedBy>
<dcterms:created xsi:type="dcterms:W3CDTF">2006-09-13T11:21:51Z</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">2020-08-06T03:03:51Z</dcterms:modified>
</cp:coreProperties>
解析后得到配置对象如下。
{
AppVersion: "12.0000",
Application: "Microsoft Excel",
Author: "",
CreatedDate: Wed Sep 13 2006 19:21:51 GMT+0800 (中国标准时间),
DocSecurity: "0",
HyperlinksChanged: false,
LastAuthor: "",
LinksUpToDate: false,
ModifiedDate: Thu Aug 06 2020 11:03:51 GMT+0800 (中国标准时间),
ScaleCrop: false,
SharedDoc: false,
}
5. 解析工作表(Sheet)
解析 workbook 的时候,获得了 工作簿(workbook)下的工作表(sheet) 的简单信息,并且给了一个外键 ID,这里就解析 workbook.xml.rels 文件,用于获得这个外键 ID 对应的 Sheet 的具体路径。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
...
开始解析 Sheet,这是所有解析中最关键的部分。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<dimension ref="A1:E2"/>
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0"><selection activeCell="B2" sqref="B2"/></sheetView>
</sheetViews>
<sheetFormatPr defaultRowHeight="13.5"/>
<sheetData>
<row r="1" spans="1:5">
<c r="A1" s="1" t="s"><v>1</v></c>
<c r="B1" s="1" t="s"><v>0</v></c>
<c r="C1" s="1" t="s"><v>3</v></c>
<c r="D1" s="3" t="s"><v>4</v></c>
<c r="E1" s="3"/>
<row r="2" spans="1:5">
<c r="A2" s="4" t="s"><v>2</v></c>
<c r="B2" s="4"><v>29</v></c>
<c r="C2" s="2"><v>554</v></c>
<c r="D2" s="5" t="s"><v>5</v></c>
<c r="E2" s="5" t="s"><v>6</v></c>
</sheetData>
</worksheet>
解析后获得如下 sheet 对象。
{
!ref: "A1:E2",
!merges: [
e: {c: 4, r: 0},
s: {c: 3, r: 0}
A1: {t: "s", v: "name", h: "name", w: "name"},
B1: {t: "s", v: "age", h: "age", w: "age"},
B2: {t: "n", v: 29, w: "29"},
C2: {t: "n", v: 554, w: "7/7/01"},
}
每一个 Sheet Object 表示一张表格,只要不是 ! 开头的都表示普通 cell,否则,表示一些特殊含义,具体如下:
!ref:表示所有单元格的范围,例如从 A1 到 E2 则记录为 A1:E2。
!merges:存放一些单元格合并信息,是一个数组,每个数组由包含 s 和 e 构成的对象组成,s 表示开始,e 表示结束,r 表示行,c 表示列。
每一个单元格是一个对象(Cell Object),主要有 t、v、r、h、w 等字段:
Key | Description
_____|________________________________________________________
v | 原始值
w | 格式化文本
r | 富文本内容
h | HTML内容
c | 与单元格关联的注释
z | 与单元格关联的数字格式字符串
l | 单元格的超链接对象 (.Target 是地址, .Tooltip 是提示消息)
s | 单元格的样式/主题
t:表示内容类型,下面列出一些 t 的取值
Type | Description
_____|________________________________________________________
b | Boolean
e | Error,遵循特等的错误码
n | Number
d | Date
s | 共享的字符串
z | 空白
到此为止,Sheet 就解析好了。后续还有一些 comment 的解析,我们都略过。
sheetjs 很牛的一点就在于它完全根据协议内容对 Excel 进行了建模,但是我们可以看出,这个建立的模型依然不是那么的友好,存在一些 t,v,h 等参数,我们很难一眼看出这个数据到底是个怎样的结构。所以,sheetjs 又封装了一些函数帮助我们处理这些数据。比如 XLSX.utils.sheet_to_json() 方法,该方法会将一个 sheet 变为 json 格式。
sheet 格式转换
对于某个 sheet 转换后的数据,sheetjs 提供了一些工具方法帮我们将其转换为别的格式,列出这些方法:
- sheet_to_json:把工作表转换为JS对象数组。
- sheet_to_csv:生成分隔符隔开值的输出。
- sheet_to_txt:生成UTF16格式化的文本。
- sheet_to_html:生成HTML输出。
- sheet_to_formulae:生成公式列表。
我们以 sheet_to_json 为例,默认情况下,后首先分析 !ref 字段,确认范围,然后遍历这个范围,将第一行作为 key,第二行作为 value。当然,也可以通过该函数的入参改变这种默认行为。具体的参数配置查看 SheetJS 官网。
我们这个例子最终会生成如下 JSON 对象,可以看出,提供的工具类没有帮助我们处理单元格合并的问题,需要用户自己处理。
[
age: 29
birthday: 554
bobby: "music"
name: "Tom"
__EMPTY: "football"
]
解析 XML 文件的方式
补充一下 JS 解析 XML 文件的方式,以下代码是用于解析 [Content_Types].xml 文件的。
可以看出,JS 使用正则表达式来解析 XML 文件。这个正则表达式会将 XML 文件中的每个 <> 或者 之间的内容找到,然后按照 key=value 的模式将 <> 之间的内容切出来,然后再用 switch case 对每个切出来的元素做处理。
果然是 正则学得好,解析没烦恼 。
var tagregex=/<[\/\?]?[a-zA-Z0-9:_-]+(?:\s+[^"\s?>\/]+\s*=\s*(?:"[^"]*"|'[^']*'|[^'">\s=]+))*\s?[\/\?]?>/g;
function parse_ct(data) {
var ct = new_ct();
if(!data || !data.match) return ct;
var ctext = {};
(data.match(tagregex)||[]).forEach(function(x) {
var y = parsexmltag(x);
switch(y[0].replace(nsregex,"<")) {
case '<?xml': break;
case '<Types': ct.xmlns = y['xmlns' + (y[0].match(/<(\w+):/)||["",""])[1] ]; break;
case '<Default': ctext[y.Extension] = y.ContentType; break;
case '<Override':
if(ct[ct2type[y.ContentType]] !== undefined) ct[ct2type[y.ContentType]].push(y.PartName);