sdtech_xlsx 原理与实现详解

纯 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.cppminiz_tinfl.c(deflate)。Windows 加密路径链接 bcrypt(SHA-512 + AES-256-CBC)。


二、xlsx 在磁盘上是什么

.xlsx 是 OOXML OPC 包:ZIP 内每个 entry 是一个 Part(路径即 Part 名,如 xl/worksheets/sheet1.xml)。SpreadsheetML 相关 Part 包括:

Part SpreadsheetML 要点
xl/workbook.xml <sheets> 列表、definedNamespivotCaches
xl/worksheets/sheetN.xml sheetData/row/c 单元格、mergeCellssheetViews/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 到 WorkbookModelWorkbookWriter::syncParts重写 被 touch 的 Part(worksheets、styles、sharedStrings、drawings…);其余 Part(未知扩展、未改 theme 等)blob 原样save 时写回。


三、OPC 层:PackagePart

3.1 Part 结构

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()blobstring 间转换;二进制 Part(图片)用 setBlob

3.2 打开:Package::open

Dll/Src/opc/Package.cpp 流程:

  1. zipReadFile(path, zipMap) → 路径名 → bytes
  2. isEncryptedZipMap(存在 EncryptedPackage entry)→ decryptOfficePackage 得内层 zipMap
  3. loadFromZipMap:每个 entry 建 Part,跳过目录项(名以 / 结尾)
  4. 解析 [Content_Types].xmlContentTypes,为各 Part 填 contentType
  5. 对每个 Part,若存在 xl/worksheets/_rels/sheet1.xml.rels 这类 sibling rels 文件,则 parseRelationshipsXml 填入 relationships
  6. wireRelationships() 补全尚未挂关系的 Part
  7. _rels/.relsRelType::OFFICE_DOCUMENT 目标,设为 mainDocumentPartName_(xlsx 即 xl/workbook.xml

关系解析后,Sheet 的 r:id 可解析为 xl/worksheets/sheet1.xml 等绝对 Part 路径(resolvePartTarget 处理 ../ 相对路径)。

3.3 空白包:Package::createBlank

createBlank() 手写最小合法 OPC 骨架(不读模板文件):

XlsxWorkbook::createBlank 在此基础上往 WorkbookModel.sheets 推入一个 WorksheetModel{ name="Sheet1", partPath="xl/worksheets/sheet1.xml", loaded=true }

3.4 保存:Package::save

  1. 遍历 parts_,对每个非 [Content_Types].xml 的 Part 调用 contentTypes.ensureOverride(partName, contentType)
  2. ContentTypes::serialize() 生成新的 [Content_Types].xml
  3. 组装 innerZip map(content types + 全部 Part blob)
  4. 无密码:逐 entry 写 ZipEntry 向量 → zipWriteFile
  5. 有密码encryptOfficePackage(innerZip, password, outerEntries) → 外层仅含加密容器四件套

WorkbookWriter::savesyncParts(model) 把 Model 写进 model.package,再 package->save(path, model.encryptionPassword)


四、MinimalZip:不依赖 libzip 的读写

Dll/Src/Impl/MinimalZip.cpp 只实现 OPC 需要的子集。

  1. 整文件读入内存
  2. 从尾部扫描 EOCD 签名 0x06054b50
  3. 读 Central Directory,对每个 entry 读 local header 0x04034b50
  4. extractZipEntry
    • method 0:stored,直接 memcpy
    • method 8:deflate,用 miniz tinfl_decompress_mem_to_mem(raw deflate,无 zlib header)

这与 sdtech_docx 共用同一套 MinimalZip 思路:读能解 Excel 默认 deflate 包,写用 store 保证实现简单、round-trip 稳定。


五、OoxmlCrypto:加密包装

Dll/Src/opc/OoxmlCrypto.cpp 实现 库内 round-trip 的简化 Agile 风格,非完整 MS-OFFCRYPTO。

识别:外层 ZIP 含 EncryptedPackage entry。

解密

  1. EncryptedPackage 前 8 字节 = 明文 ZIP 长度(uint64 LE),其后为密文
  2. EncryptionInfo 前 16 字节 salt、后 16 字节 IV(缺失则用固定占位 0x5A / 0xA5
  3. deriveKeyAgileSHA512(salt || password) 取前 32 字节为 AES-256 密钥
  4. aes256CbcDecrypt(bcrypt)→ 明文 ZIP 写临时文件 → zipReadFile 得内层 map

加密encryptOfficePackage):

  1. 内层 map 先 zipWriteFile 到临时文件
  2. 随机 salt/IV,aes256CbcEncrypt(PKCS#7 padding)
  3. 外层 ZIP 四个 Part:[Content_Types].xml_rels/.rels(指向 EncryptedPackage + EncryptionInfo)、EncryptionInfoEncryptedPackage

WorkbookModel::encryptionPassword 非空时 Package::save 走加密分支。Excel 原生打开未完全保证——GTest 验证的是库内加解密后再 WorkbookLoader 可读。


六、OxmlUtil:命名空间无关的 XML 访问

SpreadsheetML 根节点带 xmlns,子元素可能是 c 或前缀形式。 Dll/Src/oxml/OxmlUtil.cpplocal-name 匹配:

Loader/Writer 不依赖 XPath;全部 imperative 遍历 pugixml DOM。parseXmlBytes(Part::blob) 直接 parse buffer,避免多余 string 拷贝。


七、打开链路:WorkbookLoader

入口:XlsxWorkbook::loadFromPathPackage::openWorkbookLoader::loadFromPackage(std::move(pkg), model, lazyLoad)

7.1 工作簿级解析

  1. 样式表StyleResolver::loadFromPackagexl/styles.xml(见第八节)
  2. 共享字符串:遍历 xl/sharedStrings.xml<si>readSharedString 支持 plain <t> 或 rich <r><t>
  3. workbook.xml
    • <definedNames>DefinedNameModel{name, formula, sheetIndex}
    • <sheets><sheet name sheetId r:id state> → 用 workbook.xml.relsr:id 映射为 ws.partPath
  4. lazyLoad 分支
    • false:对每个 sheet Part 调 loadWorksheetXml + loadWorksheetExtensions
    • true:只填 Sheet 元数据,ws.loaded = false解析 sheetData(Part blob 仍在 Package 里)

7.2 工作表级:loadWorksheetXml

Dll/Src/model/WorkbookLoader.cpp 顺序解析 worksheet 子树:

XML 节点 写入 WorksheetModel
sheetViews/sheetView/pane freezeRowySplitfreezeColxSplit
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

7.3 扩展 Part:loadWorksheetExtensions

图表/透视 路径在 ChartPivotOps.cpp 反向解析 XML 填 ChartModel / PivotTableModel

7.4 按需加载:loadSheet

WorkbookLoader::loadSheet(model, index):若 ws.loaded 已 true 则返回;否则从 package 取 sheet Part blob,重新 StyleResolver::loadFromPackage,执行与 7.2 相同的 loadWorksheetXml。lazy 打开的大文件只有调用 load_sheet 时才分配 cells map。


八、样式:读 StyleResolver、写 StyleSheetBuilder

8.1 读取:扁平化为 CellFormat

StyleResolver 在 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

日期识别:looksLikeDateNumFmt(numFmt) 检查格式串是否含 y/d/h。

8.2 写入:去重生成 styles.xml

写路径 保留原 styles.xml 结构,而是根据内存中所有单元格的 CellFormat 重新构建

  1. StyleSheetBuilder::assignStyleIndices(model) 遍历每个 cell.formatindexFor(format) 分配 styleIndex
  2. indexForformatKey(fmt) 字符串做 dedup map;新格式则:
    • internFont / internFill / internBorder / internNumFmt 各自表内去重
    • 追加 RawXfxfs_
  3. buildStylesXml() 输出完整 styleSheet XML;自定义 numFmt 从 id 164 递增(nextCustomNumFmtId_

内存里样式是 富结构 CellFormat;磁盘上是 索引 s="3"。打开时 expand,保存时 collapse——与 openpyxl 懒样式思路一致。


九、内存模型:WorkbookModelWorksheetModel

定义于 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[])。

CellRefDll/Src/model/CellRef.cpp):列字母 bijective 映射(A→1, Z→26, AA→27);fromA1/toA1 供合并区域、公式引址使用。

日期 Dll/Src/model/DateUtil.cpp:Excel 序列日基准 1899-12-30isoDateToExcelSerial / excelSerialToIsoDate 用 civil calendar 算法,与 Loader 识别 isDate 配合。


十、C API 如何改 Model(库边界)

Dll/Src/CApi/sdtech_xlsx_api.cpp 不持有第二份状态:每个 void* 句柄即 XlsxWorkbook*,其 model 成员即权威数据。

典型写单元格 set_cell 路径:

  1. resolveSheetIndex:按 JSON "sheet":0"sheet":"Name"WorksheetModel;若 lazy 且未加载则 loadSheet
  2. applyCellFromJson:根据 typevalueType/text/numericValue/boolValue/isDate/formula
  3. ws->cells[ref] = cell
  4. maybeFlushStreamingRows(model, *ws)(流式模式)

set_cell_style 只改已有或新建格的 CellFormat 字段;styleIndex 在 save 前StyleSheetBuilder::assignStyleIndices 统一分配。

JSON 解析为 轻量 strstrjsonGetString/jsonGetInt),避免链接完整 JSON 库;C API 层用 mutex 保护 init,错误信息 thread_local g_last_error


十一、保存链路:WorkbookWriter::syncParts

WorkbookWriter::savesyncPartsPackage::savesyncParts 是库内最复杂的函数,按 依赖顺序 写 Part:

11.1 共享字符串表

两遍扫描所有 已加载 Sheet 的 cellsflushedRows

公式单元格 进 sst(值在 <f>/<v>)。

11.2 样式表

StyleSheetBuilder::assignStyleIndices + buildStylesXml()xl/styles.xml

11.3 工作簿与关系

重写 xl/workbook.xml(sheets 列表、definedNames、pivotCaches 占位)和 xl/_rels/workbook.xml.rels(每个 sheet 一个 worksheet rel + styles + sharedStrings + pivotCache rels)。

11.4 每个 Sheet 的 worksheet.xml

对每个 ws.loaded==true 的 Sheet:

  1. 合并 gridcells + flushedRows 放入 map<row, map<col, CellModel*>>
  2. 流式行界rowBegin = streamingMode ? max(1, ws.streamingMinRow) : 1,只输出窗口内及 flushed 行
  3. 序列化子节:sheetViews(冻结)、pageSetup、cols、sheetData(按 row 排序,每 row 内 writeCellXml)、autoFilter、mergeCells、hyperlinks、dataValidations、conditionalFormatting、table、drawing/pivot 占位 ref
  4. writeCellXml 逻辑摘要:
    • !isMergeOrigin → 跳过
    • s 属性:styleIndex
    • 公式:<f> + 可选 <v>
    • 布尔 t="b"、数字/日期 bare <v>、richText inlineStr+<is>、普通文本 t="s"+sst 索引

列宽写回:width = colWidths[col] / 7(与读路径互逆)。行高:ht = rowHeights[row] * 72/96

11.5 图片、图表、透视 Part

若 Sheet 有 imagescharts

透视表:预先收集 PivotWritePlan(cacheId、cacheDef/Records/pivotTable 路径),写 buildPivotCacheDefinitionXmlbuildPivotTableXml、占位 buildPivotCacheRecordsXml

批注:独立 xl/commentsN.xml Part。

最后 model.sharedStrings = sharedStrings 保持内存与磁盘 sst 一致。


十二、流式写入:StreamingOps

对标 POI SXSSFDll/Src/model/StreamingOps.cpp maybeFlushStreamingRows

streamingMode && (maxRow - streamingMinRow + 1) > streamingWindowRows
  → 把 streamingMinRow 那一行从 cells 移到 flushedRows[row]
  → streamingMinRow++

createBlank(..., streaming=true, windowRows) 设置 WorkbookModel::streamingModestreamingWindowRows(默认 100)。限制: flushed 行不再支持随机改(库未禁止,但 Excel 语义上已「落盘行」)。


十三、公式引擎:FormulaEngine

Dll/Src/model/FormulaEngine.cpp递归 descent 子集,不是完整计算引擎:

evaluateCell:求值结果写回 cell.textvalueType="n"numericValue覆盖缓存显示,不改 <f> 字符串)。未识别函数:evaluateFormula 仍返回 true 但表达式当数字 parse,保存时公式原文保留


十四、图表与透视:ChartPivotOps

(Writer 调用):

(Loader 调用 loadChartsFromDrawing / pivot 解析):从 drawing rel 找 chart Part,反向填 ChartModel;供 get_charts 与 round-trip 测试。

MVP 边界:cache records 用固定占位 XML;不在库内 刷新 透视数据。


十五、Bridge:Model → JSON(库内序列化)

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 入参解析对称。


十六、Sheet 生命周期与其它 Model 操作

Dll/Src/model/WorkbookOps.cpp

超链接、批注、数据验证、条件格式、Table、打印:CApi 层构造对应 *Model push 到 WorksheetModel 向量,Writer 在 11.4 节序列化为对应 XML 子树。

insert_image:读文件 bytes → ImageModel{data, mimeType, fromCol/Row, toCol/Row};save 时进 drawing + media。


十七、测试与能力边界

手段 验证点
GTest XlsxPhase0XlsxPhase6Deep 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% 加密互操作。


十八、建议阅读顺序(跟代码走一遍)

  1. Package.cpp — 理解 Part 如何进内存
  2. WorkbookLoader.cpploadFromPackage + loadWorksheetXml
  3. WorkbookTypes.h — 内存形状
  4. sdtech_xlsx_api.cppset_cell / set_cell_style 如何改 Model
  5. WorkbookWriter.cppsyncParts + writeCellXml
  6. StyleSheetBuilder.cpp — 样式去重
  7. StreamingOps.cpp / FormulaEngine.cpp — 扩展行为
  8. BridgeJson.cpp — JSON 输出契约
  9. 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)