需求是在前端输入框中粘贴 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:

  1. java 编写的转换程序 wmf2svg 可以正常使用,但是 java 程序无法使用前端引用
  2. 尝试使用 bytecoder 直接编译 class 文件为 wasm,js 引用失败报异常
  3. 使用 teavm,但是 gradle 导入问题失败
  4. CheerpJ,商业软件,服务器在外国,每次刷新页面要下载十几 MB 数据,体验不好

这个程序过于复杂无法只使用 js 简单的实现,所以需要使用 wasm 来嵌入脚本代码实现,参考的就是上面的 wmf2svg 项目。

上面尝试途径全部失败后,只能用笨办法——手工将它的代码翻译为 go 语言。当然选择其他语言也可以,但是需要考虑到对 wasm 的支持以及语言库的完整性等,比如原项目用 java 开发对于 wasm 的支持就很差。最后还是决定用 go,也是对自己 go 能力的一种锻炼吧。


翻译的过程基本上就是下苦功了,一句一句的调整。

具体的实现原理可以参考微软官方的 wmf 格式文档。重点需要关注其中的 EXTTEXTOUT 和 TEXTOUT 这两个块的定义,公式图片中绝大多数的数据都会写在其中,而文件中使用的字体则会定义在 CREATEFONTINDIRECT 块中。

在原来 wmf2svg 项目中当编码中文字体的时候会输出错误,主要是由于字符编码设置错误的问题,将其中的中文字体分别使用simplifiedchinese.GBK.NewDecoder()traditionalchinese.Big5.NewDecoder()字符集进行编码就能输出中文了。

翻译项目中遇到的最麻烦的问题应该是字体和 Unicode 字符的显示问题:

  1. MT Extra 字体在 Firefox 显示不完全(Firefox 的 bug),使用在线加载字体:Firefox 没有实现在加载完远程字体后替换原来字体的功能;使用 svg 文件内置字体的形式也无法显示,真正的原因是字体中的字符 Unicode 码无法在 Firefox 中显示,即使加上字体也因为编码无法显示而显示为空
  2. 无法显示的典型字符:(化学等式左下箭头)\u86,(化学等式横杠)\u87,(化学等式右上箭头)\u88

    1. (不可行)将字体转化为 base64,在图片中内嵌字体文件,似乎是 Firefox 天然不支持上面几个 Unicode 码的显示,无论如何设置字体都不能显示
    2. (过于复杂无法实现)将无法显示的字体转为路径点形式
    3. (采用)寻找 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。


遗留的一些问题:

  1. 遇到直接使用 Unicode 字符组合成公式的情况可能有部分标准库扩展的 Unicode 字符由于找不到字体支持而渲染失败
  2. 当文件中包含了过多的公式图片时,识别和转换的速度会非常的慢甚至影响到正常的使用