纯 C++ Excel (.xlsx) 库 sdtech_xlsx 3.1 的设计与实现说明。OPC + SpreadsheetML + 对象模型 + JSON Bridge,为 OfficeExcel 提供 Excel 读写与预览能力。
sdtech_xlsx 3.1 是纯 C++ 的 Excel
.xlsx读写库:OPC 容器 + SpreadsheetML 解析 + 工作簿对象模型 + JSON Bridge + C ABI。架构与姊妹库 sdtech_docx 同构;语义层对标 openpyxl 3.x / Apache POI XSSF。模块映射见库内Libs/sdtech_xlsx/Docs/python-openpyxl-port-map.md。
本文只讲 库内部 从磁盘字节到内存模型、再写回 Part 的完整链路;集成层(Electron、Backend 会话)见 OfficeExcel 功能介绍。
库按 依赖方向 分为五层,数据始终沿「ZIP → Part → XML → Model → Part → ZIP」流动:
Impl/XlsxWorkbook ← 门面:loadFromPath / createBlank / saveToPath
└─ model/WorkbookModel ← 唯一权威内存状态
├─ package (opc::Package) ← 全 Part blob 字典
├─ sheets[] (WorksheetModel)
└─ sharedStrings / definedNames / …
CApi/sdtech_xlsx_api.cpp ← C ABI:解析 JSON 入参,改 Model,调 Writer/Loader
bridge/BridgeJson.cpp ← Model → preview/structure/consistency JSON 字符串
| 目录 | 核心类型 | 职责 |
|---|---|---|
opc/ |
Package, Part, ContentTypes, OoxmlCrypto |
ZIP 读写、Part 字典、关系解析、加密包装 |
Impl/ |
MinimalZip, XlsxWorkbook |
裸 ZIP 算法、工作簿生命周期 |
oxml/ |
OxmlUtil |
pugixml 封装:local-name、命名空间无关属性 |
model/ |
WorkbookLoader/Writer, StyleResolver, FormulaEngine, … |
SpreadsheetML ↔ 对象模型 |
bridge/ |
buildPreviewJson 等 |
内存模型序列化为 JSON(库内协议) |
CApi/ |
sdtech_xlsx_* |
导出符号、错误码、thread_local last_error |
第三方以 源码 vendoring 编入:pugixml.cpp、miniz_tinfl.c(deflate)。Windows 加密路径链接 bcrypt(SHA-512 + AES-256-CBC)。
.xlsx 是 OOXML OPC 包:ZIP 内每个 entry 是一个 Part(路径即 Part 名,如 xl/worksheets/sheet1.xml)。SpreadsheetML 相关 Part 包括:
| Part | SpreadsheetML 要点 |
|---|---|
xl/workbook.xml |
<sheets> 列表、definedNames、pivotCaches |
xl/worksheets/sheetN.xml |
sheetData/row/c 单元格、mergeCells、sheetViews/pane 冻结 |
xl/sharedStrings.xml |
t="s" 单元格的值索引表 |
xl/styles.xml |
fonts / fills / borders / cellXfs / numFmts |
xl/theme/theme1.xml |
主题色索引(color theme="N" 解析) |
xl/drawings/drawingN.xml |
图片 anchor、chart graphicFrame |
xl/charts/chartN.xml |
图表 plotArea |
xl/pivotTables/ + xl/pivotCache/ |
透视表定义与缓存 |
保真策略(与 sdtech_docx 相同):Package::loadFromZipMap 把 ZIP 里 所有 entry 放进 parts_;WorkbookLoader 只解析需要建模的 XML 到 WorkbookModel;WorkbookWriter::syncParts 只 重写 被 touch 的 Part(worksheets、styles、sharedStrings、drawings…);其余 Part(未知扩展、未改 theme 等)blob 原样在 save 时写回。
Package 与 PartPart 结构Dll/Src/opc/Part.h 中 Part 是库的最小存储单元:
class Part {
std::string partName; // ZIP 内路径,已 normalize
std::string contentType; // 来自 [Content_Types].xml
std::vector<uint8_t> blob; // 原始字节(XML 或 PNG 等)
std::vector<Relationship> relationships; // 从 xxx.rels 解析
bool dirty;
};
文本 Part 通过 text() / setText() 在 blob 与 string 间转换;二进制 Part(图片)用 setBlob。
Package::openDll/Src/opc/Package.cpp 流程:
zipReadFile(path, zipMap) → 路径名 → bytesisEncryptedZipMap(存在 EncryptedPackage entry)→ decryptOfficePackage 得内层 zipMaploadFromZipMap:每个 entry 建 Part,跳过目录项(名以 / 结尾)[Content_Types].xml → ContentTypes,为各 Part 填 contentTypexl/worksheets/_rels/sheet1.xml.rels 这类 sibling rels 文件,则 parseRelationshipsXml 填入 relationshipswireRelationships() 补全尚未挂关系的 Part_rels/.rels 找 RelType::OFFICE_DOCUMENT 目标,设为 mainDocumentPartName_(xlsx 即 xl/workbook.xml)关系解析后,Sheet 的 r:id 可解析为 xl/worksheets/sheet1.xml 等绝对 Part 路径(resolvePartTarget 处理 ../ 相对路径)。
Package::createBlankcreateBlank() 手写最小合法 OPC 骨架(不读模板文件):
[Content_Types].xml 通过 ContentTypes::ensureOverride 注册 MIME_rels/.rels → rId1 → xl/workbook.xmlxl/workbook.xml:空 <sheets> 含 Sheet1 占位(后续 Writer 重写)xl/_rels/workbook.xml.rels:worksheet / styles / sharedStrings 三条关系xl/worksheets/sheet1.xml:空 <sheetData/>xl/styles.xml:1 font、1 fill、1 border、1 cellXf(Calibri 11pt)xl/sharedStrings.xml:空 sstXlsxWorkbook::createBlank 在此基础上往 WorkbookModel.sheets 推入一个 WorksheetModel{ name="Sheet1", partPath="xl/worksheets/sheet1.xml", loaded=true }。
Package::saveparts_,对每个非 [Content_Types].xml 的 Part 调用 contentTypes.ensureOverride(partName, contentType)ContentTypes::serialize() 生成新的 [Content_Types].xmlinnerZip map(content types + 全部 Part blob)ZipEntry 向量 → zipWriteFileencryptOfficePackage(innerZip, password, outerEntries) → 外层仅含加密容器四件套WorkbookWriter::save 先 syncParts(model) 把 Model 写进 model.package,再 package->save(path, model.encryptionPassword)。
Dll/Src/Impl/MinimalZip.cpp 只实现 OPC 需要的子集。
读:
0x06054b500x04034b50extractZipEntry:
tinfl_decompress_mem_to_mem(raw deflate,无 zlib header)写:
这与 sdtech_docx 共用同一套 MinimalZip 思路:读能解 Excel 默认 deflate 包,写用 store 保证实现简单、round-trip 稳定。
Dll/Src/opc/OoxmlCrypto.cpp 实现 库内 round-trip 的简化 Agile 风格,非完整 MS-OFFCRYPTO。
识别:外层 ZIP 含 EncryptedPackage entry。
解密:
EncryptedPackage 前 8 字节 = 明文 ZIP 长度(uint64 LE),其后为密文EncryptionInfo 前 16 字节 salt、后 16 字节 IV(缺失则用固定占位 0x5A / 0xA5)deriveKeyAgile:SHA512(salt || password) 取前 32 字节为 AES-256 密钥aes256CbcDecrypt(bcrypt)→ 明文 ZIP 写临时文件 → zipReadFile 得内层 map加密(encryptOfficePackage):
zipWriteFile 到临时文件aes256CbcEncrypt(PKCS#7 padding)[Content_Types].xml、_rels/.rels(指向 EncryptedPackage + EncryptionInfo)、EncryptionInfo、EncryptedPackageWorkbookModel::encryptionPassword 非空时 Package::save 走加密分支。Excel 原生打开未完全保证——GTest 验证的是库内加解密后再 WorkbookLoader 可读。
SpreadsheetML 根节点带 xmlns,子元素可能是 c 或前缀形式。 Dll/Src/oxml/OxmlUtil.cpp 用 local-name 匹配:
isElement(node, "row"):nameIs 比较完整名或 *:row 后缀childElement(parent, "sheetData"):遍历 child,找第一个 local-name 匹配attributeLocalVal(node, "ref"):先无命名空间属性,再扫 r:ref 这类Loader/Writer 不依赖 XPath;全部 imperative 遍历 pugixml DOM。parseXmlBytes(Part::blob) 直接 parse buffer,避免多余 string 拷贝。
WorkbookLoader入口:XlsxWorkbook::loadFromPath → Package::open → WorkbookLoader::loadFromPackage(std::move(pkg), model, lazyLoad)。
StyleResolver::loadFromPackage 读 xl/styles.xml(见第八节)xl/sharedStrings.xml 的 <si>,readSharedString 支持 plain <t> 或 rich <r><t><definedNames> → DefinedNameModel{name, formula, sheetIndex}<sheets><sheet name sheetId r:id state> → 用 workbook.xml.rels 把 r:id 映射为 ws.partPathfalse:对每个 sheet Part 调 loadWorksheetXml + loadWorksheetExtensionstrue:只填 Sheet 元数据,ws.loaded = false,不解析 sheetData(Part blob 仍在 Package 里)loadWorksheetXmlDll/Src/model/WorkbookLoader.cpp 顺序解析 worksheet 子树:
| XML 节点 | 写入 WorksheetModel |
|---|---|
sheetViews/sheetView/pane |
freezeRow ← ySplit,freezeCol ← xSplit |
autoFilter@ref |
autoFilterRef |
cols/col@min,width |
colWidths[col] = width * 7(内部像素近似) |
mergeCells/mergeCell@ref |
merges[],再 applyMergeMetadata |
hyperlinks/hyperlink |
hyperlinks[] |
sheetData/row@r,ht |
rowHeights[row] = ht * 96/72 |
sheetData/row/c |
见下 |
单元格 <c> 解析:
r → CellRef,得到 col/row
t → valueType: s/n/b/inlineStr
s → styleIndex → StyleResolver::resolve → cell.format
f → formula 文本
v → 按 t 解释:
s → sharedStrings[idx]
b → boolValue + text TRUE/FALSE
n/空 → numericValue,若 numFmt 像日期则 isDate=true
inlineStr → is/r 或单 t → text / richText[]
缺 r 时从 row@r + 列序推断;最后 cells[ref] = cell。键为 A1 字符串,值为 CellModel。
合并元数据 Dll/Src/model/WorkbookOps.cpp applyMergeMetadata:
MergeRange(如 B2:D4),范围内每个格都在 cells 中有条目colSpan/rowSpan 为合并宽高,isMergeOrigin=trueisMergeOrigin=false;Writer 的 writeCellXml 跳过非 origin(Excel 只写一个 <c>)loadWorksheetExtensionsdrawing@r:id → sheet rels → drawing Part → loadChartsFromDrawingloadPivotTablesFromWorksheet 读 pivotTable rel图表/透视 读 路径在 ChartPivotOps.cpp 反向解析 XML 填 ChartModel / PivotTableModel。
loadSheetWorkbookLoader::loadSheet(model, index):若 ws.loaded 已 true 则返回;否则从 package 取 sheet Part blob,重新 StyleResolver::loadFromPackage,执行与 7.2 相同的 loadWorksheetXml。lazy 打开的大文件只有调用 load_sheet 时才分配 cells map。
StyleResolver、写 StyleSheetBuilderCellFormatStyleResolver 在 Loader 内解析 xl/styles.xml 五表:
fonts[] / fills[] / borders[] / cellXfs[] / customNumFmts(id≥164)
每个 cellXfs[i] 通过 fontId/fillId/borderId/numFmtId 索引子表,合并 alignment 子节点,得到 resolved_[i] 即 CellFormat(bold、fontFamily、bgColor、四边 border、numFmt…)。
颜色 Dll/Src/model/WorkbookLoader.cpp:
rgb="FFRRGGBB" → #RRGGBBtheme="N" tint="…" → ThemeResolver::resolveThemeIndex日期识别:looksLikeDateNumFmt(numFmt) 检查格式串是否含 y/d/h。
写路径 不 保留原 styles.xml 结构,而是根据内存中所有单元格的 CellFormat 重新构建:
StyleSheetBuilder::assignStyleIndices(model) 遍历每个 cell.format,indexFor(format) 分配 styleIndexindexFor 用 formatKey(fmt) 字符串做 dedup map;新格式则:
internFont / internFill / internBorder / internNumFmt 各自表内去重RawXf 到 xfs_buildStylesXml() 输出完整 styleSheet XML;自定义 numFmt 从 id 164 递增(nextCustomNumFmtId_)内存里样式是 富结构 CellFormat;磁盘上是 索引 s="3"。打开时 expand,保存时 collapse——与 openpyxl 懒样式思路一致。
WorkbookModel 与 WorksheetModel定义于 Dll/Src/model/WorkbookTypes.h。
WorkbookModel
├── package: unique_ptr<Package> // 全 Part;save 的数据源
├── sheets: vector<WorksheetModel>
├── sharedStrings: vector<string> // 与 sst 同步;Writer save 时重建
├── definedNames[]
├── activeSheetIndex, styleCount
├── lazyLoad, streamingMode, streamingWindowRows
├── encryptionPassword // 非空则 save 加密
└── nextPivotCacheId
WorksheetModel
├── name, index, sheetId, partPath // partPath 来自 workbook rel
├── active, hidden, veryHidden, tabColor
├── cells: map<string, CellModel> // key = "B3"
├── merges, colWidths, rowHeights
├── freezeRow/Col, autoFilterRef
├── hyperlinks, comments, images, charts, pivotTables
├── dataValidations, conditionalFormats, tables, printSetup
├── loaded // lazy 标志
├── streamingMinRow // 流式窗口下界
└── flushedRows: map<row, map<col, CellModel>> // 已刷出窗口的行
CellModel 同时持有 展示(text)、类型(valueType/numericValue/boolValue/isDate)、样式(format + styleIndex)、公式(formula)、合并(colSpan/rowSpan/isMergeOrigin)、富文本(richText[])。
CellRef(Dll/Src/model/CellRef.cpp):列字母 bijective 映射(A→1, Z→26, AA→27);fromA1/toA1 供合并区域、公式引址使用。
日期 Dll/Src/model/DateUtil.cpp:Excel 序列日基准 1899-12-30,isoDateToExcelSerial / excelSerialToIsoDate 用 civil calendar 算法,与 Loader 识别 isDate 配合。
Dll/Src/CApi/sdtech_xlsx_api.cpp 不持有第二份状态:每个 void* 句柄即 XlsxWorkbook*,其 model 成员即权威数据。
典型写单元格 set_cell 路径:
resolveSheetIndex:按 JSON "sheet":0 或 "sheet":"Name" 找 WorksheetModel;若 lazy 且未加载则 loadSheetapplyCellFromJson:根据 type 填 valueType/text/numericValue/boolValue/isDate/formulaws->cells[ref] = cellmaybeFlushStreamingRows(model, *ws)(流式模式)set_cell_style 只改已有或新建格的 CellFormat 字段;styleIndex 在 save 前由 StyleSheetBuilder::assignStyleIndices 统一分配。
JSON 解析为 轻量 strstr(jsonGetString/jsonGetInt),避免链接完整 JSON 库;C API 层用 mutex 保护 init,错误信息 thread_local g_last_error。
WorkbookWriter::syncPartsWorkbookWriter::save → syncParts → Package::save。syncParts 是库内最复杂的函数,按 依赖顺序 写 Part:
两遍扫描所有 已加载 Sheet 的 cells 与 flushedRows:
cellNeedsSharedString:非公式、非数字/布尔/日期、非 inlineStr 的文本 → 需要 sstintern(text) 去重,生成 xl/sharedStrings.xml公式单元格 不 进 sst(值在 <f>/<v>)。
StyleSheetBuilder::assignStyleIndices + buildStylesXml() → xl/styles.xml。
重写 xl/workbook.xml(sheets 列表、definedNames、pivotCaches 占位)和 xl/_rels/workbook.xml.rels(每个 sheet 一个 worksheet rel + styles + sharedStrings + pivotCache rels)。
对每个 ws.loaded==true 的 Sheet:
cells + flushedRows 放入 map<row, map<col, CellModel*>>rowBegin = streamingMode ? max(1, ws.streamingMinRow) : 1,只输出窗口内及 flushed 行writeCellXml)、autoFilter、mergeCells、hyperlinks、dataValidations、conditionalFormatting、table、drawing/pivot 占位 refwriteCellXml 逻辑摘要:
!isMergeOrigin → 跳过s 属性:styleIndex<f> + 可选 <v>t="b"、数字/日期 bare <v>、richText inlineStr+<is>、普通文本 t="s"+sst 索引列宽写回:width = colWidths[col] / 7(与读路径互逆)。行高:ht = rowHeights[row] * 72/96。
若 Sheet 有 images 或 charts:
xl/drawings/drawingN.xml(xdr:twoCellAnchor + pic 或 graphicFrame)xl/media/image{sheet}_{idx}.pngxl/charts/chart{sheet}_{idx}.xml,buildChartXml 生成 c:barChart/lineChart/pieChartxl/drawings/_rels/drawingN.xml.rels 链 image/chart透视表:预先收集 PivotWritePlan(cacheId、cacheDef/Records/pivotTable 路径),写 buildPivotCacheDefinitionXml、buildPivotTableXml、占位 buildPivotCacheRecordsXml。
批注:独立 xl/commentsN.xml Part。
最后 model.sharedStrings = sharedStrings 保持内存与磁盘 sst 一致。
StreamingOps对标 POI SXSSF:Dll/Src/model/StreamingOps.cpp maybeFlushStreamingRows:
streamingMode && (maxRow - streamingMinRow + 1) > streamingWindowRows
→ 把 streamingMinRow 那一行从 cells 移到 flushedRows[row]
→ streamingMinRow++
streamingMinRow ~ 当前最大行):cells map 可随机读写flushedRows 保留,Writer 写 sheet 时与 cells 合并进 gridflush_streaming_rows API 对全部 Sheet 调用上述逻辑,强制刷到 flushedRowscreateBlank(..., streaming=true, windowRows) 设置 WorkbookModel::streamingMode 与 streamingWindowRows(默认 100)。限制: flushed 行不再支持随机改(库未禁止,但 Excel 语义上已「落盘行」)。
FormulaEngineDll/Src/model/FormulaEngine.cpp 是 递归 descent 子集,不是完整计算引擎:
evalExpression:去 leading =,按函数名 dispatchsplitTopLevelArgs:括号深度计数切参,支持 IF(a,b,c) 嵌套resolveRef:从 ws.cells 或 flushedRows 取数值(流式场景公式仍可引用已刷行)SUM/AVERAGE 区域、IF、VLOOKUP、DATE/TODAY/YEAR/MONTH/DAY、CONCAT、简单 +/-evaluateCell:求值结果写回 cell.text、valueType="n"、numericValue(覆盖缓存显示,不改 <f> 字符串)。未识别函数:evaluateFormula 仍返回 true 但表达式当数字 parse,保存时公式原文保留。
ChartPivotOps写(Writer 调用):
buildChartXml:最小 c:chartSpace,series 用 strRef/numRef 存 区域字符串(如 Sheet1!:),不内联数据点buildPivotCacheDefinitionXml:cacheSource/worksheetSource@ref + cacheFieldsbuildPivotTableXml:location@ref + pivotFields 角色(row/col/data)读(Loader 调用 loadChartsFromDrawing / pivot 解析):从 drawing rel 找 chart Part,反向填 ChartModel;供 get_charts 与 round-trip 测试。
MVP 边界:cache records 用固定占位 XML;不在库内 刷新 透视数据。
Dll/Src/bridge/BridgeJson.cpp 三套输出,供 C API get_preview / get_structure / get_open_consistency 返回:
| 函数 | 内容 |
|---|---|
buildStructureJson |
workbook → sheets → cells 的 {ref,text,formula,row,col} 扁平列表 |
buildPreviewJson |
每 sheet:rows 二维稀疏数组(仅 isMergeOrigin 格)、colWidths/rowHeights 数组、format 对象、freeze/chartCount 等 |
buildConsistencyJson |
sheet_count、cell_count、shared_string_count、style_count、part_count 与 Model 自检 |
Preview 的 rows 构造:扫 cells 得 maxRow/maxCol,逐行逐列查 map,跳过 merge 非 origin 格;appendFormat 输出 CellFormat 字段。JSON 手写拼接 + jsonEscape,与 C API 入参解析对称。
Dll/Src/model/WorkbookOps.cpp:
removeWorksheet:至少保留 1 个 Sheet;删除后重排 index/partPath(sheet1.xml 命名与 index 对齐)renameWorksheet / setActiveSheet:只改内存,save 时写入 workbook.xmlunmergeCells:删 merges 项,范围内非 origin 空 cell 删除,span 重置超链接、批注、数据验证、条件格式、Table、打印:CApi 层构造对应 *Model push 到 WorksheetModel 向量,Writer 在 11.4 节序列化为对应 XML 子树。
insert_image:读文件 bytes → ImageModel{data, mimeType, fromCol/Row, toCol/Row};save 时进 drawing + media。
| 手段 | 验证点 |
|---|---|
GTest XlsxPhase0~XlsxPhase6Deep |
API、流式、加密、图表透视 |
python_ref/dump_workbook.py |
openpyxl 黄金 JSON |
structure_golden_test.py / preview_format_test.py |
Bridge 字段契约 |
详见 Tests/COVERAGE.md。
已实现(3.1):OPC 全 Part 保真、Sheet/Cell 类型化、样式去重、合并/冻结/筛选、富文本/图片/批注/超链接、公式子集、图表透视 MVP、SXSSF 式 flush、lazy sheet、库内加密 round-trip。
未实现:完整 Excel 函数、透视刷新、复杂 chart、VBA、与 Excel 100% 加密互操作。
Package.cpp — 理解 Part 如何进内存WorkbookLoader.cpp — loadFromPackage + loadWorksheetXmlWorkbookTypes.h — 内存形状sdtech_xlsx_api.cpp — set_cell / set_cell_style 如何改 ModelWorkbookWriter.cpp — syncParts + writeCellXmlStyleSheetBuilder.cpp — 样式去重StreamingOps.cpp / FormulaEngine.cpp — 扩展行为BridgeJson.cpp — JSON 输出契约OoxmlCrypto.cpp — 加密包装Libs/sdtech_xlsx/
├── include/sdtech_xlsx_c.h
├── Dll/Src/
│ ├── opc/Package.cpp Part.cpp ContentTypes.cpp OoxmlCrypto.cpp OpcConstants.cpp
│ ├── oxml/OxmlUtil.cpp
│ ├── model/
│ │ WorkbookLoader.cpp WorkbookWriter.cpp WorkbookTypes.h WorkbookOps.cpp
│ │ StyleSheetBuilder.cpp CellRef.cpp DateUtil.cpp ThemeResolver.cpp
│ │ FormulaEngine.cpp ChartPivotOps.cpp StreamingOps.cpp
│ ├── bridge/BridgeJson.cpp
│ ├── CApi/sdtech_xlsx_api.cpp internal.h
│ └── Impl/MinimalZip.cpp XlsxWorkbook.cpp
├── Docs/python-openpyxl-port-map.md
└── Tests/
版本:3.1.0(SDTECH_XLSX_VERSION_* = 3 / 1 / 0)