❝
年关将至,你今年成长了吗?
大家好,我是
柒八九
。一个
专注于前端开发技术/
Rust
及
AI
应用知识分享
的
Coder
❝
此篇文章所涉及到的技术有
使用
zip::ZipWriter
创建
ZIP
文件
因为,行文字数所限,有些概念可能会一带而过亦或者提供对应的学习资料。请大家酌情观看。
前言
在上一篇
Rust 赋能前端: 纯血前端将 Table 导出 Excel
我们用很大的篇幅描述了,如何在前端页面中使用我们的
table2excel
(
WebAssembly
)。
❝
有同学想获取上一篇的前端项目,等有空我会上传到
github
中。同时,也想着把
table2excel
发布到
npm
中。到时候,会通知大家的。
具体展示了,如何在前端对
静态表格
/
静态长表格(1 万条数据)
/
静态表格合并
/
动态表格合并
等表格进行导出为
excel
。
运行效果
静态表格
静态长表格(1 万条数据)
静态表格合并
动态表格合并
但是呢,对于源码的解读,我们只是浅尝辄止。只是介绍了,如何将在前端构建的
Table
的信息转换为我们
Excel
引擎需要的信息。
那么我们今天就来讲讲
如何用 Rust 写一个 Excel 引擎
。
好了,天不早了,干点正事哇。
我们能所学到的知识点
❝
1. 设计思路
Excel
是一个压缩文件
先说可能打破大家认知的结论
❝
Excel
的
.xlsx
文件实际上是一个包含多个
XML
文件的压缩文件。
为了论证这个结论,我们来实际操作一下。(我用的是
Mac
,所以下面的操作都是基于
Mac
,至于其他环境大家可自行验证)
这是我们上一篇文件生成的
excle
文件。当然,你也可以用你本机的资源。
我们使用终端命令来执行
excel
的解压操作。(并且该文件的名字为
test.xlsx
)
假设
.xlsx
文件在桌面上:
cd ~/Desktop
更改扩展名
:
将
.xlsx
文件扩展名更改为
.zip
:
mv test.xlsx test.zip
解压 ZIP 文件
:
使用
unzip
命令解压 ZIP 文件:
unzip test.zip -d test_folder
这将会把
.zip
文件解压到
test_folder
目录中。
然后,我们切换到
test_folder
目录中,执行
Vscode
的快捷命令 -
code .
。
就会看到下面的目录结构
我们来简单解释一下比较重要文件的含义
worksheets
文件夹用于存放
excel
的
sheet
信息,由于我们之前的
excel
只有一个
sheet
。所以这里只有一个
sheet1.xml
。如果生成的
excel
有多个
sheet
。那么这里就有多个
sheetN.xml
文件
sheetData
用于定义
excel
中每个
cell
的值
sharedStrings.xml
是一种优化方案,
excel
中存在多个相同的值,那么我们可以存放到这里,然后在
sheetN.xml
引用这些值,可以节省
excel
的存储空间。
styles.xml
用于存放
excel
的样式信息。虽然,我们的引擎暂未支持样式的处理,但是后期也是可以把这块给加上的。
啥是 XML
关于
xml
有很多文章来介绍它。我们在摘录关于
维基百科\_xml
[1]
的定义。
❝
XML
是一种用于存储、传输和重建任意数据的
标记语言
和
文件格式
。其定义了一套用于编码文档的规则,这些规则使得文档既易于人类阅读,也易于机器处理。
然后,如果大家不想看英文内容,大家也可以看
xml 中文解释
[2]
,这里就不过多解释了。但是呢,有一点还是想多啰嗦下。
❝
Open XML Formats
到此为止,我已经默认大家已经对
xml
有了些许的了解。然后,我们再解释一个概念。
上面说了,
excel
是一堆
xml
组成的压缩文件。其实呢,还有一个定语,是符合
Open XML Formats
格式的
xml
。
我们还是直接从
Office*Open_XML*维基百科
[3]
中寻找答案。
❝
Office Open XML
(也非正式地称为
OOXML
)是微软开发的一种
基于 XML 的压缩文件格式
,用于表示
spreadsheets
(也就是
excel
)、
ppt
和
word
。
在 Excel 中使用 XML
为了更加深大家对
Excel
的理解,或者更准确的说是
Excel
和
xml
之前的关系。我们写一个简单的
Node
应用。
❝
注意:我们需要构造符合
Excel
标准的
XML
结构
具体代码如下:
const fs = require ('fs' );// 用来生成 Excel XML 格式的函数 function generateExcelXml (data ) { const xmlHeader = `` ; const worksheetXml = ` ` ; // 创建表格行 let rowsXml = '' ; data.forEach(row => { rowsXml += ''
; row.forEach(cell => { rowsXml += `${cell} | ` ; }); rowsXml += '' ; }); const footerXml = ` ` ; // 合并所有部分 const fullXml = `${xmlHeader} ${worksheetXml} ${rowsXml} ${footerXml} ` ; return fullXml; }// 示例数据 const data = [ ['Name' , 'Age' , 'City' ], ['北宸' , 25 , '北京' ], ['南蓁' , 30 , '山西' ], ['Front789' , 35 , '晋中' ] ];// 生成 XML 内容 const xmlContent = generateExcelXml(data);// 保存为 Excel 可读取的 XML 文件 fs.writeFileSync('workbook.xml' , xmlContent, 'utf8' );
代码说明:
XML 头部
:指定了 XML 文件的版本和编码方式。
:工作簿的根元素,
Excel
使用
ss
命名空间来定义 XML 文件的结构。
:工作表定义,每个工作簿可以有多个工作表,这里定义了一个工作表
Sheet1
。
保存文件
:将生成的
XML
内容写入
workbook.xml
文件。
然后,我们运行上面的代码后,就会生成一个
workbook.xml
文件。随后,我们将该文件拖入到
WPS
中。
看到的效果如下:
可以看到,我们刚才用代码生成的
xml
,是正常显示为
excel
格式。并且数据也是正确的。
❝
还有一点需要说明,当我们把刚才生成的
xml
拖入到
WPS
时,它会跳出一个提示框,问你需要将该
xml
以何种模式展示。这步也反向证明了
Office_Open_XML 是微软开发的一种基于 XML 的压缩文件格式,用于表示 spreadsheets(也就是 excel)、ppt 和 word
这个概念。
2. 代码结构
项目初始化
该内容,在上一篇讲过,我们就直接复制过来了。
我们通过
cargo new --lib table2excel
来构建一个项目。
同时呢,我们在项目根目录中创建用于打包优化的文件。
这个我们在之前的
Rust 赋能前端:为 WebAssembly 瘦身
中介绍过相关概念,这里就不再赘述了。
项目结构
在
src
目录下,我们有如下的目录结构
├── json2sheet.rs ├── lib.rs ├── sheet_data.rs ├── struct_define.rs ├── utils.rs ├── xml.rs └── xml_meta.rs
json2sheet.rs
在上一篇文章中讲过,它的作用就是将前端页面中传入的
json
转换为构建
xml
的所需结构
lib.rs
这里只有一个函数,就是我们在前端调用的主函数
generate_excel
sheet_data.rs
:该文件用于基于
json2sheet.rs
返回的数据和
json
中特定的数据,构建
xml
的数据部分
struct_define.rs
:用于存放该项目中用到的
Struct
log_to_console
封装web_sys
[4]
::console,用于在前端中打印信息
set_panic_hook
封装console_error_panic_hook
[5]
,让错误更好的控制台捕获
xml.rs
:基于
sheet_data
拼装
xml
信息
xml_meta
:用于生成符合
open xml
的元数据信息
下面,我们就会拿我认为主要的代码,来讲讲核心逻辑。
3. 核心代码解释
lib.rs
引入第三方包和自定义模块
use struct_define::{ CellValue, InnerCell };use wasm_bindgen::prelude::*;use std::io::prelude::*;use zip;use zip::write::FileOptions;use std::io::Cursor;pub mod struct_define;pub mod xml;pub mod utils;pub mod json2sheet;pub mod xml_meta;pub mod sheet_data;const ROOT_RELS: &'static [u8 ] = br#" "# ;
wasm_bindgen
[6]
这是
Rust
编译为
WebAssembly
绕不开的大山,这里就不再展示细说了。
zip
[7]
:前面说了,
excel
就是一堆
xml
的
zip
压缩包。所以,我们使用
zip
来处理压缩
std::io::Cursor
:
Cursor
是一种用于内存缓冲区的类型,它提供了对内存中的数据进行读取和写入的功能。
通过实现
Seek
,
Cursor
使得这些
缓冲区可以像文件一样进行随机访问
。
Cursor
可用于多种类型的缓冲区,比如
Vec
和切片 (
&[u8]
),并能够利用标准库中的 I/O 特性实现
数据的读取和写入
。
核心代码
❝
该代码的主要功能是生成一个
Excel
文件(
.xlsx
格式),它通过将
JSON
数据处理为
Excel
格式并使用
zip
压缩库将其封装成一个
.xlsx
文件。
#[wasm_bindgen] pub async fn generate_excel (raw_data: &JsValue) -> Result <Vec <u8 >, JsValue> { utils::set_panic_hook(); // 解析前端传入的数据 let data = json2sheet::process_json(raw_data); let mut shared_strings = vec! (); let mut sheets_info: Vec String, String )> = vec! (); // 创建压缩文件的内存缓冲区 let buf: Vec <u8 > = vec! (); let w = Cursor::new(buf); let mut zip = zip::ZipWriter::new(w); let options = FileOptions::default() .compression_method(zip::CompressionMethod::Stored) .unix_permissions(0o755 ); let sheet = &data.data; let mut rows: Vec <Vec > = vec! (); // 将行数据处理成 InnerCell 格式 if let Some (cell) = &sheet.cells { for (row_index, row) in cell.iter().enumerate() { let mut inner_row: Vec = vec! (); for (col_index, cell) in row.iter().enumerate() { if let Some (value) = cell { let cell_name = sheet_data::cell_offsets_to_index(row_index, col_index); let mut inner_cell = InnerCell::new(cell_name); if let Ok (_) = value.parse::<f64 >() { inner_cell.value = CellValue::Value(value.to_owned()); } else { inner_cell.value = CellValue::SharedString(shared_strings.len() as u32 ); shared_strings.push(value.to_owned()); } inner_row.push(inner_cell); } } rows.push(inner_row); } } // 获取 sheet 信息并开始写入压缩文件 let sheet_info = sheet_data::get_sheet_info(sheet.name.clone(), 0 ); zip.start_file(sheet_info.0 .clone(), options).unwrap(); zip.write_all( sheet_data::get_sheet_data(rows, &sheet.cols, &sheet.rows, &sheet.merged).as_bytes() ).unwrap(); sheets_info.push(sheet_info); // 写入 _rels/.rels 文件 zip.start_file("_rels/.rels" , options).unwrap(); zip.write_all(ROOT_RELS).unwrap(); // 创建 XML 元数据 let (content_types, rels, workbook) = xml_meta::create_open_xml_meta(sheets_info); // 写入各种 XML 文件 zip.start_file("[Content_Types].xml" , options).unwrap(); zip.write_all(content_types.as_bytes()).unwrap(); zip.start_file("xl/_rels/workbook.xml.rels" , options).unwrap(); zip.write_all(rels.as_bytes()).unwrap(); zip.start_file("xl/workbook.xml" , options).unwrap(); zip.write_all(workbook.as_bytes()).unwrap(); // 写入 sharedStrings.xml 文件 zip.start_file("xl/sharedStrings.xml" , options).unwrap(); zip.write_all(sheet_data::get_shared_strings_data(shared_strings, 0 ).as_bytes()).unwrap(); // 完成压缩并返回结果 let res = zip.finish().unwrap(); Ok (res.get_ref().to_vec()) }
❝
该函数的主要核心步骤如下:
接收 JSON 数据并处理
:接收
JsValue
类型的输入数据,这个数据是通过
json2sheet::process_json
函数处理后的
JSON
数据。
构建 Excel 数据结构
:解析并转换
JSON
数据为
InnerCell
格式的行数据,以便在
Excel
中进行存储。
生成 Excel 压缩文件(.xlsx 格式)
:通过
zip
库创建一个内存中的 ZIP 文件,并将
Excel
文件的不同部分(如
workbook.xml
,
sharedStrings.xml
)写入该
ZIP
文件。
异步处理
:通过
async/await
使得函数能够在
JavaScript
中异步执行,避免阻塞主线程。
下面我们就简单来对代码中重要的核心部分做一个简单的解释。
1.
设置 Panic Hook
utils::set_panic_hook();
这行代码设置了一个
Panic Hook
,用于在
Rust
中发生
panic
时,能够捕获并进行适当的处理。通常在
WebAssembly
中使用它来处理错误。
2.
处理 JSON 数据
let data = json2sheet::process_json(raw_data);
process_json
函数处理传入的
JSON
数据,将其转换成适合构建
Excel
的数据结构。
raw_data
是通过
JsValue
类型传入的,在调用该函数后,它被转换成一个包含
Excel
工作表数据的结构(例如:行、列、单元格等)。
3.
初始化压缩文件 (ZIP)
let buf: Vec <u8 > = vec! ();let w = Cursor::new(buf);let mut zip = zip::ZipWriter::new(w);
这段代码创建了一个内存缓冲区(
Vec
),并将其包装在
Cursor
中。
zip::ZipWriter
用于创建一个
ZIP
文件,在其中写入
Excel
文件的各个部分。
4.
写入工作表数据(行数据)
if let Some (cell) = &sheet.cells { for (row_index, row) in cell.iter().enumerate() { let mut inner_row: Vec = vec! (); for (col_index, cell) in row.iter().enumerate() { // 省略部分代码 } rows.push(inner_row); } }
这一部分将从
cells
(一个包含
Excel
工作表所有行的
Vec
>>
)中获取每一行数据,逐个单元格处理,将每个单元格的数据转换为
InnerCell
对象,并将它们组织成行。每个
InnerCell
可能是直接存储值(如数字),或者是共享字符串(如果该单元格是文本)。所有的共享字符串都会被存储在
shared_strings
中。
5.
写入 Excel 文件的各个部分
let sheet_info = sheet_data::get_sheet_info(sheet.name.clone(), 0 ); zip.start_file(sheet_info.0 .clone(), options).unwrap(); zip.write_all( sheet_data::get_sheet_data(rows, &sheet.cols, &sheet.rows, &sheet.merged).as_bytes() ).unwrap();
这段代码处理工作表(
sheet_info
),并将其写入
ZIP
文件中。它还将当前工作表的数据(如行、列、合并单元格等)写入到
ZIP
文件中。
6.
写入其他 Excel 文件元数据
zip.start_file("_rels/.rels" , options).unwrap(); zip.write_all(ROOT_RELS).unwrap();
这部分写入
Excel
文件的关系文件(
_rels/.rels
),它用于描述文件之间的关系,例如工作表与数据文件之间的关系。
接下来的代码还会写入 Excel 文件所需的其他 XML 文件:
[Content_Types].xml
:描述 Excel 文件中各种文件类型。
xl/_rels/workbook.xml.rels
:描述工作簿的关系文件。
xl/workbook.xml
:工作簿的主 XML 文件。
xl/sharedStrings.xml
:存储共享字符串(如文本)数据。
❝
这些文件,我们在文章刚开始就用见到过了,也就是说这些文件是构成
excel
压缩文件的基础
7.
完成 ZIP 压缩并返回结果
let res = zip.finish().unwrap();Ok (res.get_ref().to_vec())
在完成所有数据写入后,调用
zip.finish()
来结束 ZIP 文件的创建。最后,返回一个
Vec
,它包含了压缩后的
.xlsx
文件内容。
json2sheet.rs - 处理 JSON 数据
这步,我们在上一篇文章中(
Rust 赋能前端: 纯血前端将 Table 导出 Excel
讲过了,为了不让文章看起来又臭又长,所以这里就不再过多解释了。
❝
总结一句话,其实就是将从前端环境传入的
Table
的配置信息,转换为我们生成
xml
需要的数据格式。
sheet_data.rs - 基于信息构建 xml
我们在
lib.rs
中,当基于
sheet.cells
信息构建完
rows
信息后,我们此时其实已经收集了可以构建
xml
的所有数据信息。那么,我们就可以调用
sheet_data::get_sheet_data
来处理相关的逻辑。
sheet_data::get_sheet_data(xx).as_bytes()
主要代码
❝
该函数的主要功能是
将传入的 Excel 数据(如单元格内容、列、行、高度、合并单元格等)转换成符合 Excel 2006 XML 格式的字符串(即
元素)
。它生成的
XML
数据可以嵌入到一个
Excel
文件(
.xlsx
文件)中,作为
excel
的
数据部分
。这个过程是通过
构造 XML 元素并为其添加属性和子元素来实现的
。
pub fn get_sheet_data ( cells: Vec <Vec >, columns: &Option <Vec <Option >>, rows: &Option <Vec <Option >>, merged: &Option <Vec > ) -> String { let mut worksheet = Element::new("worksheet" ); let mut sheet_view = Element::new("sheetView" ); sheet_view.add_attr("workbookViewId" , "0" ); let mut sheet_views = Element::new("sheetViews" ); sheet_views.add_children(vec! [sheet_view]); let mut sheet_format_pr = Element::new("sheetFormatPr" ); sheet_format_pr .add_attr("customHeight" , "1" ) .add_attr("defaultRowHeight" , "15.75" ) .add_attr("defaultColWidth" , "14.43" ); let mut cols = Element::new("cols" ); let mut cols_children = vec! (); match columns { Some (columns) => { for (index, column) in columns.iter().enumerate() { match column { Some (col) => { let mut column_element = Element::new("col" ); column_element .add_attr("min" , (index + 1 ).to_string()) .add_attr("max" , (index + 1 ).to_string()) .add_attr("customWidth" , "1" ) .add_attr("width" , (col.width / WIDTH_COEF).to_string()); cols_children.push(column_element); } None => (), } } } None => (), } let mut rows_info: HashMap<usize , &RowData> = HashMap::new(); match rows { Some (rows) => { for (index, column) in rows.iter().enumerate() { match column { Some (row) => { rows_info.insert(index, row); } None => (), } } } None => (), } let mut sheet_data = Element::new("sheetData" ); let mut sheet_data_rows = vec! (); for (index, row) in cells.iter().enumerate() { let mut row_el = Element::new("row" ); row_el.add_attr("r" , (index + 1 ).to_string()); match rows_info.get(&index) { Some (row_data) => { row_el .add_attr("ht" , (row_data.height * HEIGHT_COEF).to_string()) .add_attr("customHeight" , "1" ); } None => (), } let mut row_cells = vec! (); for cell in row { let mut cell_el = Element::new("c" ); cell_el.add_attr("r" , &cell.cell); match &cell.value { CellValue::Value(ref v) => { let mut value_cell = Element::new("v" ); value_cell.add_value(v); cell_el.add_children(vec! [value_cell]); utils::log!("value {}" , v); } CellValue::SharedString(ref s) => { cell_el.add_attr("t" , "s" ); let mut value_cell = Element::new("v" ); value_cell.add_value(s.to_string()); cell_el.add_children(vec! [value_cell]); } CellValue::None => (), } row_cells.push(cell_el); } row_el.add_children(row_cells); sheet_data_rows.push(row_el); } sheet_data.add_children(sheet_data_rows); let mut worksheet_children = vec! [sheet_views, sheet_format_pr]; if cols_children.len() > 0 { cols.add_children(cols_children); worksheet_children.push(cols); } worksheet_children.push(sheet_data); match merged { Some (merged) => { if merged.len() > 0 { let mut merged_cells_element = Element::new("mergeCells" ); merged_cells_element.add_attr("count"
, merged.len().to_string()).add_children( merged .iter() .map(|MergedCell { from, to }| { let p1 = cell_offsets_to_index(from.row as usize , from.column as usize ); let p2 = cell_offsets_to_index(to.row as usize , to.column as usize ); let cell_ref = format! ("{}:{}" , p1, p2); let mut merged_cell = Element::new("mergeCell" ); merged_cell.add_attr("ref" , cell_ref); merged_cell }) .collect() ); worksheet_children.push(merged_cells_element); } } None => (), } worksheet .add_attr("xmlns:xm" , "http://schemas.microsoft.com/office/excel/2006/main" ) .add_attr("xmlns:x14ac" , "http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" ) .add_attr("xmlns:x14" , "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" ) .add_attr("xmlns:mv" , "urn:schemas-microsoft-com:mac:vml" ) .add_attr("xmlns:mc" , "http://schemas.openxmlformats.org/markup-compatibility/2006" ) .add_attr("xmlns:mx" , "http://schemas.microsoft.com/office/mac/excel/2008/main" ) .add_attr("xmlns:r" , "http://schemas.openxmlformats.org/officeDocument/2006/relationships" ) .add_attr("xmlns" , "http://schemas.openxmlformats.org/spreadsheetml/2006/main" ) .add_children(worksheet_children); worksheet.to_xml() }
核心功能分析
❝
还记得我们文章刚开始的解压缩后的
test_folder
我们就来看看,我们是如何用代码生成这些信息的。
1.
初始化工作表元素
let mut worksheet = Element::new("worksheet" );
首先,创建一个
worksheet
元素,这个元素将表示整个
Excel
工作表,并作为最终的
XML
输出。
该元素是
sheet
的根元素
2.
创建
sheetView
和
sheetViews
let mut sheet_view = Element::new("sheetView" ); sheet_view.add_attr("workbookViewId" , "0" );let mut sheet_views = Element::new("sheetViews" ); sheet_views.add_children(vec! [sheet_view]);
sheetView
元素描述了工作表的视图设置(如显示模式等)。这里添加了一个
sheetView
元素,并设置了其
workbookViewId
属性。
sheetViews
是一个容器元素,包含了多个
sheetView
元素。
3.
设置工作表格式
let mut sheet_format_pr = Element::new("sheetFormatPr" ); sheet_format_pr .add_attr("customHeight" , "1" ) .add_attr("defaultRowHeight" , "15.75" ) .add_attr("defaultColWidth" , "14.43" );
sheetFormatPr
元素定义了工作表的格式,包括行高(
defaultRowHeight
)和列宽(
defaultColWidth
)等属性。此处设置了默认行高为
15.75
和默认列宽为
14.43
。
4.
处理列数据并生成
cols
元素
let mut cols = Element::new("cols" );let mut cols_children = vec! ();
这段代码处理传入的列数据(
columns
)。如果列数据存在,遍历每一列,并根据列的宽度生成
元素,并将其添加到
cols
中。每个列元素会包含以下属性: