news 2026/9/25 6:35:16

.NET实战:Aspose.Words基于Word模板批量生成合同与PDF导出

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET实战:Aspose.Words基于Word模板批量生成合同与PDF导出

简介:面向.NET开发者的Aspose.Words实战Demo源码,聚焦如何依据Word模板批量生成个性化文档。源码围绕Document加载模板、MailMerge执行合并、字段识别与替换等核心API展开,涵盖从数据库或自定义数据类型注册数据源的完整流程;同时还展示了占位符的多格式处理、多区域模板合并和异常反馈逻辑。Demo中可以看到如何识别{{name}}、{MERGEFIELD CustomerName}等占位符,并根据业务对象批量填充客户姓名、合同条款、统计报表等内容。通过对照阅读,开发者可系统掌握合同、信函、报告等场景下的自动生成方案,将重复性文档工作转化为模板加数据的标准流程。压缩包约77.76MB,内容以C#源码为主,便于直接打开调试和扩展。该资源已有575人学习下载,适合希望减轻手动编辑负担、提升文档处理效率的.NET工程师。

1. 别再用手工改 Word:Aspose.Words 模板生成究竟解决什么问题

做企业合同批量生成这几年,我最怕的不是业务逻辑,是运营改完 Word 模板后我这边没跟上。Aspose.Words for .NET 这套根据 Word 模板创建文档的 Demo 源码,解决的就是这类具体问题:模板里先留好占位符和书签,代码一跑,几十份带客户名、金额、日期的正式合同直接生成,还能顺手导出 PDF,模板里的字体、表格线、页眉页脚全部保留。它不是把文本硬塞进文档,而是真正基于模板渲染,服务器上不用装 Office。适合在 .NET 平台被报表、合同、公文生成折磨的后端,也适合想从 NPOI 硬拼 Word 换条路的人。下面按我实际拆包验证的顺序讲,选型、模板设计、核心代码,还有几处不跑一次根本发现不了的坑。

2. 环境与选型:.NET Framework 4.8、License 和两套模板方案的取舍

2.1 为什么是 Aspose.Words:跟 NPOI、OpenXML 的真实差距

先说选型背景。我做文档生成最早用 NPOI,处理 Excel 顺手,但 NPOI 对 Word 的支持远不如 Excel。docx 的表格嵌套、书签、MERGEFIELD 域、目录页这些,NPOI 很多都没封装。硬要写就得自己解析 OpenXml,相当于重写半个库,而且 NPOI 在 .NET 里的 Word 模块长期处于半维护状态,遇到复杂模板只能干瞪眼。

再用 OpenXML SDK 是微软官方免费方案,但它是底层 SDK,生成文档的每一步都是直接操作 XML 模型:要在指定位置插一个带格式的段落,你得维护好前面的元素顺序;表格复制行这种操作要处理大量节点关系。之前有个外包团队用 OpenXML 写了两个月模板渲染,最后卡在表格循环行上,换 Aspose.Words 不到一周就稳定跑通。这个对比在职场上很常见——不是 OpenXML 不能用,而是高层库省掉的那部分心智负担,正好是业务开发最缺的。

方案模板还原度表格/书签/域支持服务端使用授权成本
Aspose.Words高完整无 Office 依赖商业授权
NPOI低到中弱,docx 支持粗糙无 Office 依赖免费(Apache 2.0)
OpenXML SDK中中,需自建封装无 Office 依赖免费(MIT)
VSTO高完整依赖 Office 安装需 Office 授权

Aspose.Words 的定位是把 Word 当成一个文档对象模型来操作的高层库,Range.Replace、Bookmark、MailMerge这些能力,正好对应该 Demo 要做的模板替换场景。商业授权是成本,但相比开发人员一个月工资加加班费,这笔账通常划算。选它还有个隐藏理由:评估阶段可以直接跑,业务验证通过再谈授权,节奏上可控。

2.2 环境准备:.NET Framework 4.8 与 License 初始化

Demo 按 .NET Framework 4.8 编译,跑之前先把运行库装好。Windows Server 上直接装 net48 运行库就行,别跟 .NET Framework 3.5 那套离线包混,两个体系,装错报错信息也不一样。检查机器上有没有装 4.8,可以在 cmd 里敲这条命令:

reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Release

Release 值大于 461808 基本可以认为已装 4.8;如果这个键不存在,说明连 v4 运行库都没有,直接去装 net48 的离线安装包。注意服务器上如果跑着老程序依赖 3.5,4.8 安装不会覆盖它,两者共存没问题。

项目里的 NuGet 引用是这样加的:

Install-Package Aspose.Words

在 Visual Studio 的包管理器控制台执行,或者直接打开 NuGet 管理界面搜索 Aspose.Words 点安装。装完后引用里会看到 Aspose.Words 程序集,目标框架选 net48 就行。这套 Demo 源码里的引用路径如果指向的是本地 DLL 而不是 NuGet 包,就把 DLL 放到项目 libs 目录,再手动添加引用,效果一样。

License 初始化是拆这个包时最容易忽略的一步。Demo 里如果带了.lic文件,要在程序入口、任何Document创建之前调用:

// 必须放在 Main 方法最前面,或者静态构造函数里 var license = new Aspose.Words.License(); license.SetLicense("Aspose.Words.lic");

SetLicense的参数可以是相对路径,比如 exe 同目录下的Aspose.Words.lic。注意这个文件要右键属性里设成“复制到输出目录”。很多人第一次跑直接抛FileNotFoundException,还以为是包没装好,其实只是 lic 文件没被复制到 bin 目录。后面的IsLicensed属性可以顺手验证一下:

if (!license.IsLicensed) { throw new Exception("License 未生效,请检查 lic 文件路径"); }

评估模式下生成的文档带水印、大文档还被截断,这个坑我放到第 5 章专门讲。这里记住一条:写代码前先把 License 初始化框架搭好,别等生成了一堆水印文档再回头找原因。

2.3 两套模板方案:{{}} 纯文本占位符 vs MERGEFIELD 域

打开 Demo 仔细看会发现,模板数据填充实际上是两条路:Range.Replace处理{{xxx}}文本占位符,MailMerge处理 MERGEFIELD 域。占位符对普通用户友好,模板里直接打字就行;MERGEFIELD 是 Word 原生域,按 Alt+F9 能看到《Name》这种形式,适合结构化批量数据。

维度纯文本占位符 {{Name}}MERGEFIELD 域
模板制作门槛Word 里直接打字插入→文档部件→域,步骤多
代码入口doc.Range.Replacedoc.MailMerge.Execute
模板可见性打开就能看到占位符默认显示域结果,要开域代码
误删风险低高,按 Delete 可能删坏域
表格循环不擅长可以配合书签做,但维护难

我个人的习惯是:单个字段用{{}},因为打开模板能直接检查有没有打错;重复行的表格用书签复制行,不用 MERGEFIELD——业务人员维护 Word 域真的会改坏,而且坏了还说不清怎么坏的。这个 Demo 里也是混合方案,后面的核心代码按这个思路给,你拿到手直接照抄就行。

3. 模板设计:占位符命名、书签包行和内容控件的坑

3.1 先做一份“干净”的模板:占位符命名规范

从 Demo 里看,模板设计花的时间比写代码多。一份能稳定跑的模板,制作步骤是四步:第一步,复制正式文档另存为template.docx,不要在原文件上改;第二步,把需要变动的文字删掉,打上{{字段名}};第三步,表格里需要循环的那一行,行首和行尾插入书签;第四步,全选模板,检查没有残留的硬编码变量。

占位符命名是大事,直接决定代码里能不能一次对上。我见过最乱的项目,模板里{{客户名称}}、{{customer name}}、{{CustomerName}}三种写法混着来,代码里得写三个 Replace 兜底。规范只有三条:驼峰命名、不要空格、不要中文括号。做成表就是:

字段含义占位符写法备注
客户名称{{CustomerName}}驼峰,不要写成{{客户名称}}
合同金额{{Amount}}数据源统一传字符串
签署日期{{SignDate}}格式化后再传,比如 2025-06-18

占位符和 Word 域是两回事。如果模板里按 Ctrl+F9 插的是MERGEFIELD,Range.Replace按{{xxx}}匹配是替换不到的,除非把FindReplaceOptions.IgnoreFields设成 false 去改域代码,但那样会破坏域结构。所以做模板前先明确:要么全用纯文本占位符,要么全用域,混着用后面排查起来很痛苦。

3.2 表格重复行:书签必须包住整行,含段落标记

表格里要循环的部分,比如订单明细,模板里只留一行,用书签把它包住。Word 里操作是:选中这一整行,包括行末的段落标记,然后菜单栏“插入→书签”,起个名比如订单明细。书签名的规则和变量名一样,不要有空格,不要数字开头。

很多人在这里翻车:只选中了行里的单元格文字,没选段落标记,结果书签只包住文字没包住行。代码里用GetAncestor(NodeType.Row)定位表格行时,拿到的可能是 null。怎么确定书签包对了?Word 里“文件→选项→高级”,把“显示书签”勾上,书签位置会显示灰色方括号,方括号必须包含整行的结尾段落标记才算包住。

程序侧也有一个验证姿势,第 4 章会写完整代码,这里先记住判断逻辑:bookmark.BookmarkStart.GetAncestor(NodeType.Row)能返回Row对象,说明书签在行内;返回 null,就是没包住。模板是给别人做的,就先把这个校验写在程序开头,模板不对直接报错,不要等到生成完才发现。

3.3 模板里为什么不用内容控件(SDT)

现代 Word 的“开发者工具”里有内容控件,就是那种灰色框的占位符。如果模板用内容控件做的字段,Aspose.Words 不是不能处理,但过程绕:要遍历StructuredDocumentTag,取它的Range再替换,比{{}}麻烦得多,而且生成结果后面容易残留空行。

我拆过几个客户给的模板,全是内容控件做的,打开看是灰色框,Alt+F9 还看不出门道。后来统一做法:先检查模板里有没有 SDT,有就让 Word 把它“转换为纯文本”,再存成 template.docx。这条一定要写在模板验收清单里,别等代码写完了再返工。

4. 核心代码链路:加载模板、数据填充、表格循环和 PDF 导出

4.1 数据字典与模板加载

先把数据准备好。Demo 里用的是Dictionary<string, string>,key 和模板占位符完全一致,value 全部转成字符串。这样做的好处是代码简单,坏处是数字格式化你得自己控制。

// 数据字典:key 必须和模板占位符完全一致 var data = new Dictionary<string, string> { { "CustomerName", "某某科技有限公司" }, { "Amount", "128000.00" }, { "SignDate", DateTime.Now.ToString("yyyy-MM-dd") } }; // 加载模板文件,路径可以是绝对路径或相对 exe 的路径 var doc = new Document("template.docx");

Document构造接收模板路径后,整个文档树就进内存了。这里两个注意点:模板文件不要被 Word 进程占用,否则会抛文件被占用的异常;Document对象用完后要 Dispose,尤其是批量生成场景,后面第 5 章单独讲。加载完模板后,先做一次书签校验,模板不对立刻报错,避免脏数据生成一堆废文件。

4.2 全文档全局替换:Range.Replace 的匹配参数

单个字段的填充用的是Range.Replace,它不是单纯的字符串替换,而是带查找选项的文档范围替换。参数决定匹配行为,默认值在某些情况下会坑你。

var options = new FindReplaceOptions { MatchCase = false, // 忽略大小写,防止模板里大小写不一致 FindWholeWordsOnly = false // 只要字符串吻合就替换,不要求整词 }; foreach (var kv in data) { doc.Range.Replace(kv.Key, kv.Value, options); }

MatchCase = false应对的是模板里{{customername}}和代码里{{CustomerName}}不一致的情况,这个开关能兜底。FindWholeWordsOnly = false让替换不要求整词匹配,因为占位符两侧就是括号,不涉及词边界。循环里逐条替换,顺序无所谓,占位符之间不重叠。

提示:不要用doc.ToString()拿到全文再用string.Replace,然后new Document()重新加载。这样做的结果就是样式全丢,表格线、字体、页边距全部回默认,基本等于重新排版。

模板里如果有字段漏配,替换不会报错,模板上会残留{{xxx}}字样。肉眼检查很难发现,尤其文档几十页时。这个问题我在第 6 章给了一个自动校验方案,先往下看。

4.3 表格循环行:书签定位、克隆、局部替换、删模板行

这是整个 Demo 里含金量最高的一段,也是当初外包团队卡住的地方。思路是:用书签定位模板里的那一行,克隆它,逐行插入,最后删掉模板原始行。

// 1. 定位书签,并找到书签所在的表格行 var bm = doc.Range.Bookmarks["订单明细"]; if (bm == null) throw new Exception("模板缺少书签: 订单明细"); var templateRow = bm.BookmarkStart.GetAncestor(NodeType.Row) as Row; var table = templateRow.ParentTable; // 2. 循环克隆模板行,逐行填充数据 Row lastRow = templateRow; for (int i = 0; i < orderLines.Count; i++) { // DeepClone(true) 深拷贝行内所有段落、字体、列宽 var newRow = (Row)templateRow.DeepClone(true); // 插入到 lastRow 后面,保持订单顺序 table.Rows.InsertAfter(newRow, lastRow); lastRow = newRow; // 3. 在克隆行范围内做局部替换,复用前面的 FindReplaceOptions newRow.Range.Replace("{{OrderName}}", orderLines[i].ProductName, options); newRow.Range.Replace("{{OrderQty}}", orderLines[i].Quantity.ToString(), options); newRow.Range.Replace("{{OrderPrice}}", orderLines[i].Price.ToString("F2"), options); } // 4. 删除模板原始行 table.Rows.Remove(templateRow); // 5. 清理残留书签(克隆行会复制书签,导致同名书签有多个) var dupBookmarks = doc.Range.Bookmarks .Cast<Bookmark>() .Where(b => b.Name == "订单明细") .ToList(); foreach (var b in dupBookmarks) b.Remove();

DeepClone(true)是深拷贝,行内的段落、字体、单元格宽度全部复制,这是保留格式的关键。InsertAfter的第二个参数是参照行,这里必须用lastRow,不能用templateRow——如果用固定参照行,每轮都插在模板行后面,生成顺序是反的或者乱序,这个我踩过。局部Range.Replace只作用于克隆行,不会误伤全文档其他内容,比手动往单元格塞Run对象干净,模板样式自然保留。

最后删掉模板行之后,要清理克隆过程中产生的大量同名书签。Word 允许同名书签存在,但第 6 章的字段回归会基于书签查漏,书签重名会干扰校验,所以这里统一清理掉。这一步看起来是小事,实际漏掉会让后面的自动化检查完全失效。

4.4 导出:docx 和 PDF 一起存

生成完文档后,一般要同时导出 docx 和 PDF。docx 是给内部再编辑用的,PDF 是发给客户定版的。

// 确保输出目录存在 Directory.CreateDirectory("output"); // 先存 docx 可编辑版本,再存 PDF 定版版本 doc.Save("output/合同_客户A.docx", SaveFormat.Docx); doc.Save("output/合同_客户A.pdf", SaveFormat.Pdf);

SaveFormat枚举控制输出格式,常用的是Docx、Pdf,想要纯文本就Txt,想转网页就Html。如果只想把第一页转成图片预览,SaveFormat.Png配合ImageSaveOptions的PageIndex参数可以做到。这个 Demo 里默认存两种格式就够用了。文件路径注意目录要提前创建,Save方法不会自动建目录。

到这里,一条完整的链路就走通了:模板设计 → 数据字典 → 全局替换 → 表格循环 → 导出。第 5 章讲的是这条链路跑起来之后,真实环境下最常见的几个事故现场。

5. 避坑排查:水印、字体、列宽和并发生成的 5 个真实故障

5.1 生成的文档带水印,大文档被截断

现象:输出文档页眉位置出现英文水印文字,内容多的文档生成到一半就没了,后面段落缺失。

原因:License 没设置成功,程序跑在评估模式下。评估模式限制单文档的段落数,超过限制的文档不会报异常,而是直接截断。lic 文件路径写错、没有复制到输出目录,都会造成SetLicense静默失败,很多人的代码里又没检查IsLicensed,所以一直没发现。

解决:把 License 初始化放到程序入口最前面,并用IsLicensed属性兜底拦截。lic 文件右键属性设置为“复制到输出目录”,确认 bin 目录下真实存在该文件。如果 Demo 包里没带 lic 文件,就先去官网申请试用版,别直接把评估模式的输出交给业务。

5.2 书签替换失效:锚点没包住内容

现象:往Bookmarks["订单明细"]里定位并复制行,结果输出文档里该位置还是模板原文,或者GetAncestor(NodeType.Row)返回 null 直接抛空引用。

原因:Word 书签本质是两个锚点,能替换的前提是内容落在BookmarkStart和BookmarkEnd之间。很多人插入书签时只选中了单元格文字,没选段落标记,书签范围不包含整行,代码里自然定位不到行。

解决:模板里重新插入书签,必须选中整行并包含行末段落标记。程序侧在启动时做校验,定位不到Row就抛异常,让模板问题在生成前暴露。另外,删除模板行后如果残留空段落,Word 里看就是“最后一页死活删不掉”,处理手法是删完行顺手检查表格后面的段落,空的直接删掉。

5.3 表格列宽错乱,Word 里怎么拖都拖不动

现象:生成出来的文档表格比模板宽,列宽不一致,用户在 Word 里手动拖列宽拖不动,一拖整列乱跳。

原因:克隆行保留了模板行的列宽设置,但和表格属性里的 AutoFit 策略冲突。模板表格如果设置了“自动调整内容”,生成后表格会根据内容重新布局;如果又是“固定列宽”,两个策略打架,最终列宽不可控。

解决:生成完成后强制指定表格布局策略,代码里加一行:

table.AutoFit(AutoFitBehavior.FixedColumnWidths);

或者手动统一每一列宽度。注意 Aspose.Words 里列宽单位是 point,和 Word 里的厘米不同,换算关系是 1 厘米约等于 28.35 pt。下表是常用宽度参考:

目标宽度pt 值
2 厘米56.7
3 厘米85.05
4 厘米113.4

设置完AutoFit后再导出,列宽基本和模板一致。

5.4 服务器上中文字体全变:本地正常,部署就翻车

现象:本地开发生成文档字体正常,部署到服务器后中文全部变成默认宋体,或者干脆显示成方块。

原因:服务器没装模板里引用的中文字体,Aspose.Words 字体回退到系统默认字体。Windows Server 精简版缺字体很常见,Linux 容器里更严重——容器里往往一个中文字体都没有。

解决:如果模板里字体名写成“微软雅黑”,目标机器必须能解析到这个字体。代码里可以显式指定字体目录:

var fontSettings = new FontSettings(); fontSettings.SetFontsFolder("/usr/share/fonts/chinese", false); doc.FontSettings = fontSettings;

Windows 服务器装一下微软雅黑字体包即可。Linux 部署要把中文字体文件拷进容器,比如/usr/share/fonts/chinese目录,再挂载到镜像里。这个坑最隐蔽的地方在于:本地开发永远测不出来,一定要在部署环境里加一条字体自检流程。

5.5 多线程并发生成:内存暴涨加偶发异常

现象:用Parallel.ForEach批量生成 100 份合同,进程内存涨到几个 G,偶发ObjectDisposedException或内存不足崩溃。

原因:Document对象不是线程安全的,多个线程共享同一个Document实例必然出问题;另一个原因是每份文档生成完没有 Dispose,内存只涨不回收。

解决:每个任务内独立new Document,模板路径共享没问题,但对象实例要各自持有。License 只在进程启动时初始化一次,不要在循环里重复SetLicense。生成完的doc用using包裹释放:

var tasks = orders.Select(order => Task.Run(() => { using var doc = new Document("template.docx"); FillDocument(doc, order); doc.Save($"output/合同_{order.Id}.docx", SaveFormat.Docx); })); await Task.WhenAll(tasks);

这样内存峰值可控,也不会出现线程间的交叉污染。

6. 用数据字典做全字段回归:一遍跑完所有占位符的验证技巧

6.1 全字段回归:把模板占位符兜出来对账

交付前最怕的是模板里某个{{字段名}}没被替换,生成完文档带着占位符发出去。肉眼翻页找太慢,我的做法是在生成前做一次全字段回归:把模板里所有{{}}占位符提取出来,和数据字典比对,差集就是漏配的字段。

// 从模板文件提取所有 {{占位符}},注意 UseRawExtension 保留原文 var templateText = File.ReadAllText("template.docx"); var regex = new Regex(@"\{\{([^}]+)\}\}"); var templateKeys = regex.Matches(templateText) .Select(m => m.Groups[1].Value.Trim()) .Distinct() .ToList(); // 和数据字典比对,找出模板里有但数据里没配的字段 var dataKeys = data.Keys.ToList(); var missingKeys = templateKeys.Except(dataKeys).ToList(); if (missingKeys.Any()) { throw new Exception($"模板字段未配置: {string.Join(", ", missingKeys)}"); }

这个校验放在填充数据之前执行,把错误拦截在生成前。输出文档生成后再做一次反向校验:打开输出 docx,如果还能匹配到{{}},说明替换有遗漏,直接标记失败。从那次之后,我每次交付前都强制走一遍全字段回归和残留检查,再也不用手工开文档翻找,省下的时间够干别的事了。

6.2 输出文档的四项自检清单

校验代码跑完,再补四项自检:第一,确认输出目录每个文件大小都大于 0;第二,抽查一份 docx 打开,看页眉页脚是否正常;第三,确认 PDF 页数和模板页数一致,防止内容被截断;第四,检查文档属性里没有评估模式的痕迹。全部通过,才算一份合格的交付物。

这套流程走下来,Aspose.Words 生成文档这件事基本就闭环了。从那以后我每次做模板类功能,都会强制走一遍字段回归、书签定位校验、导出格式检查三步,模板改过一次就重复跑一遍,再没出过带占位符发货的事故。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 6:34:22

构建Agent技能库:解决复用难题与编排复杂度的实战指南

1. 项目概述1.1 为什么你需要一个Agent技能库做Agent应用开发的朋友应该都有过这种经历&#xff1a;项目里的Agent越来越多&#xff0c;每个Agent都需要调用工具、处理文本、做检索&#xff0c;但代码越写越乱&#xff0c;功能越来越难复用。有的Agent里写了一段爬虫逻辑&#…

作者头像 李华
网站建设 2026/9/25 6:33:41

FastReport 2023.3在Delphi 12.3下安装与排坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 6:32:47

Backup Exec 2010许可激活与重装排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 6:32:34

计算机二级Python真题倒推:高频考点与满分代码实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 6:31:00

SUSCTF 2018线上赛实战复盘:从Web注入到Misc隐写的CTF解题全记录

凌晨两点的房间里&#xff0c;我盯着终端上滚动的报错信息&#xff0c;整个人处于一种既兴奋又抓狂的状态。SUSCTF 2018线上赛已经进行了二十个小时&#xff0c;作为一支临时凑起来的三人小队&#xff0c;我们手头还有四道题没解出来&#xff0c;而最让我在意的&#xff0c;是那…

作者头像 李华
网站建设 2026/9/25 6:30:48

计算机组成原理课程设计实战:从MIPS子集到单周期CPU完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华