MathType 公式转换开发记录
一
需求是在前端输入框中粘贴 docx 文档内容,读取其中的公式图片转为清晰的图片存储,这里使用的是 MathType 格式公式的文档。
首先需要拿到复制的数据,前端监听剪贴板事件获取 text/rtf 格式的内容可以拿到rtf格式的内容(经测试只有在 Windows 下使用 WPS 打开 docx 格式文档复制其中内容才能拿到 text/rtf 格式数据,在其它情况下获取 text/rtf 数据可能为空):
// 监听获取剪切板数据
doc.addEventListener("paste", function (e) {
if (!(e.clipboardData && e.clipboardData.items)) {
return;
}
const clipboardData = e.clipboardData;
// 获取粘贴板中的 rtf 数据
let rtf = clipboardData.getData('text/rtf');
// 处理数据
extractImageData(rtf);
})
Windows 下的剪切板会保存多种格式的数据,但只有在 rtf 格式的数据中保存了公式图片的 wmf 格式图片,其中还有存储 EquoteMEFTV5 格式公式的源码,但是我们只需要其中的图片数据。
在处理 rtf 数据前我们需要了解一下它的格式,阅读 rtf 标准文档其中描述了图片存储在 rtf 中的标记语法:
Example:
{\*\shppict {\pict \emfblip ..... }}{\nonshppict {\pict ....}}
根据上面的标记格式构建一个正则切出其中的图片数据,然后判断格式并转为 base64 字符串:
// 获取 rtf 格式数据中的图片
const regexPictureHeader = /{\\\*\\shppict\s{\\pict[\s\S]+?({\\\*\\blipuid\s?[\da-fA-F]+)[\s}]*/;
const regexPicture = new RegExp('(?:(' + regexPictureHeader.source + '))([\\da-fA-F\\s]+)\\}', 'g');
const images = rtfData.match(regexPicture);
// 判断格式并输出 base64 格式图片
for (const image of images) {
let imageType = false;
if (image.includes('\\pngblip')) {
imageType = 'image/png';
} else if (image.includes('\\jpegblip')) {
imageType = 'image/jpeg';
} else if (image.includes('\\wmetafile')) {
imageType = 'image/x-wmf';
}
if (imageType) {
let imgHex = `data:`+imageType+`;base64,`+_convertHexToBase64(image.replace(regexPictureHeader, '').replace(/[^\da-fA-F]/g, ''))
}
}
// 16 进制转换为 base64
_convertHexToBase64:function(hexString){
return btoa(hexString.match(/\w{2}/g).map(char => {
return String.fromCharCode(parseInt(char, 16));
}).join(''));
}
通过上面的方式就能拿到 base64 格式的 wmf 图片,wmf 本质是一种矢量图但是已经很少使用了,如果直接输出到 img 标签中是无法显示的,我们需要将它转为一种能被 web 支持的图片格式。由于业务上需要适应网络显示和输出为文档或 pdf,所以最好还是使用矢量图,于是最后选择了将 wmf 转为 svg 的途径来实现。
尝试过以下几种方案来实现转换 wmf 为 svg:
- java 编写的转换程序 wmf2svg 可以正常使用,但是 java 程序无法使用前端引用
- 尝试使用 bytecoder 直接编译 class 文件为 wasm,js 引用失败报异常
- 使用 teavm,但是 gradle 导入问题失败
- CheerpJ,商业软件,服务器在外国,每次刷新页面要下载十几 MB 数据,体验不好
这个程序过于复杂无法只使用 js 简单的实现,所以需要使用 wasm 来嵌入脚本代码实现,参考的就是上面的 wmf2svg 项目。
上面尝试途径全部失败后,只能用笨办法——手工将它的代码翻译为 go 语言。当然选择其他语言也可以,但是需要考虑到对 wasm 的支持以及语言库的完整性等,比如原项目用 java 开发对于 wasm 的支持就很差。最后还是决定用 go,也是对自己 go 能力的一种锻炼吧。
二
翻译的过程基本上就是下苦功了,一句一句的调整。
具体的实现原理可以参考微软官方的 wmf 格式文档。重点需要关注其中的 EXTTEXTOUT 和 TEXTOUT 这两个块的定义,公式图片中绝大多数的数据都会写在其中,而文件中使用的字体则会定义在 CREATEFONTINDIRECT 块中。
在原来 wmf2svg 项目中当编码中文字体的时候会输出错误,主要是由于字符编码设置错误的问题,将其中的中文字体分别使用simplifiedchinese.GBK.NewDecoder()
和traditionalchinese.Big5.NewDecoder()
字符集进行编码就能输出中文了。
翻译项目中遇到的最麻烦的问题应该是字体和 Unicode 字符的显示问题:
- MT Extra 字体在 Firefox 显示不完全(Firefox 的 bug),使用在线加载字体:Firefox 没有实现在加载完远程字体后替换原来字体的功能;使用 svg 文件内置字体的形式也无法显示,真正的原因是字体中的字符 Unicode 码无法在 Firefox 中显示,即使加上字体也因为编码无法显示而显示为空
无法显示的典型字符:(化学等式左下箭头)\u86,(化学等式横杠)\u87,(化学等式右上箭头)\u88
- (不可行)将字体转化为 base64,在图片中内嵌字体文件,似乎是 Firefox 天然不支持上面几个 Unicode 码的显示,无论如何设置字体都不能显示
- (过于复杂无法实现)将无法显示的字体转为路径点形式
- (采用)寻找 Unicode 中的替代字符https://symbl.cc/cn/unicode/table/#arrows,需要根据情况具体修改相关样式:如原本使用Symbol 字体的三个字符拼接成一个长符号,如果直接改为 Unicode 就会组合错位,改为直接使用一个完整字符在 svg 中使用 transfer 变形拉长填充
还有一个问题是使用这种方式转换出来的 svg 图片会较大,由于 svg 本质就是用明文标记的语法而且其中包含了很多位置和格式的信息,所以没有办法简单的进行压缩,网上有已经实用的 svg 文件体积压缩方案,但是由于本项目对于存储体积不敏感,所以暂时不实现这个功能了。
svg 格式的文件不内置字体,如果显示平台的计算机不支持某个字体会可能会导致加载错误,这个问题暂时没有办法解决。测试发现在 Windows 上都能正常显示,但是如果在 MacOS、安卓或者 IOS 上有部分图片会由于缺少字体显示为乱码。
三
在翻译完成并测试完成后,接下来需要将 go 程序转为 wasm 程序嵌入到前端中调用,这需要对程序进行一些改动:
func parseFunc(this js.Value, args []js.Value) interface{} {
// 获取参数
inStr := args[0].String()
var inByte []byte
// 调用你的方法parseReturn
outStr := base64.StdEncoding.EncodeToString([]byte(parseReturn(inByte)))
return js.ValueOf(outStr)
}
func main() {
// 注册函数到js全局
done := make(chan int, 0)
// 这里设置的名称就是之后可以在 js 中调用的函数名
js.Global().Set("Wmf2Svg", js.FuncOf(parseFunc))
<-done
}
用下面的命令编译程序输出 wasm 代码,注意需要同时获取 js 支持文件:
# 使用Go的WASM模块编译生成文件
GOOS=js GOARCH=wasm go build -o static/main.wasm main.go
# 复制js支持文件
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" static
参考 MDN 对 wasm 的介绍,部署 wasm 脚本和 js 支持文件到前端页面中并调用:
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
// 如果无法在服务器中将wmf2svg.wasm文件设置为Content-Type:application/wasm格式返回
// 可能会导致无法使用instantiateStreaming方法直接读取文件
// 可以尝试这种方式将其直接转为字节流
fetch("wmf2svg.wasm")
.then((response) => response.arrayBuffer())
.then((bytes) => WebAssembly.instantiate(bytes, go.importObject))
.then((result) => go.run(result.instance));
// 官方使用的加载wasm文件的标准方法
WebAssembly.instantiateStreaming(fetch("wmf2svg.wasm"), go.importObject)
.then((result) => go.run(result.instance));
// 加载完成后直接调用方法
let res = Wmf2Svg(src)
</script>
四
完成了录入和转换的过程,接下来需要处理一下导出为 word/pdf 格式文件的兼容性问题。
导出图片需要在服务器中安装字体支持,MathType 公式可能会用到的 Arial,Times New Roman,Symbol,MT Extra,SimSun,Wingdng2,Wingdng3 等,这些字体都可以在 Windows 的字体库中找到,可以直接复制后安装到服务器中。
特别的,如果是在 Docker 中运行的 Alpine 镜像还需要为其安装一些默认字体,如:apk add font-noto
,否则可能会出现部分 Unicode 字符显示异常的问题。
安装完成字体后,使用命令fc-cache -f
刷新字体并重启 php-fpm。
五
遗留的一些问题:
- 遇到直接使用 Unicode 字符组合成公式的情况可能有部分标准库扩展的 Unicode 字符由于找不到字体支持而渲染失败
- 当文件中包含了过多的公式图片时,识别和转换的速度会非常的慢甚至影响到正常的使用