Apache POI SXSSF 流式输出时富文本加粗失效排查

欢迎转载,原文链接:https://corningsun.github.io/Apache-POI-SXSSF-RichText-Bold/

先说结论:失效的不是整个单元格的 CellStyle,而是单元格内部的富文本字体片段。SXSSFWorkbook 默认使用 inline string,写出时只保留了纯文字,没有把 XSSFRichTextString 中的多个 text run 写入 xlsx。解决办法是让 SXSSF 使用 Shared Strings Table,或者在数据量允许时改用 XSSFWorkbook

先固定现场,不急着改代码

复现项目 test_jdk17_poi 的环境很简单:

  • JDK 17
  • Apache POI 5.5.1
  • Maven 3.9+

为了让读者不依赖本地复现项目,下面给出两个可独立运行的完整示例。两段代码都生成“标题加粗:普通文本”,区别只有 Workbook 的实现。

流式版本:PoiRichTextDemo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
// DIFF 1: Use the streaming workbook implementation.
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFRichTextString;

public final class PoiRichTextDemo {
private PoiRichTextDemo() {
}

public static void main(String[] args) throws IOException {
Path outputPath = Path.of("target", "sxssf-demo.xlsx");
Files.createDirectories(outputPath.getParent());

// DIFF 2: This defaults to the inline-string write path.
try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
Sheet sheet = workbook.createSheet("demo");
Row row = sheet.createRow(0);
Cell cell = row.createCell(0);

String boldText = "标题加粗";
String normalText = ":普通文本";

Font boldFont = workbook.createFont();
boldFont.setBold(true);

XSSFRichTextString richText =
new XSSFRichTextString(boldText + normalText);
richText.applyFont(0, boldText.length(), boldFont);
cell.setCellValue(richText);

try (OutputStream outputStream = Files.newOutputStream(outputPath)) {
workbook.write(outputStream);
}
}

System.out.println("Generated: " + outputPath.toAbsolutePath());
}
}

非流式对照版本:PoiRichTextXssfDemo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFRichTextString;
// DIFF 1: Use the in-memory workbook implementation.
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

public final class PoiRichTextXssfDemo {
private PoiRichTextXssfDemo() {
}

public static void main(String[] args) throws IOException {
Path outputPath = Path.of("target", "xssf-demo.xlsx");
Files.createDirectories(outputPath.getParent());

// DIFF 2: XSSF preserves the rich-text runs.
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("demo");
Row row = sheet.createRow(0);
Cell cell = row.createCell(0);

String boldText = "标题加粗";
String normalText = ":普通文本";

Font boldFont = workbook.createFont();
boldFont.setBold(true);

XSSFRichTextString richText =
new XSSFRichTextString(boldText + normalText);
richText.applyFont(0, boldText.length(), boldFont);
cell.setCellValue(richText);

try (OutputStream outputStream = Files.newOutputStream(outputPath)) {
workbook.write(outputStream);
}
}

System.out.println("Generated: " + outputPath.toAbsolutePath());
}
}

把无关代码折叠后,真正的差异只有下面两处:

1
2
3
4
5
- import org.apache.poi.xssf.streaming.SXSSFWorkbook;
+ import org.apache.poi.xssf.usermodel.XSSFWorkbook;

- try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
+ try (XSSFWorkbook workbook = new XSSFWorkbook()) {

FontXSSFRichTextStringapplyFont 的区间以及 setCellValue 的调用顺序完全相同。这样就建立了一个只改变 Workbook 实现的对照实验。

先看实际效果

分别运行两个程序并用 WPS 打开输出文件,可以直观看到差异:

SXSSFWorkbook 与 XSSFWorkbook 局部加粗效果对比

左侧是 SXSSF 生成的 示例. xlsx,文字内容完整,但 “标题加粗” 的局部粗体没有保留;右侧是 XSSF 生成的 XSSF - 示例. xlsx,只有 “标题加粗” 部分为粗体,“:普通文本”保持普通字重。

这个对照很重要。它说明 setBold(true)applyFont 的区间和文字内容大概率都没有问题,变量已经缩小到 Workbook 实现及其序列化过程。

按优先级列出假设

遇到 “内存中设置成功,文件中却失效” 的问题,我通常按下面的顺序排查:

  1. applyFont 的范围是否写错。它使用左闭右开区间 [start, end)
  2. 是否在 cell.setCellValue(richText) 之后又用普通字符串覆盖了单元格。
  3. 字体是否来自另一个 Workbook,或者字体对象是否被后续修改。
  4. SXSSF 的行是否已经刷入临时文件,导致再修改单元格无效。
  5. SXSSF 写 xlsx 时是否丢弃了富文本 run。

本项目只有一行数据,SXSSF 默认窗口可以容纳它;富文本也在写文件前设置,因此 “行过早 flush” 不是本次根因。XSSF 对照程序使用同样的字体和下标又能正常工作,前面三个假设也基本可以排除。

截图已经确认现象真实存在。接下来才进入文件结构和 POI 源码,解释格式为什么会丢失。

深入 xlsx 文件结构,解释格式为什么丢失

xlsx 本质上是 ZIP 包。解压检查 OOXML 不是为了代替截图判断视觉效果,而是为了确认丢失发生在写文件阶段。先检查 SXSSF 生成文件的工作表 XML:

1
2
unzip -p target/sxssf-demo.xlsx xl/worksheets/sheet1.xml \
| xmllint --format -

关键内容如下:

1
2
3
4
5
<c r="A1" t="inlineStr">
<is>
<t>标题加粗:普通文本</t>
</is>
</c>

这里仅有一个 <t>,没有表示富文本片段的 <r>,也没有字体属性 <rPr>。它解释了截图中 SXSSF 为什么无法呈现局部粗体:格式信息没有进入最终文件。

再看 XSSF 对照文件中的 xl/sharedStrings.xml

1
2
unzip -p target/xssf-demo.xlsx xl/sharedStrings.xml \
| xmllint --format -
1
2
3
4
5
6
7
8
9
10
11
12
13
<si>
<r>
<rPr>
<b val="true"/>
<sz val="11.0"/>
<rFont val="Calibri"/>
</rPr>
<t>标题加粗</t>
</r>
<r>
<t>:普通文本</t>
</r>
</si>

粗体片段和普通片段都被写成了独立的 <r>。到这里,问题已经从 “为什么 Excel 不加粗” 变成了“为什么 SXSSF 只写纯文字”。

顺着 Apache POI 源码定位根因

SXSSFWorkbook 的说明中写明:它默认使用 inline strings,而不是 Shared Strings Table。这样更节省内存,但兼容性和功能表现需要调用方权衡。参见 SXSSFWorkbook 源码说明

真正决定本例结果的是 SheetDataWriter 写字符串单元格的分支

1
2
3
4
5
6
7
8
if (_sharedStringSource != null) {
RichTextString rt = cell.getRichStringCellValue();
int sRef = _sharedStringSource.addSharedStringItem(rt);
// Write the shared string index.
} else {
// inlineStr branch.
outputEscapedString(cell.getStringCellValue());
}

两条路径的差异非常直接:

  • 有 Shared Strings Table 时,传入的是完整的 RichTextString,字体片段可以保留。
  • 默认 inline string 路径调用 getStringCellValue(),得到的只是纯文字,富文本 run 信息已经不在返回值里。

所以,本次问题和 JDK 17 没有直接关系,也不是 applyFont 没执行。根因是 new SXSSFWorkbook() 选择了默认的 inline string 写出路径,而这条路径没有序列化单元格内部的富文本格式。

方案一:保留 SXSSF,启用 Shared Strings Table

如果导出数据量较大,仍然需要 SXSSF 的滑动窗口,可以使用四参数构造方法,将最后一个参数设为 true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFRichTextString;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

Path outputPath = Path.of("target", "sxssf-rich-text.xlsx");

// rowAccessWindowSize = 100;
// compressTmpFiles = false;
// useSharedStringsTable = true.
try (SXSSFWorkbook workbook = new SXSSFWorkbook(
new XSSFWorkbook(), 100, false, true)) {
Sheet sheet = workbook.createSheet("demo");
Row row = sheet.createRow(0);
Cell cell = row.createCell(0);

String boldText = "标题加粗";
String normalText = ":普通文本";

Font boldFont = workbook.createFont();
boldFont.setBold(true);

XSSFRichTextString richText =
new XSSFRichTextString(boldText + normalText);
richText.applyFont(0, boldText.length(), boldFont);
cell.setCellValue(richText);

try (OutputStream outputStream = Files.newOutputStream(outputPath)) {
workbook.write(outputStream);
}
}

这仍然是流式写行,但不再是 “所有字符串内容都无需驻留内存” 的模式。Shared Strings Table 会保存工作簿中的唯一字符串;如果数据量很大且大量字符串互不重复,内存占用会明显增加。上线前应使用真实数据做堆内存和耗时测试,不能只用一行示例判断。

字体也应在 Workbook 级别创建并复用,不要在循环中为每个单元格创建一个新字体,否则会增加样式记录并最终触碰 Excel 的格式数量限制。

方案二:数据量可控时使用 XSSFWorkbook

项目中的 PoiRichTextXssfDemo 已经证明 XSSFWorkbook 可以正确写出富文本。如果文件规模不大,优先使用它,代码更直接:

1
2
3
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
// Create richText, apply its font, then set the cell value.
}

代价是所有行、单元格和相关对象都保留在内存中。它适合中小规模、富文本和复杂 Excel 功能优先的场景,不适合不经评估就替换超大数据量导出。

不要把 CellStyle 和 RichTextString 混为一谈

如果整个单元格都要加粗,可以使用 CellStyle

1
2
3
4
5
6
Font boldFont = workbook.createFont();
boldFont.setBold(true);

CellStyle style = workbook.createCellStyle();
style.setFont(boldFont);
cell.setCellStyle(style);

它控制的是整个单元格,无法表达 “标题加粗:普通文本” 这种同一单元格内的局部格式。局部字体必须依赖 RichTextString 的多个 text run。用 CellStyle 绕过本问题,会改变需求,而不是修复需求。

修复后如何验证

不要把 “代码能运行” 当作验收完成。至少做三层验证:

  1. 用目标客户端打开文件并截图,确认 Microsoft Excel、WPS 等实际使用端的显示符合预期。
  2. 重新用 XSSFWorkbook 读取 A1,确认 numFormattingRuns() 大于 0。
  3. 需要继续定位底层原因时,再解压 xlsx,确认 sharedStrings.xml 中存在 <r><rPr><b>

可以增加一个自动化断言:

1
2
3
4
5
6
7
8
9
10
try (XSSFWorkbook workbook = new XSSFWorkbook(outputPath.toFile())) {
XSSFRichTextString value = workbook.getSheetAt(0)
.getRow(0)
.getCell(0)
.getRichStringCellValue();

if (value.numFormattingRuns() == 0) {
throw new AssertionError("Rich text formatting was lost");
}
}

总结这次排查方法

这类问题最容易在 API 调用上反复试错,例如换下标、换字体、重复调用 setBold。更有效的路径是:

  1. 做最小复现,并用 XSSF/SXSSF 建立单变量对照。
  2. 用实际客户端和对比截图确认现象,不用文件结构代替视觉结果。
  3. 再把 xlsx 当成 ZIP 检查 OOXML,定位丢失发生在哪条序列化路径。
  4. 沿着差异追到 POI 的写出分支,确认默认配置背后的行为。
  5. 修复后同时验证语义正确性和内存代价。

最终根因可以压缩成一句话:SXSSF 默认 inline string 写出路径把富文本降成了纯字符串;启用 Shared Strings Table,或改用 XSSFWorkbook,才能保留单元格内的局部粗体。

参考资料