夜雨聆风学习资料网

ARTICLE · 1221649

零依赖 C# Office 库到底怎么用——四个场景讲透 Wisdom.Office

零依赖 C# Office 库到底怎么用——四个场景讲透 Wisdom.Office

这是一篇"功能导读",不是 API 参考,也不是分步教程。参考文档回答"有什么",教程回答"照着做一遍",而这篇想回答的是另一个问题:手上有真实业务需求时,该从哪个入口进来、四个场景之间怎么选、以及哪些行为只有真正跑过才知道。

文中的代码全部按仓库里的公开签名写成,数字全部是实测输出(可在 docs/ 下五份分步教程里复现)。

先给出这个库的画像:

  • 纯 C# 自研,不依赖任何第三方 NuGet 组件,也不依赖 Microsoft Office 或 WPS。ZIP、XML、OLE2 复合文档、PDF、OOXML、BIFF8 都是自己实现的。
  • 覆盖 PDF(读+写)、DOCX(读+写)、DOC(读)、XLSX(读+写)、XLS(读)。
  • 每个包同时提供六份资产:net461、netstandard2.0、net7.0、net8.0、net9.0、net10.0,源码里没有一处 #if NET* 条件编译。
  • 四个目标场景:报表导出、文档互转、模板填充、通用文档模型。

0. 先把层次看清楚,再决定装哪个包

五个包的分工是纵向的,不是横向平铺的:

Wisdom.Office.Core   统一文档模型 + 工作簿模型 + 演示模型 + 模板引擎 + OLE2/ZIP/压缩基础设施Wisdom.Pdf           PDF 1.7 生成与解析、TrueType 字体子集嵌入、文本与表格提取Wisdom.Word          DOCX 读写、模板填充、.doc 读取、文档整理/校验/差异/统计Wisdom.Excel         XLSX 读写、模板填充、图表/条件格式/锚定图片、.xls(BIFF8) 读取Wisdom.Office        门面:格式检测、格式互转、Excel→Word/PDF 排版、Report 报表 DSL

依赖方向只有一个:上面吃下面,Core 谁都不吃。所以选型规则很简单:

  • 只做报表导出或格式转换:装门面包 Wisdom.Office 就够了,它会传递带上其余四个。
  • 只写 Excel(服务里生成台账、看板):单装 Wisdom.Excel,它只依赖 Core。
  • 只解析 PDF 抽字段:单装 Wisdom.Pdf。
  • 想自己控制排版(比如做一套企业内部的版式规范):直接用 Wisdom.Office.Core 的模型,把渲染交给 Wisdom.Office 的 OfficeConverter.ToPdf/ToWord。

安装始终是一条命令(版本与门面包保持一致,当前 1.0.0):

dotnet add package Wisdom.Office

有一个容易被忽略的点:六份资产由同一份源码编译。这意味着升级到 .NET 11 时不必重新打包(NuGet 会选中 net10.0 或向前回退),也意味着 .NET Framework 4.6.1 上的行为和 .NET 10 上完全一致——这一点不是推论,端到端测试套件在 net8.0 和 net461 上各跑一遍,用同一套断言。


场景一:报表导出——一份数据,三种格式

这是最常进来的入口,也是这个库把"复杂度收起来"做得最彻底的地方。

1.1 数据只描述一次

Report 是一个构建在统一模型之上的轻量 DSL,它自己不做排版引擎,只描述"有哪些表、每列多宽、什么格式、怎么对齐",导出时由对应格式的引擎去解释:

using System;using System.Collections.Generic;using Wisdom.Office;using Wisdom.Office.Core;record Sale(string Product, int Qty, decimal Amount, DateTime Date);List<Sale> sales = new List<Sale>{new Sale("无线键盘", 12, 3594.00m, new DateTime(2026, 7, 5)),new Sale("机械鼠标", 30, 2697.00m, new DateTime(2026, 7, 6)),new Sale("27 英寸显示器", 5, 9495.00m, new DateTime(2026, 7, 7)),};Report report = new Report("季度销售报表");report.Subtitle = "2026 年第三季度";report.Author = "示例公司";report.FooterText = "由 Wisdom.Office 自动生成";report.GeneratedAt = new DateTime(2026, 10, 21, 9, 30, 0);report.ShowGeneratedTime = true;ReportTable table = report.AddTable("产品销售明细");table.AddColumn("产品", 120, null, HorizontalAlignment.Left);table.AddColumn("数量", 60, "0", HorizontalAlignment.Right);table.AddColumn("金额", 100, "#,##0.00", HorizontalAlignment.Right);table.AddColumn("日期", 90, "yyyy-MM-dd", HorizontalAlignment.Center);foreach (Sale sale in sales) table.AddRow(sale.Product, sale.Qty, sale.Amount, sale.Date);report.AddNote("金额单位:人民币元,均为含税价。");

AddColumn(header, width, numberFormat, alignment) 里的宽度是磅,numberFormat 用的是 Excel 那套格式串(#,##0.00、yyyy-MM-dd、0;-0;"—" 都支持),这意味着同一份报表定义在三种格式里的数字呈现是一致的——它不是"三个导出器各自猜"。

1.2 样式必须在导出之前定完

外观走 report.Style,一份样式同时作用于三种格式:

report.Style.FontFamily = "Microsoft YaHei";report.Style.BaseFontSize = 9;report.Style.HeaderFill = OfficeColor.FromHtml("#1F4E79");report.Style.HeaderTextColor = OfficeColor.White;report.Style.AlternateRowFill = OfficeColor.FromHtml("#F2F7FC");report.Style.ZebraStripes = true;report.Export("out/sales.pdf");     // 按扩展名分派report.Export("out/sales.docx");report.Export("out/sales.xlsx");byte[] pdfBytes = report.ToPdf();   // 或者直接拿字节byte[] docxBytes = report.ToWord();byte[] xlsxBytes = report.ToExcel();

ToDocument() / ToWorkbook() / ToPdf() 每次调用都从当前定义重新构建。所以顺序错了不会报错,只会静悄悄地出一份没样式的产物——这是这个入口唯一的"反直觉"之处,值得写进团队的 review 清单。

1.3 导出物各自落在哪里

Report 替你做掉的事,按格式分别是:

  • PDF:标题、副标题、生成时间、表题、表头底纹、斑马纹、边框、备注,表格跨页时表头自动重复。
  • DOCX:同一套内容映射成段落与表格,表头行标成 IsHeader 且 RepeatHeaderRow = true。
  • XLSX:竖排的记录区,并给数据区加冻结窗格。

回读 XLSX 时会看到一个固定的行号结构(实测):

第 1 行  季度销售报表        标题第 2 行  2026 年第三季度     副标题第 3 行  生成时间 …          ShowGeneratedTime 打开时第 4 行  (空行)第 5 行  产品销售明细        表题第 6 行  产品 数量 金额 日期  表头第 7 行  无线键盘 …          第一条数据

back.GetCell(7, 3).Value 取回来是数值 3594 而不是 "3,594.00"——decimal 在写入时被转成 double 存成数字单元格,格式串只影响显示。写校验脚本时这点很关键:你要断言的是值,不是文本。

另外两条实测行为:

  • DOCX 侧:当文档以表格结尾时,写出器会补一个空段落。OOXML 要求 body 以 w:p 结束,这是合规代价,不是 bug;但回读断言块数时会多出一块。
  • PDF 侧:表头跨页重复是可以直接验证的。docs/Wisdom.Pdf.Tutorial.md 里那份长表格的实测结果是 pages=2、hasLastRow=True、pagesWithHeader=2/2,页脚每页都能匹配到 第 N /。

1.4 需要超出 Report 的表达力时

Report 故意不提供页眉页脚细节、图文混排、跨列合并。真要那些,就取中间模型继续手工加工:

OfficeDocument document = report.ToDocument();document.Sections[0].Blocks.Add(new PageBreak());   // 备注另起一页OfficeWorkbook workbook = report.ToWorkbook();workbook.Worksheets[0].SetColumnWidth(1, 28);       // 默认列宽可以再调

这一步之后你就脱离了"报表 DSL",进入场景四的统一模型——但两者之间没有断层,Report 产出的就是同一套模型。


场景二:文档互转——按内容认格式,按选项排版

2.1 先别管扩展名

OfficeFile.Load 用文件内容签名判断格式,扩展名写错照样能读:

OfficeFile file = OfficeFile.Load("out/sales.xlsx");Console.WriteLine(file.Format);          // XlsxConsole.WriteLine(file.Metadata.Title);  // 季度销售报表Console.WriteLine(file.ExtractText());   // 纯文本摘要file.Save("out/sales-copy.xlsx");        // 同格式回存byte[] pdfBytes = file.ToArray(OfficeFileFormat.Pdf);

只想路由、不想构造完整模型时用 OfficeFileFormats.Detect(path),返回 Pdf / Docx / Doc / Xlsx / Xls / Unknown。这一步很轻,适合放在上传接口的前面做分派。

按格式拿底层模型:

属性 / 方法
适用格式
得到
file.Workbook
Xlsx、Xls
OfficeWorkbook
file.Document
Docx、Doc
OfficeDocument
file.Pdf
Pdf
PdfDocument
file.AsDocument()
Xlsx、Xls
按默认排版映射出的 OfficeDocument
file.AsDocument(options)
Xlsx、Xls
按指定排版选项映射出的 OfficeDocument

2.2 转换矩阵,以及它为什么长这样

DOCX / DOC  ->  PDF、DOCXPDF         ->  DOCXXLSX / XLS  ->  PDF、DOCX、XLSX

两条边界值得说清:

  • Word / PDF 不能输出 XLSX。 文档模型里没有"单元格地址"这个概念,反向映射会丢语义,所以不提供。
  • .doc / .xls 只能作为源。 旧版二进制格式只实现了解析。而且 .xls 的公式只保留缓存值——用 XlsReader 读一份优化器求解结果工作簿,实测 公式单元格数 = 0,所有数值都是 BIFF8 里存的最后计算结果。指望读回公式再重算的,要另找方案。

2.3 Excel → PDF/Word:真正需要你决策的地方

工作簿没有"页"的概念,WorkbookToDocumentConverter 必须替你决定怎么折。这就是 WorkbookLayoutOptions 存在的原因,它的默认值本身就是一份"常规打印"的策略:

字段
默认
作用
IncludeSheetNamestrue
每个工作表前输出一级标题(表名)
IncludeHiddenWorksheetsfalse
隐藏工作表是否参与转换
UsePrintSetuptrue
沿用纸张、方向、页边距、打印区域、分带
UseGridLinestrue
用网格线代替逐格边框
PageSetupnull
非 null 时统一覆盖所有工作表的页面设置
FontFamily
 / FontSize
"Arial"
 / 9
正文字体族与字号
GridColor
浅灰 #D0D0D0
网格线颜色
EmulateNumberOverflowtrue
列宽不足时按 Excel 规则输出 ####

横向大表的推荐组合不是在转换时硬调,而是把排版意图留在源工作簿里,转换时沿用:

using Wisdom.Excel;OfficeWorkbook data = XlsxReader.Load("out/sales.xlsx");Worksheet sheet = data.Worksheets[0];sheet.PrintSetup.IsLandscape = true;sheet.PrintAreaFirstRow = 1;sheet.PrintAreaLastRow = 60;sheet.PrintAreaFirstColumn = 1;sheet.PrintAreaLastColumn = 16;OfficeConverter.WorkbookToPdf(data, "out/wide.pdf", new WorkbookLayoutOptions{    UsePrintSetup = true,    IncludeSheetNames = true,    IncludeHiddenWorksheets = false});

2.4 三个高频"为什么输出不对"

中文变方块。 PDF 生成需要从系统字体目录嵌入 TrueType 字体,且只支持 glyf 轮廓,不支持 CFF/OpenType(OTTO)。Windows 默认可用;精简容器里请装 Noto 或文泉驿,或用 Wisdom.Pdf 的 FontResolver 显式注册字体文件。字体族名匹配会做归一化——FontResolver.NormalizeFamily("  Microsoft  YaHei ") 得到 microsoftyahei(去空格、转小写),所以传 "Microsoft YaHei" 和 "microsoftyahei" 是同一件事。

数字变成 ####。 这是 EmulateNumberOverflow = true 时按 Excel 规则的模拟输出,属于正确行为。要么加宽源列,要么把该选项设为 false 直接输出文本。注意区分:Cell.DisplayText() 本身不会输出 ####,它只负责按数字格式渲染文本;#### 是排版层的决定。

隐藏内容忽隐忽现。 两层默认值方向相反,这是最容易踩的:ExcelReadOptions 的 IncludeHiddenWorksheets / IncludeHiddenRows / IncludeHiddenColumns默认全为 true(读的时候什么都给),而 WorkbookLayoutOptions.IncludeHiddenWorksheets默认 false(转 Word/PDF 时丢掉隐藏表)。真实考勤报表里这一点很具体:那份工作簿的第一个工作表是隐藏的取数区,1556 行 x 15 列,关掉转换侧的隐藏表开关才能只拿到 4 张业务表。

2.5 PDF → Word 是"重新排版",不是"还原"

OfficeConverter.Convert("scan-output.pdf", "out/summary.docx") 走的是解析文本与表格、再按统一模型重排。产出的是"内容 + 大致结构"的可编辑文档,不追求与原始 PDF 逐像素一致。表格识别基于文本位置与线条,属启发式,合并单元格、无边框表、跨页表需要按坐标自行校正。

提取侧还有一条实测细节会影响你的断言写法:PdfTextLine.Text 会把同一行的多个单元格用空格连起来,例如 序号 巡检项 结果 用时(min)。按整段原文 == 比较会失败,应该用 Contains 或者逐片段(Fragments 保留各自坐标与字号)判断。

2.6 服务场景:全程字节

// 单向、无需排版选项publicstaticbyte[] ToDocx(byte[] sourceDocx){return OfficeConverter.Convert(sourceDocx, OfficeFileFormat.Docx);}// Excel -> PDF 且要控制排版publicstaticbyte[] BuildPdf(byte[] sourceXlsx){    OfficeWorkbook workbook = XlsxReader.Load(sourceXlsx);return OfficeConverter.WorkbookToPdf(workbook, new WorkbookLayoutOptions    {        IncludeSheetNames = true,        UseGridLines = true    });}

这里有个容易写错的签名事实:OfficeConverter.Convert 的字节重载只有(byte[], OfficeFileFormat) 一个,不接受排版选项。要带 WorkbookLayoutOptions 走字节路径,就先 XlsxReader.Load(bytes) 再 WorkbookToPdf(workbook, options);文件路径版才有 (sourcePath, targetPath, layout) 三参重载。


场景三:模板填充——让业务方改模板,不改代码

前两个场景是"代码决定版式",模板填充反过来:版式冻结在模板文件里,代码只喂数据。 这对合同、通知单、检验报告这类"格式是法务/质量部门定的"的场景更合适。

3.1 语法只有这么点

{{key}}                 简单值{{key:format}}          带格式串(数字、日期同 Excel 那套){{obj.prop}}            点路径{{#each list}} ... {{/each}}   循环

循环体内可用 @index(从 0)、@number(从 1)、@first、@last 和 .(当前项本身)。不支持 [i] 下标——要按下标取,先把列表投影成需要的形状。

看起来太简单了,但"跨 Run"这件事只有自研解析才处理得干净:Word 会把一段文字按格式切成若干 w:r,{{customerName}} 经常被拆成 {{、customer、Name}} 三个 run。模板引擎在文本层面重组占位符,所以模板里手打的占位符不需要"保证在一个 run 内"。

3.2 Word:段落级与表格行循环

模板长这样:

{{customerName}},您好:我方确认以下订单:{{#each items}}{{name}} 数量 {{qty}} 金额 {{amount:0.00}}{{/each}}合计:{{total:0.00}} 元 签订日期 {{signDate:yyyy-MM-dd}}
TemplateContext context = new TemplateContext();context["customerName"] = "示例客户";context["total"] = 1268.0;context["signDate"] = new DateTime(2026, 10, 21);context["items"] = new[]{new { name = "无线键盘", qty = 2, amount = 398.0 },new { name = "机械鼠标", qty = 5, amount = 449.5 },new { name = "清洁服务", qty = 1, amount = 420.5 }};TemplateOptions options = new TemplateOptions{    LeaveUnresolvedTokens = false,   // 没给值的占位符留空,而不是保留 {{xxx}}    HtmlEncodeValues = false,    Culture = System.Globalization.CultureInfo.InvariantCulture};DocxTemplate.Fill("out/contract-template.docx", "out/contract.docx", context, options);

实测填出来的正文:

示例客户,您好:我方确认以下订单:无线键盘 数量 2 金额 398.00机械鼠标 数量 5 金额 449.50清洁服务 数量 1 金额 420.50合计:1268.00 元 签订日期 2026-10-21

段落级 {{#each}} 会把标记所在段落整体换成 N 份内容,列表为空时循环块整块消失。表格行循环写法相同,把 {{#each}} / {{/each}} 放在循环行的首列与末列。DocxTemplate.Fill 提供文件、字节、流三种形态,字节版直接返回填充后的 byte[]。

3.3 Excel:行级循环,样式与合并跟着复制

Excel 侧循环以"行"为单位,标记可以跨一行或多行:

sheet.GetCell(4, 1).Value = "{{#each items}}{{name}}";sheet.GetCell(4, 2).Value = "{{qty}}";sheet.GetCell(4, 3).Value = "{{amount:0.00}}";sheet.GetCell(4, 4).Value = "{{@number}}";sheet.GetCell(4, 5).Value = "{{/each}}";sheet.GetCell(6, 1).Value = "合计";sheet.GetCell(6, 3).Value = "{{total:0.00}}";

三条数据填充后(实测):

3: 品名 | 数量 | 金额 | 序号4: 无线键盘 | 2 | 398.00 | 15: 机械鼠标 | 5 | 449.50 | 26: 清洁服务 | 1 | 420.50 | 37:  |  |  |8: 合计 |  | 1268.00 |lastRow=8

值得注意的两件事:模板行的样式、行高、列宽、合并区域会逐份复制,所以"金额列右对齐 + 千分位"只需在模板里做一次;合计 那行整体下移,地址不需要在代码里重算。列表为空时循环块删掉、后续行上移,同一份模板填零条数据得到 lastRow=5,A5 就是原来的 合计——空列表不会留下一行带 {{}} 的残骸,这对自动生成"零订单客户"对账单很重要。

内存版重载返回新工作簿,模板实例不被污染,可以在服务里加载一次模板、并发填充多次:

OfficeWorkbook inMemory = XlsxTemplate.Fill(XlsxReader.Load("out/order-template.xlsx"), context);

实测对照:模板文件里的 A1 仍是 客户:{{customerName}},结果里的 A1 是 客户:示例客户。

3.4 TemplateOptions 的三个开关各管一类事故

  • LeaveUnresolvedTokens:true 时未给值的占位符原样留在文档里。给"人工补录"的流程用 true;给客户看的产物必须 false,否则 {{invoiceNo}} 会印在合同上。
  • HtmlEncodeValues:值里含 <、& 时是否需要转义。打开它,转义由引擎负责,别在业务层重复处理。
  • Culture:决定 {{amount:0.00}} 的小数点与千分位分隔符。跨环境一致输出请固定 InvariantCulture。

场景四:通用文档模型——前三个场景脚下的东西

Report 和转换器都是模型的消费者。当你需要的版式超出 DSL 能表达的范围(不规则表头、页眉里的公司标识与页码域、跨列合并、图注混排),就直接写模型。

4.1 文档侧:三层结构 + 三种块

OfficeDocument  └─ Section(PageSetup、Header/Footer)       └─ Block:Paragraph | Table | PageBreak            └─ Paragraph.Inlines:TextRun | FieldRun | ImageRun
OfficeDocument document = new OfficeDocument();document.Metadata.Title = "示例文档";Section section = document.AddSection(PageSetup.A4());section.Blocks.Add(new Paragraph("标题").AddText(" - 副标题", new TextFormat("Arial", 10)));Table table = new Table();table.Columns.Add(new TableColumn(160));table.Columns.Add(new TableColumn(100));table.AddRow("项目", "金额");TableRow row = table.AddRow("合计", "2,288.40");row.IsHeader = false;section.Blocks.Add(table);OfficeConverter.ToPdf(document, "document.pdf");OfficeConverter.ToWord(document, "document.docx");

几个绕不开的约定:

页码是域,不是文本。 用 FieldRun:

new FieldRun("PAGE", "{page}", new TextFormat("Arial", 9))

Word 侧写成 w:fldSimple,由 Word 打开时计算;PDF 侧由生成器把 {page} / {pages} 替换成实际数字。同一份模型因此在两种格式里都有页码,而不是一句死的"第 1 页"。

合并单元格的覆盖格要手动少放。TableCell.ColSpan / RowSpan 只描述跨度,模型不会替你校验:RowSpan = 2 的单元格,它覆盖到的下一行必须不再为该列放单元格。写错不会抛异常,后果是 DOCX 的合并结构与 Word 里显示的不一致——这是最容易在上线后才被发现的一类缺陷。想知道某个坐标被哪个合并区覆盖,用 FindMergeAnchor(row, column) 取回锚点单元格;区域字符串则用 SpreadsheetRange.TryParse("台账!$B$2:$D$9", out firstRow, out lastRow, out firstColumn, out lastColumn) 解析(实测 2,9,2,4)。

横向是交换宽高。PageSetup.IsLandscape = true 把 595.28 x 841.89 变成 841.89 x 595.28(A4 的磅值),它不是渲染时的旋转标记。

单位换算工具在 Core 里。UnitConverter.PointsToTwips(72) = 1440(Word 的表宽与边距用 twips)、UnitConverter.PointsToEmu(72) = 914400(Drawing 锚定用 EMU)。模型层统一用磅,落盘时各自换算。

4.2 工作簿侧:模型不是二维数组

OfficeWorkbook / Worksheet / Cell / CellStyle 看着直观,但有四条语义必须提前知道,否则读回来的结果会和你预期的不一样:

  • GetCell(r,c) 会创建,FindCell(r,c) 只探测。 用 GetCell 遍历稀疏表会凭空造出大量空单元格。
  • LastRow / LastColumn 把合并区和"手工创建但没赋值"的空单元格算进去。 空表 Sheet1 的 LastRow 是 0,但被你 GetCell 过的区域会算数。
  • DisplayText() 认数字格式的分节语义,包括 正;负;零;文本 四段式。12800.50 配 #,##0.00 显示成 12,800.50,配 "0;-0;\"—\"" 时零值显示成 —;[Red] 之类的颜色指令只影响显示,值本身仍是数字。
  • 列宽单位是 Excel 的"字符宽",不是磅。 要和页面排版对齐就用 GetColumnWidthPoints 换算。

4.3 Word 整理引擎:模型之上的批处理

这是场景四的一个延伸,也是实际项目里最常缺的一块:把业务方上传的"脏草稿"批量整理成规范文档。

WordNormalizeOptions normalize = new WordNormalizeOptions{    MergeShortParagraphs = false,          // 默认 true    RegenerateTableOfContents = false,     // 默认 true    RemoveTinyImages = false,              // 默认 true    BodyEastAsiaFont = "宋体",    BodyAsciiFont = "Times New Roman",    BodyFontSize = 12,    BodyLineSpacing = 1.5,                 // 行距是倍数,不是磅值    FirstLineIndentCharacters = 2,    PromoteFakeHeadings = true,    FakeHeadingMinFontSize = 14,    HeadingFontSizes = newdouble[] { 16, 15, 14, 13, 12, 12, 12, 12, 12 },    NormalizeTables = true,    UnifyTableAlignment = true,    TableAlignment = TableAlignment.Center,    RepeatTableHeaderRows = true};WordNormalizeResult result = WordNormalizer.Normalize(document, normalize);OfficeDocument cleaned = result.Document;   // 与 document 是同一个实例:就地修改

第一句要提醒的是:默认构造就是"中文公文常用值",绝大多数开关默认已打开,不是全关。所以上面这段是在改目标值并关掉演示中不需要的三项。为什么要关 MergeShortParagraphs——实测它会把"项目实施方案"和"一、实施范围"并成 项目实施方案一、实施范围,还顺带产出一个假的二级标题。

伪标题升级是按字号比例判定:章级命名模式或比例 ≥1.5 记 1 级,≥1.3 记 2 级,≥1.15 记 3 级,其余 4 级;FixHeadingLevelSkips 再把跳跃的层级夹到"上一级 +1"。实测那份草稿的结果是 变更 9 项,问题 1 项,其中包含 伪标题升级为 1 级标题: 项目实施方案 和 标题层级跳跃 4 -> 3。表格则统一把第 0 行标成表头并打开 RepeatHeaderRow。

批处理走 WordProcessor.Process(inputPath, outputPath, options):先在输入旁边生成 .backup-<时间戳>.docx,再加载、整理、写回,报告固定落在 outputPath + ".report.txt"。回滚是 WordProcessor.Rollback(backupPath, targetPath),本质是覆盖式 File.Copy——目标路径填原始文件,不是填输出文件。

验证整理效果时有两个工具,各自有明确的适用面:

  • WordDocumentDiff.Compare 只比文本行。纯格式整理后差异是 HasDifferences=False、+0 -0 =8;改了正文才会 True +1 -0。**所以它不能用来证明"整理生效了"**,那是 metrics 的活。
  • WordDocumentMetrics.Measure 统计正文 + 页眉页脚 + 单元格段落。实测整理后:段落 12,空段 1,字 92,表 1,图 0,标题 3、各级标题: 1/1/1/0、字体分布 黑体 3 处 / 宋体 8 处、字号分布 16/15/14/12。这组数字前后对比才是格式整理的有效证据。

还有一点关于克隆:库内部有文档克隆器,但没有公开的 Clone API。要在不改原件的前提下试不同整理策略,就重新从文件加载一次,或者先 CreateBackup。


五、零依赖的另一面:边界条件得自己扛

用第三方库时,畸形文件的处理是别人的事;自研时,这就是你的事。这个库在这一点上的做法可以直接抄给自己的项目。

把框架差异收敛成一种异常。.NET Framework 的 ZipArchive 延迟解析中央目录,损坏的 OOXML 条目名会在毫不相关的调用点抛 ArgumentException,绕过读取器外层的 catch (IOException)。解决办法是新增 PackageArchive.OpenForRead:打开时立即物化中央目录,把这类异常统一收敛成 InvalidDataException,已应用于 XLSX/DOCX 读取、DOCX 模板填充与格式检测四处入口。收敛保证的是"同一扇门在六个框架上表现一致",而异常类型仍取决于你从哪扇门进来——实测四类损坏输入:

XlsxReader.Load(截断的 xlsx)      -> InvalidDataException(End of Central Directory record could not be found.)OfficeFile.Load(截断的 pdf)       -> InvalidDataException(PDF 没有有效 catalog)OfficeFile.Load(随机 8 字节)      -> NotSupportedException(无法识别文件格式…)OfficeFileFormats.Detect(随机字节) -> Unknown,不抛异常

也就是说:格式专属的读取器把损坏统一表达为 InvalidDataException;门面先做内容嗅探,嗅不出来的走 NotSupportedException;只做检测则永远返回 Unknown。 服务侧要写"坏文件返回 400"的兜底,就照这三类分别处理,别只 catch 一种。

给解压量和结构规模设硬上限。

OfficePackageLimits.MaxEntryBytes   = 256 MBOfficePackageLimits.MaxTotalBytes   = 1 GBPdfReadLimits.MaxDecodedStreamBytes = 256 MBPdfReadLimits.MaxXrefEntries        = 2,000,000PdfReadLimits.MaxObjectStreamObjects = 1,000,000PdfReadLimits.MaxPageTreeDepth      = 64

流式读取用 ReadEntryBytes(entry, ref totalBytes) 累计,超限抛 DecompressionLimitException——它和 InvalidDataException 分开,是为了让调用方能区分"文件坏了"和"文件太大了",这两者在服务侧的处置策略通常不同(重试/告警 vs. 拒绝/限流)。

把攻击面写进测试而不是评论。测试套件里包含 XXE、zip 炸弹、路径穿越、zlib 解压上限、模糊输入这类用例,且同一套断言在 net8.0 与 net461 上各跑一遍。安全回归和跨框架回归共用一套基线,是自研项目里性价比高的一件事。


六、十一条实测出来的行为,文档不会主动告诉你

这些是写教程时一条条撞出来、已经写进教程但值得单独列在这里的:

  1. report.Style 必须在 Export/ToXxx 之前配置完,模型每次重新构建。
  2. Table.RepeatHeaderRow 默认 true——你以为需要显式打开,其实往往需要显式关掉。
  3. DOCX 以表格结尾时写出器补一个空段落,回读块数会多 1。
  4. ExcelReadOptions 三个隐藏开关默认 true,WorkbookLayoutOptions.IncludeHiddenWorksheets 默认 false。
  5. Cell.DisplayText() 不输出 ####;#### 来自 EmulateNumberOverflow。
  6. decimal 写入时转成 double,回读断言要用值不是文本。
  7. WordNormalizeOptions 默认几乎全开,MergeShortParagraphs 能粘出假标题。
  8. WordDocumentDiff 只比文本行,格式整理的效果要靠 WordDocumentMetrics 看。
  9. 字节版 OfficeConverter.Convert 不接受 WorkbookLayoutOptions;带选项的 Excel→PDF 字节路径要走 XlsxReader + WorkbookToPdf。
  10. 图表按 OOXML 保存,只有 Excel/WPS 会渲染;导出 Word/PDF 时输出占位文本。需要图形请在源工作簿里改用 ExcelImage 锚定图片。
  11. 坏文件的异常类型取决于入口:专属读取器给 InvalidDataException,门面嗅探失败给 NotSupportedException,Detect 只给 Unknown。

七、上手路径

建议的顺序是:先跑通门面那一份,再按你实际负责的格式深入一个包。

包
说明文档(也是包内 README)
分步教程
Wisdom.Office
src/Wisdom.Office/README.md
docs/Wisdom.Office.Tutorial.md
Wisdom.Office.Core
src/Wisdom.Office.Core/README.md
docs/Wisdom.Office.Core.Tutorial.md
Wisdom.Pdf
src/Wisdom.Pdf/README.md
docs/Wisdom.Pdf.Tutorial.md
Wisdom.Word
src/Wisdom.Word/README.md
docs/Wisdom.Word.Tutorial.md
Wisdom.Excel
src/Wisdom.Excel/README.md
docs/Wisdom.Excel.Tutorial.md

每份教程的输入文件都由教程自身生成,代码片段拼起来就是一个可运行的控制台程序,文中输出全是实测结果——包括那些"看起来像笔误"的行号和字节数。

验收产物时推荐这个姿势:回读并断言标题存在、关键数据行存在、页数合理、没有空页;不要硬记行号,遍历 sheet.Cells 找目标文本更稳:

foreach (Cell cell in back.Cells){if (cell.Value isstring s && s == "无线键盘")    {        Console.WriteLine($"row {cell.Row}, col {cell.Column}");   // row 7, col 1    }}

库自身的回归就是靠财务报表、数据看板、检验/审核报告、考勤明细四类真实版式的断言兜住的。把你自己的产物也纳入同一套检查,是上线后最省事的护栏。


八、什么情况下不该选它

把边界说清楚比承诺能力更有用:

  • 加密 PDF:不支持,读取直接失败。
  • CFF / OpenType(OTTO)字体嵌入:不支持,只嵌 TrueType glyf 轮廓;隔行扫描 PNG 也不支持。
  • 扫描件 OCR:文本提取依赖文本操作符,图像里没有字就没有字。
  • .doc / .xls 写回:只读;.doc 的复杂域、批注、修订不保证还原;.xls 只支持 BIFF8,不含加密工作簿。
  • Word/PDF → Excel:不提供。
  • 像素级复刻 PDF:PDF 保存是依据统一模型重新排版,不保留原字节和完整视觉结构。
  • 图表进 Word/PDF:见第 10 条,占位文本。

反过来说,如果你的需求是"服务器上不装 Office、不引第三方组件,把数据稳定导出成 PDF/Word/Excel,或者把上传的办公文档读成统一模型",从 .NET Framework 4.6.1 到 .NET 10 都要覆盖——这个库的四个入口恰好就是为这四件事准备的。

相关学习资料