关于NPOI的两个坑——保存到MemoryStream自动关闭和AddPicture生成的文件打不开的问题

今天踩了两个NPOI的坑,一个比一个气人。

故事背景都是要导出Excel或者Word。

1. 导出Excel,写入Stream被关闭

很普通的导出Excel,于是后端先去数据库里查数据,然后用NPOI生成Excel表格,把生成的结果传给前端,触发浏览器下载保存到本地。

按照这个思路,我们很快写出了代码,然而却发现导出存在问题!出问题的代码是这样的:

1
2
3
using var ms = new MemoryStream();
workbook.Write(ms);
ms.Seek(0, SeekOrigin.Begin); //重置流当前位置,给后面步骤第二次使用

结果运行一下,喜提异常一个:System.ObjectDisposedException: Cannot access a closed Stream.

我就想,我好好的一个Stream,怎么就Closed了呢?

一般我们在C#中使用Stream,原则是谁打开的,就谁负责关闭。这也是为什么我们要写using var ms = new MemoryStream()。由于using关键字的特性,这里打开的Stream会在结束这段代码时自动关闭并清理。

NPOI.XSSF 这个Excel组件可好,他直接自作多情的在Write(ms)的调用里面把Stream关了。如果是写文件,写到这里关了倒无可厚非,反正文件已经写完了。

但是我们这里本地不想保存这个文件,而是希望直接把结果推给浏览器。也就是这个Stream流需要再读一遍的,你直接给关了肯定不行啊。

最后也没办法,就继承了一个MemoryStream,然后重载Close,不让它关掉。

定义了NpoiMemoryStream,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class NpoiMemoryStream : MemoryStream
{
public bool AllowClose { get; set; }
public NpoiMemoryStream()
{
AllowClose = true;
}

public override void Close()
{
if (AllowClose)
{
base.Close();
}
}
}

那么我们就把用法也对应改成:

修改后的代码:

1
2
3
4
5
6
using var ms = new NpoiMemoryStream();
ms.AllowClose = false; //先禁止Close
workbook.Write(ms);
ms.Flush();
ms.Seek(0, SeekOrigin.Begin); //重置流当前位置,给后面步骤第二次使用
ms.AllowClose = true; //重新允许Close,让这段执行结束后这个MemoryStream可以被正常关闭回收

2. 往Word文档里添加图片,导出后的docx文件Word打不开

这个就更坑了,需求是导出工单为Word文档。那么在工单界面上传的图片需要用插图的形式插在Word文档里面。

那么我们正常写代码,就成了下面这样:

有问题的代码:

1
2
3
4
using var imgStream = await efs.DownloadImage(imageUrl);
var imgRun = para.CreateRun(); //para 是 XWPFParagraph的对象,当前段落。
imgRun.AddPicture(imgStream, (int)PictureType.JPEG, imageFileName, Units.ToEMU(imageWidth), Units.ToEMU(imageHeight));
imgRun.AddCarriageReturn();

写好了,运行,没报错,docx生成了。那么我们用Word打开一下:

噔噔咚!

1
2
3
4
5
Word 在试图打开文件时遇到错误。
请尝试下列方法:
* 检查文档或驱动器的文件权限。
* 确保有足够的内存和磁盘空间。
* 用文本恢复转换器打开文件。

这是为什么呢?我尝试了很多可能,修改设定的长宽啊,不从网络下载图片而是先给个本地图片试试啊,给图片一个单独的Run啊,尝试把图片不再内联到文档里,而是插入成“四周型”啊,都没用。最后我确信了自己的代码没有问题,而问题出在NPOI.XWPF里面。

然后我去查看了生成的Word文档内部结构。发现了Bug的所在:

大家知道docx文件其实是一组xml的zip包。我们把生成的docx后缀名改成zip,解压,用VSCode打开,看看到底给我们生成了什么xml出来。

然后发现这个图片生成的XML是这样的:

1
<wp:docPr name="Drawing 0" descr="微信-01.png.jpg"></wp:docPr>

而我正常在Word里拖进去一张图片,生成的XML是这样的:

1
<wp:docPr id="237795" name="Drawing 0" descr="微信-01.png.jpg"></wp:docPr>

NPOI,你id呢?

只能用NPOI给出的内部工具手工解决一下:

修改后的代码:

1
2
3
4
5
6
using var imgStream = await efs.DownloadImage(imageUrl);
var imgRun = para.CreateRun(); //para 是 XWPFParagraph的对象,当前段落。
imgRun.AddPicture(imgStream, (int)PictureType.JPEG, imageFileName, Units.ToEMU(imageWidth), Units.ToEMU(imageHeight));
var docPr = ((NPOI.OpenXmlFormats.Dml.WordProcessing.CT_Drawing)imgRun.GetCTR().Items[0]).inline[0].docPr;
docPr.id = (uint)(new Random()).Next(1000, 1000000);
imgRun.AddCarriageReturn();

然后再尝试运行,生成的Word终于正常打开了。

以上两个问题感觉已经在NPOI中存在好多年了,然而并没有人修复过。目前也不知道java版的祖宗Apache POI是不是有同样的问题。但是碰到了的话确实很坑。