news 2026/10/4 8:30:50

Freemarker+POI导出带图片Excel的实战方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Freemarker+POI导出带图片Excel的实战方案

1. 项目概述:为什么一张图片让Excel导出变得“不简单”

Freemarker整合POI导出带图片的Excel,听起来只是“模板渲染+文件生成”的常规组合,但实际落地时,90%的开发者会在第三步卡住——不是数据没填上,而是图片死活不显示,或者Excel打开直接报错“文件已损坏”。我去年帮三个业务线重构报表模块,全栽在这张图上:财务要导出带公章扫描件的对账单,HR要生成含员工证件照的花名册,供应链得在入库单里嵌入商品实拍图。表面需求一致,底层技术逻辑却完全不同。核心矛盾在于:Freemarker是纯文本模板引擎,它只认字符串;而POI的XSSF(.xlsx)操作的是二进制流、OLE复合文档结构和XML节点树;图片既不是纯文本,也不是普通单元格值,它必须作为独立对象嵌入到工作簿的“媒体资源”目录下,并通过关系ID(Relationship ID)与特定单元格绑定。这中间没有现成的“图片变量”,只有手动构造Drawing、PictureData、ClientAnchor三者联动的硬编码逻辑。更麻烦的是,网络热词里反复出现的“apache poi <= 4.1.0 xssfexporttoxml xxe漏洞”,恰恰说明早期版本用XML解析处理图片路径时存在安全风险,现在必须绕过XML注入路径,改用POI原生的Workbook.addPicture()+Drawing.createPicture()双阶段注入。所以这不是一个“配置一下就能跑”的教程,而是一次对Excel文件物理结构的实地勘探——你要亲手把图片塞进.xlsx这个ZIP包里的xl/media/子目录,再告诉Excel:“这张图属于第3行第5列”。接下来所有步骤,都围绕这个物理事实展开。

2. 技术选型与架构设计:为什么必须放弃“模板里写img标签”这种幻想

2.1 Freemarker与POI的天然错位:两个世界的协议不兼容

很多人第一反应是:“Freemarker模板里写个<#assign picPath='logo.png'>,然后POI读取路径去加载?”这是典型误区。Freemarker渲染阶段(.ftl文件解析)和POI写入阶段(XSSFWorkbook对象构建)是完全分离的两个生命周期。Freemarker输出的是纯字符串(比如"姓名: 张三\n部门: 技术部"),它根本不知道“图片”是什么概念——它连base64字符串都当普通文本处理,更不会帮你调用workbook.addPicture()。你如果在模板里硬塞<img src="data:image/png;base64,xxx">,最终生成的Excel里只会显示一长串乱码文字,因为POI不会解析HTML标签。我试过用正则匹配模板里的base64片段再提取,结果发现:base64字符串跨多行时换行符处理混乱,不同浏览器生成的base64头部(data:image/png;base64,)格式不统一,且POI对超长base64解码失败率高达37%(实测1000次有372次抛IllegalArgumentException: Illegal base64 character)。所以必须斩断“模板直接写图”的念头,采用“数据预处理+模板占位+POI后置注入”的三段式流程。

2.2 POI版本选择:4.1.2是安全与功能的黄金分割点

网络热词里反复刷屏的“apache poi <= 4.1.0 xssfexporttoxml xxe漏洞”,根源在于旧版POI用DocumentBuilder解析用户传入的XML片段时未禁用外部实体。而图片插入恰恰涉及XML操作——每个图片在xl/drawings/drawing1.xml里都有对应<xdr:pic>节点。4.1.2版本起,POI默认关闭XXE,且修复了XSSFPicture在合并单元格区域定位偏移的bug(这个bug导致图片总往左上角堆叠)。更重要的是,4.1.2新增XSSFPicture.resize()方法,能自动按单元格尺寸缩放图片,避免手动计算像素导致的变形。我们对比过4.0.1、4.1.0、4.1.2三个版本:

  • 4.0.1:picture.resize()无效,需手动算宽高比,代码行数增加40%;
  • 4.1.0:虽修复resize,但ClientAnchor.setAnchor()对合并单元格支持不稳定,10次中有3次图片位置漂移;
  • 4.1.2:resize稳定,anchor定位精准,且Workbook.getBytes()返回的字节数组不再包含临时文件句柄,避免Tomcat环境下导出大文件时IOException: Stream closed。
    所以直接锁定org.apache.poi:poi-ooxml:4.1.2,Maven依赖如下:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>4.1.2</version> </dependency>

提示:不要用poi-ooxml-full,它会引入冗余的xmlbeans和commons-collections4,与Spring Boot 2.3+的spring-boot-starter-web冲突,导致启动时报NoSuchMethodError: org.apache.xmlbeans.XmlOptions.setSaveSyntheticDocumentElement(Z)Lorg/apache/xmlbeans/XmlOptions;。

2.3 图片资源管理策略:绝对路径、Classpath还是Base64?

三种方案实测对比:

方案加载方式优点缺点适用场景
绝对路径new FileInputStream("/opt/images/logo.png")加载快,内存占用低部署环境路径不一致,Docker容器内路径映射复杂企业内网固定服务器,运维可统一维护图片目录
Classpaththis.getClass().getResourceAsStream("/static/images/logo.png")打包进jar,路径稳定图片随代码编译,修改需重新打包,无法热更新小型项目,logo等静态不变图片
Base64预解码模板传入byte[],POI直接workbook.addPicture(bytes, XSSFWorkbook.PICTURE_TYPE_PNG)完全脱离文件系统,微服务间传输方便内存占用翻倍(base64体积比原图大33%),大图易OOMAPI接口导出,前端上传图片后实时生成Excel

我们最终采用混合策略:系统Logo、水印等固定图走Classpath;用户上传的证件照、商品图走Base64预解码。关键技巧是——Base64字符串必须在Controller层就解码为byte[],绝不能传给Freemarker模板。因为Freemarker的?decode("base64")函数在Java 8+环境下会因字符集问题解码失败(实测UTF-8和ISO-8859-1混用时,100次有12次解码后字节数组长度错误)。正确做法是在Service层用java.util.Base64.getDecoder().decode(base64Str),并捕获IllegalArgumentException做兜底。

3. 核心实现:从模板占位到图片注入的完整链路

3.1 Freemarker模板设计:用“占位符”代替“图片标签”

Freemarker模板里绝不出现任何图片相关语法,只用纯文本占位符标记图片位置。例如导出员工花名册的staff.ftl:

<#-- 员工基本信息 --> | 姓名 | 部门 | 职位 | 入职日期 | 证件照 | |------|------|------|----------|--------| <#list staffList as staff> | ${staff.name} | ${staff.dept} | ${staff.position} | ${staff.hireDate?date} | [PHOTO:${staff.photoId}] | </#list>

关键点:[PHOTO:${staff.photoId}]是唯一标识,不是HTML,不是路径,只是一个带前缀的字符串。photoId可以是数据库主键、UUID或文件名哈希值,目的是让后续POI处理时能精准匹配到对应图片数据。这样设计的好处是:

  • 模板可被其他导出方式复用(比如PDF导出时,把[PHOTO:xxx]替换成文字“见附件”);
  • 占位符格式统一,正则提取稳定(Pattern.compile("\\[PHOTO:([^\\]]+)\\]"));
  • 避免Freemarker对特殊字符(如/、.)的转义干扰,比如[PHOTO:avatar_123.png]中的点号不会触发FTL语法解析。

注意:占位符必须用方括号[]包裹,且内部不含空格。曾有同事用{PHOTO:xxx},结果Freemarker误认为是自定义指令,报freemarker.core.ParseException: Expected directive name。

3.2 数据预处理:构建“图片上下文”Map

Controller层接收请求后,先调用Service获取业务数据,再同步加载图片资源,构建成Map<String, byte[]>供POI使用。以Spring Boot为例:

@GetMapping("/export/staff") public void exportStaff(HttpServletResponse response) throws IOException { // 1. 获取员工列表(含photoId) List<Staff> staffList = staffService.listAll(); // 2. 预加载所有图片,避免POI循环中IO阻塞 Map<String, byte[]> photoMap = new HashMap<>(); for (Staff staff : staffList) { if (StringUtils.isNotBlank(staff.getPhotoId())) { byte[] photoBytes = photoService.loadPhotoById(staff.getPhotoId()); if (photoBytes != null && photoBytes.length > 0) { photoMap.put(staff.getPhotoId(), photoBytes); } } } // 3. 渲染模板(此时模板里只有[PHOTO:xxx]占位符) String htmlContent = freemarkerService.render("staff.ftl", Collections.singletonMap("staffList", staffList)); // 4. POI处理:解析占位符 + 注入图片 ByteArrayInputStream bais = new ByteArrayInputStream(htmlContent.getBytes(StandardCharsets.UTF_8)); XSSFWorkbook workbook = poiService.exportWithPhotos(bais, photoMap); // 5. 输出响应 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=staff_list.xlsx"); workbook.write(response.getOutputStream()); }

这里的关键是photoMap——它把photoId(字符串)和byte[](二进制)一一对应,POI后续只需查Map,无需再次IO。实测1000条数据加载100张图,预加载耗时120ms,若在POI循环中逐个loadPhotoById(),耗时飙升至2.3秒(磁盘IO瓶颈)。

3.3 POI图片注入:四步法精准定位与嵌入

poiService.exportWithPhotos()是核心方法,分四步执行:

第一步:解析HTML内容,提取占位符坐标
将Freemarker渲染的字符串按行分割,用正则匹配[PHOTO:xxx],记录其所在行号、列号(按|分割的列索引):

List<PhotoPlaceholder> placeholders = new ArrayList<>(); String[] lines = content.split("\n"); for (int i = 0; i < lines.length; i++) { String line = lines[i].trim(); if (!line.startsWith("|")) continue; // 跳过非表格行 String[] cells = line.split("\\|"); for (int j = 0; j < cells.length; j++) { String cell = cells[j].trim(); Matcher m = PHOTO_PATTERN.matcher(cell); if (m.find()) { placeholders.add(new PhotoPlaceholder(i, j, m.group(1))); } } }

PhotoPlaceholder类封装了行、列、photoId,为后续定位打基础。

第二步:创建空白工作簿,填充文本数据
用Apache POI的SXSSFWorkbook(流式写入,防OOM)创建工作簿,逐行写入文本:

SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 100行缓存 Sheet sheet = workbook.createSheet("员工名单"); for (int i = 0; i < lines.length; i++) { Row row = sheet.createRow(i); String[] cells = lines[i].split("\\|"); for (int j = 0; j < cells.length; j++) { Cell cell = row.createCell(j); String text = cells[j].trim(); // 移除占位符,只留纯文本(如[PHOTO:abc] → "") text = PHOTO_PATTERN.matcher(text).replaceAll(""); cell.setCellValue(text); } }

第三步:为每个占位符注入图片
这才是真正的技术难点。POI要求:

  • 图片必须先通过workbook.addPicture()注册,返回pictureIndex;
  • 然后创建Drawing对象,在指定单元格区域绘制;
  • ClientAnchor必须精确设置col1/col2/row1/row2,否则图片悬浮在左上角;
  • 合并单元格需用sheet.getMergedRegion()获取真实范围,不能直接用占位符行列。
    完整代码:
Drawing<?> drawing = sheet.createDrawingPatriarch(); for (PhotoPlaceholder ph : placeholders) { byte[] photoBytes = photoMap.get(ph.getPhotoId()); if (photoBytes == null) continue; // 1. 注册图片 int pictureIdx = workbook.addPicture(photoBytes, XSSFWorkbook.PICTURE_TYPE_PNG); // 2. 获取目标单元格(处理合并单元格) Cell cell = sheet.getRow(ph.getRow()).getCell(ph.getCol()); CellRangeAddress mergedRegion = getMergedRegion(sheet, ph.getRow(), ph.getCol()); int col1 = mergedRegion == null ? ph.getCol() : mergedRegion.getFirstColumn(); int col2 = mergedRegion == null ? ph.getCol() : mergedRegion.getLastColumn(); int row1 = mergedRegion == null ? ph.getRow() : mergedRegion.getFirstRow(); int row2 = mergedRegion == null ? ph.getRow() : mergedRegion.getLastRow(); // 3. 创建锚点(图片覆盖整个合并区域) ClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, col1, row1, col2 + 1, row2 + 1); // 4. 插入图片 Picture picture = drawing.createPicture(anchor, pictureIdx); picture.resize(); // 自动适配单元格尺寸 }

getMergedRegion()方法需遍历sheet所有合并区域:

private CellRangeAddress getMergedRegion(Sheet sheet, int row, int col) { for (int i = 0; i < sheet.getNumMergedRegions(); i++) { CellRangeAddress region = sheet.getMergedRegion(i); if (region.isInRange(row, col)) { return region; } } return null; }

第四步:清理临时文件,释放资源
SXSSFWorkbook会生成临时文件,默认在系统临时目录。若不清理,100次导出产生100个poi-sxssf-sheet*.xml文件,磁盘爆满。必须显式调用:

workbook.dispose(); // 删除临时文件

放在try-finally块中:

try { workbook.write(outputStream); } finally { workbook.dispose(); }

4. 实操避坑指南:那些官方文档不会告诉你的细节

4.1 图片尺寸失真?别怪POI,先查Excel单元格默认高度

POI的picture.resize()看似智能,实则依赖单元格的原始尺寸。Excel默认行高20(约15磅),列宽8.43(约64像素),而PNG图片的DPI通常是96,导致图片被强行压缩变形。解决方案只有两个:

  • 方案A(推荐):提前设置单元格尺寸
    在填充文本后、插入图片前,批量设置目标列的宽度和行高:
    sheet.setColumnWidth(ph.getCol(), 256 * 20); // 20字符宽度(256单位=1字符) sheet.getRow(ph.getRow()).setHeightInPoints(120); // 行高120磅(约160像素)
    这样resize()才能按预期比例缩放。
  • 方案B:用anchor.setDx1()/setDy1()微调像素偏移
    若需精确控制,可计算图片原始宽高(ImageIO.read(new ByteArrayInputStream(photoBytes)).getWidth()),再设置锚点偏移:
    anchor.setDx1((short) (1024 * (targetWidth - imgWidth) / 2)); // 水平居中 anchor.setDy1((short) (256 * (targetHeight - imgHeight) / 2)); // 垂直居中

4.2 导出后Excel打不开?90%是ZIP结构损坏

.xlsx本质是ZIP包,POI写入时若workbook.write()中途异常(如磁盘满、网络中断),生成的文件缺少[Content_Types].xml或xl/workbook.xml,Windows直接报“文件已损坏”。排查步骤:

  1. 将导出的.xlsx文件后缀改为.zip,用7-Zip打开;
  2. 检查根目录是否有[Content_Types].xml;
  3. 检查xl/目录下是否有workbook.xml、worksheets/sheet1.xml;
  4. 检查xl/media/目录下图片文件名是否为image1.png、image2.jpeg(POI自动生成,非原始名)。
    常见原因:
  • response.getOutputStream()被提前关闭(如Filter中拦截了响应);
  • workbook.write()后未调用outputStream.flush();
  • Tomcat的maxSwallowSize默认2MB,大图导出时被截断(需在server.xml中设maxSwallowSize="-1")。

4.3 多线程并发导出图片?必须加锁,但锁粒度要细

若多个用户同时导出带图Excel,workbook.addPicture()是线程安全的,但SXSSFWorkbook的临时文件目录可能冲突。POI默认用System.getProperty("java.io.tmpdir"),高并发下多个线程写同一临时目录会报java.io.IOException: Unable to create temporary file。解决方案:

  • 全局锁(不推荐):synchronized (PoiService.class),吞吐量暴跌;
  • 局部锁(推荐):为每个导出任务创建独立临时目录:
    File tempDir = Files.createTempDirectory("poi-export-").toFile(); SXSSFWorkbook workbook = new SXSSFWorkbook(100); workbook.setCompressTmpFiles(true); workbook.setTempFileDirectory(tempDir); // 指定专属临时目录
    任务结束时FileUtils.deleteDirectory(tempDir)。实测100并发下,临时目录创建耗时均值3ms,远低于全局锁的等待时间。

4.4 Mac版Excel打不开?字体和编码是隐形杀手

Mac版Excel对UTF-8 BOM敏感,且不支持Windows默认字体(如微软雅黑)。导出时需:

  • 移除BOM:Freemarker渲染时用content.getBytes(StandardCharsets.UTF_8),而非content.getBytes()(后者可能用平台默认编码);
  • 设置字体:为所有单元格应用Font:
    Font font = workbook.createFont(); font.setFontName("Arial"); // Mac通用字体 font.setFontHeightInPoints((short) 10); CellStyle style = workbook.createCellStyle(); style.setFont(font); for (Row row : sheet) { for (Cell cell : row) { cell.setCellStyle(style); } }
  • 禁用富文本:cell.setCellValue("text")即可,勿用RichTextString,Mac Excel解析富文本XML易出错。

5. 性能优化与扩展:从单图到千图的实战经验

5.1 千张图片导出:内存从512MB压到128MB

导出含1000张图片的Excel(如商品图册),默认配置下JVM内存溢出。优化手段:

  • 启用SXSSFWorkbook流式写入:new SXSSFWorkbook(100),只缓存100行在内存,其余刷盘;
  • 图片分批加载:将photoMap拆成每100张一批,workbook.addPicture()后立即System.gc()提示回收;
  • 禁用自动公式计算:workbook.setForceFormulaRecalculation(false);
  • 压缩图片:前端上传时用Canvas压缩(质量0.7),后端再用Thumbnailator二次压缩:
    ByteArrayOutputStream out = new ByteArrayOutputStream(); Thumbnails.of(new ByteArrayInputStream(photoBytes)) .size(400, 300) // 限制最大尺寸 .outputQuality(0.7) .toOutputStream(out); byte[] compressed = out.toByteArray();

5.2 动态水印:在每张图片上叠加文字

业务需要在导出的证件照上加“仅供HR使用”水印。不能用CSS,必须在图片二进制层操作:

BufferedImage original = ImageIO.read(new ByteArrayInputStream(photoBytes)); Graphics2D g = original.createGraphics(); g.setColor(new Color(200, 200, 200, 100)); // 半透明灰色 g.setFont(new Font("Arial", Font.BOLD, 24)); g.rotate(-Math.PI / 6, original.getWidth() / 2, original.getHeight() / 2); // 30度倾斜 g.drawString("仅供HR使用", 50, 50); g.dispose(); // 转回byte[] ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(original, "png", baos); byte[] watermarked = baos.toByteArray();

注意:Graphics2D.rotate()的旋转中心必须是图片中心,否则文字偏移。实测1000张图加水印,耗时从3.2秒降至1.8秒(CPU密集型,多线程加速有限,重点优化单图算法)。

5.3 与Spring Boot深度集成:自动配置Starter

将上述逻辑封装成poi-freemarker-starter,简化使用:

@ConfigurationProperties(prefix = "poi.freemarker") public class PoiFreemarkerProperties { private boolean enableWatermark = false; private String watermarkText = "内部使用"; private int maxPhotoSize = 5 * 1024 * 1024; // 5MB } @Bean @ConditionalOnMissingBean public PoiFreemarkerService poiFreemarkerService( FreemarkerConfiguration freemarkerConfiguration, PoiFreemarkerProperties properties) { return new PoiFreemarkerServiceImpl(freemarkerConfiguration, properties); }

使用者只需:

@GetMapping("/export") public void export(HttpServletResponse response) { Map<String, Object> data = new HashMap<>(); data.put("list", dataList); data.put("photos", photoMap); // byte[] map poiFreemarkerService.export("template.ftl", data, response); }

starter已内置水印、压缩、临时目录隔离、OOM防护,开箱即用。

6. 常见问题速查表:从报错信息反推故障点

报错信息根本原因解决方案
java.lang.IllegalArgumentException: Invalid URL: [PHOTO:xxx]Freemarker模板里写了<#include "[PHOTO:xxx]">,被当成URL解析检查模板,确保占位符纯文本,无FTL指令包裹
org.apache.poi.openxml4j.exceptions.InvalidFormatException: Package should contain a content type part.xlsx文件损坏,缺少[Content_Types].xml检查workbook.write()是否完整执行,确认outputStream未被提前关闭
java.lang.OutOfMemoryError: Java heap space图片未压缩,单张超10MB前端上传限5MB,后端用Thumbnailator二次压缩
java.lang.NullPointerException at org.apache.poi.xssf.usermodel.XSSFPicture.resize(XSSFPicture.java:123)picture对象为null,addPicture()失败检查photoBytes是否为空,确认pictureIdx>= 0
Excel无法粘贴数据(导出后)单元格被设置为“锁定”且工作表保护开启sheet.protectSheet("")后未解锁,或CellStyle.setLocked(false)未设置
图片显示为红叉图片格式不被Excel支持(如WebP)后端强制转PNG:ImageIO.write(bufferedImage, "png", outputStream)
Mac版Excel打开空白字体不兼容或BOM头设置font.setFontName("Arial"),渲染时用StandardCharsets.UTF_8
导出文件名乱码(中文)Content-Disposition未编码URLEncoder.encode("员工名单.xlsx", "UTF-8").replace("+", "%20")

最后分享个小技巧:调试图片注入时,别急着打开Excel,先用unzip -l exported.xlsx看xl/media/目录下是否有image1.png,再用xxd xl/media/image1.png | head -n 5确认文件头是89 50 4e 47(PNG魔数)。这比反复重启Tomcat高效十倍。我踩过最深的坑是——某次测试用的图片是CMYK色彩模式,POI解析失败却不报错,静默生成空白图片,折腾了3小时才用identify -verbose image.png发现色彩空间问题。所以,永远相信二进制,别信眼睛。

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

从零开始做AI工程:评论情感分析与要点抽取系统

“从零开始做 AI 工程”这几年一直是个容易被低估、也容易被误解的方向。很多人以为“从零”是让一个完全没写过代码的人从 Python 语法开始学&#xff0c;也有人以为“从零”是把 PyTorch 里每个算子都手写一遍。我比较认同的“from scratch”&#xff0c;指的是不带黑盒心态地…

作者头像 李华
网站建设 2026/10/4 8:28:56

OpenShell全攻略:让Windows 11也能拥有经典开始菜单

OpenShell这个开源项目&#xff0c;很多人第一眼看到会以为是某个命令行工具&#xff0c;其实它是Windows老用户的“后悔药”。如果你还在怀念Windows 7时代那个干净利落、一切尽在掌握的经典开始菜单&#xff0c;又不想因为外观放弃新系统的底层性能和安全性&#xff0c;那Ope…

作者头像 李华
网站建设 2026/10/4 8:26:51

插件化设计全解:从IAR到MusicFree的插件机制与排查指南

直接了当说&#xff1a;近几个月我这边很多技术群里高频出现一个词——"plugins"。有的是问"IAR plugins是干什么的"&#xff0c;有的是复制一段报错"failed to load plugins web boot: 2 entries did not activate"&#xff0c;还有人在琢磨Mus…

作者头像 李华
网站建设 2026/10/4 8:26:41

C++/Qt飞机大战源码解析:QTimer驱动游戏循环与碰撞检测

简介&#xff1a;基于C与Qt开发的飞机大战小游戏完整工程&#xff0c;面向计算机相关专业在校学生、教师及初级开发者&#xff0c;尤其适合作为课程设计、毕业设计或Qt入门练手项目。工程代码经过测试可正常运行&#xff0c;也支持在此基础上扩展新玩法&#xff0c;帮助读者理解…

作者头像 李华
网站建设 2026/10/4 8:25:59

端侧大模型部署工程师核心技能与实战避坑指南

端侧大模型部署这个方向&#xff0c;最近一年我身边至少有七八个朋友从后端、算法、嵌入式等不同岗位往这边转。有人三个月就拿到了翻倍的offer&#xff0c;也有人投了半年简历连面试都约不上。差距在哪&#xff1f;不是谁更聪明&#xff0c;而是有没有搞清楚这个岗位真正要解决…

作者头像 李华
网站建设 2026/10/4 8:25:31

Chisel时序逻辑从入门到实践:寄存器、状态机与流水线设计

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

作者头像 李华