摘要

本文记录了使用ExcelJS库,向XLSX单元格里居中写入图片的方法。

技术要点包括:

  1. xlsx 文件本质是一个 zip 文件,其内容是一组相互关联的 xml 文件,了解图片定位相关 xml 语法有助于解决问题;

  2. xlsx 的图片定位的长度单位是 EMU (English Metric Unit),单元格高度的单位是pt,列宽单位是0.1英寸,在写入图片时需要注意px、pt、emu、英寸之间的转换;

  3. !!!使用nativeColOff和nativeRowOff进行定位,不要使用官网给的定位方式!!!

  4. 写入 并保存 xlsx 后,用 excel 打开,可能会发现 excel 中显示的列宽与写入时给定的列宽不一致,二者有固定大小的偏差,因此在居中定位图片时需要考虑列宽偏差;

  5. 图片加载完毕后才能将图片写入到单元格,否则单元格内的图片将是空白的;

  6. 在浏览器端,ExcelJS只支持写入base64字符串格式的图片。可以将图片绘制到canvas元素然后将canvas转为base64字符串。

ExcelJS介绍

ExcelJS是前端开发者常用的读写xlsx文件的npm包。SheetJS也支持相同功能,但是想实现写入图片、自定义样式等非基础功能,需要购买Pro版。有xlsx-style库可以帮助实现上述功能,但是需要修改源码,比较麻烦,而且xlsx-style库已不再维护。详情可见两个包的官方文档:

ExcelJS:
GitHub:https://github.com/exceljs/exceljs
中文版:https://www.cnblogs.com/full-stack-linux-new/p/18249002 (版本更新信息不全)

SheetJS:
GitHub: https://sheetjs.com/
中文版:https://xlsx.nodejs.cn/docs/getting-started/examples/import/

XLSX文件格式

一个XLSX文件是以xlsx为后缀的zip文件。修改xlsx文件后缀为zip,然后解压,就会发现,xlsx文件本质上就是一堆相互关联的xml文件。

下图是修改文件扩展名前后:
xlsx文件修改后缀得到zip文件
下图是解压后的文件夹结构:
zip文件解压后得到相互关联的xml文件和文件夹
下图为xl文件夹的结构,这个文件夹是xlsx的核心:
xl文件夹结构
下面为/xl/worksheets/sheet1.xml的代码,可以看见其标签是语义化的,比较好理解:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
    xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="x14ac"
    xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac">
    <dimension ref="A1:C7" />
    <sheetFormatPr defaultRowHeight="15" outlineLevelRow="0" outlineLevelCol="0"
        x14ac:dyDescent="55" />
    <cols>
        <col min="1" max="1" width="40" customWidth="1" />
        <col min="2" max="2" width="5" customWidth="1" />
        <col min="3" max="3" width="40" customWidth="1" />
    </cols>
    <sheetData>
        <row r="1" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A1" t="s">
                <v>0</v>
            </c>
            <c r="B1" t="s">
                <v>1</v>
            </c>
            <c r="C1" t="s">
                <v>2</v>
            </c>
        </row>
        <row r="2" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A2" t="s">
                <v>3</v>
            </c>
            <c r="B2" t="s">
                <v>4</v>
            </c>
            <c r="C2" t="s">
                <v>5</v>
            </c>
        </row>
        <row r="3" ht="100" customHeight="1" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A3" t="s">
                <v>3</v>
            </c>
            <c r="B3" s="1" t="s">
                <v>6</v>
            </c>
            <c r="C3" t="s">
                <v>7</v>
            </c>
        </row>
        <row r="4" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A4" t="s">
                <v>3</v>
            </c>
            <c r="B4" t="s">
                <v>8</v>
            </c>
            <c r="C4" t="s">
                <v>7</v>
            </c>
        </row>
        <row r="5" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A5" t="s">
                <v>3</v>
            </c>
            <c r="B5" t="s">
                <v>4</v>
            </c>
            <c r="C5" t="s">
                <v>9</v>
            </c>
        </row>
        <row r="6" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A6" t="s">
                <v>3</v>
            </c>
            <c r="B6" t="s">
                <v>10</v>
            </c>
            <c r="C6" t="s">
                <v>11</v>
            </c>
        </row>
        <row r="7" spans="1:3" x14ac:dyDescent="0.25">
            <c r="A7" t="s">
                <v>3</v>
            </c>
            <c r="B7" t="s">
                <v>10</v>
            </c>
            <c r="C7" t="s">
                <v>12</v>
            </c>
        </row>
    </sheetData>
    <pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3" />
    <pageSetup orientation="portrait" horizontalDpi="4294967295" verticalDpi="4294967295"
        scale="100" fitToWidth="1" fitToHeight="1" />
    <drawing r:id="rId1" />
</worksheet>

系统的xlsx文件格式解析请见:
http://officeopenxml.com/anatomyofOOXML-xlsx.php

应着重了解 sheet、workbook、rels、drawing、media 等组成部分。

XLSX文件格式的规范基于XML格式规范,XML规范可自行搜索。

XLSX的图片定位

XLSX的图片定位方式遵循DrawML规范(http://officeopenxml.com/drwOverview.php)。

图片插入与定位的核心是在drawing.xml指定图片来源定位起始点,和 终止点,如下方代码和注释所示:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"
    xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
    <!--起锚定方式-->
    <xdr:twoCellAnchor editAs="twoCell">
    	<!--起始点-->
        <xdr:from>
        	<!--起始行列以及偏离值-->
            <xdr:col>1</xdr:col>
            <xdr:colOff>0</xdr:colOff>
            <xdr:row>2</xdr:row>
            <xdr:rowOff>0</xdr:rowOff>
        </xdr:from>
        <!--终点-->
        <xdr:to>
        	<!--终止行列以及偏离值-->
            <xdr:col>2</xdr:col>
            <xdr:colOff>0</xdr:colOff>
            <xdr:row>2</xdr:row>
            <xdr:rowOff>999899</xdr:rowOff>
        </xdr:to>
        <!--图片信息-->
        <xdr:pic>
            <xdr:nvPicPr>
                <xdr:cNvPr id="1" name="Picture 1">
                    <a:extLst>
                        <a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
                            <a16:creationId
                                xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
                                id="{00000000-0008-0000-0000-000002000000}" />
                        </a:ext>
                    </a:extLst>
                </xdr:cNvPr>
                <xdr:cNvPicPr>
                    <a:picLocks noChangeAspect="1" />
                </xdr:cNvPicPr>
            </xdr:nvPicPr>
            <!--图片来源-->
            <xdr:blipFill>
            	<!-- r:embed="rId1"是drawings文件夹下_rels子文件夹中rel文件里图片的ID -->
                <a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
                    r:embed="rId1" cstate="print" />
                <a:stretch>
                    <a:fillRect />
                </a:stretch>
            </xdr:blipFill>
            <xdr:spPr>
                <a:xfrm>
                    <a:off x="0" y="0" />
                    <a:ext cx="0" cy="0" />
                </a:xfrm>
                <a:prstGeom prst="rect">
                    <a:avLst />
                </a:prstGeom>
            </xdr:spPr>
        </xdr:pic>
        <xdr:clientData />
    </xdr:twoCellAnchor>
</xdr:wsDr>

定位细节请见:http://officeopenxml.com/drwOverview.php

长度单位

定位偏移量(rowOff,colOff)的单位是EMU(English Metric Unit),其值必须是整数,否则会导致xlsx无法打开。

1 cm = 360000 EMUs

为了确定定位偏移量,需要知道图片的物理尺寸,以及单元格的物理尺寸。可通过html api获取客户端1px对应的物理长度。xlsx 单元格高度的单位是pt,列宽单位是0.1英寸,很容易与EMU换算。

我是通过阅读网络博客知道上述长度单位的,没有在ISO标准中确认过。

两个行为怪异的定位方式:tl/Ext,tl/br

ExcelJS Readme里记录了两种图片定位方式,包括:

  1. 使用 tl(topLeft的缩写) 和 ext(extent 的缩写)
    在这里插入图片描述
    ext的长宽是96dpi下,以px为单位的图片长宽。这个API需要进行繁琐的长度换算,而且行为怪异无法正确地精细控制图片形状和位置建议不要使用

  2. 使用 tl 和 br(bottomRight 的缩写)
    在这里插入图片描述
    这API看着没啥问题,实际用起来也是一团糟,感兴趣的可以试试。建议不要使用

ColOff & RowOff

在具备了上述知识后,阅读exceljs 的github上的readme文档的release(版本更新历史)部分,发现release4.3.0最下面一项说修改了定位bug。
release4.3.0
查看此PR(pull request),发现修改了定位相关代码,增加了一个Anchor类

在这里插入图片描述

这个类的成员的语义与xml原生定位标签语义很相似。根据ExcelJS源码中对Anchor类的使用情况以及TS类型导出情况,我判断 release4.3.0后已经可以使用nativeCol/Row和nativeColOff/RowOff进行图片定位,但是Readme还没更新。

于是,我尝试使用这一方法。

在这里插入图片描述

果然,定位结果是准确的,效果如下所示:

在这里插入图片描述

定位技术细节

说完解决定位bug的过程,现在通过一个案例讲下ExcelJS图片定位的技术细节。

下面是一个封装好的写入图片的函数。参数workbook和worksheet都是ExcelJS库构造的对象,在此不赘述,详情请见ExcelJS官网。img是一个img元素,cellName是一个类似’B3’这样的字符串。这个函数是用来写入正方形图片的,长方形图片其实大同小异

此函数做了几件事情:

  1. 获取目标单元格的物理尺寸
  2. 获取图片尺寸,构造相同大小的canvas,将图片绘制到canvas上,然后转为base64字符串
  3. 根据目标单元格尺寸和图片尺寸,计算col/row和colOff/rowOff的值
  4. 将图片添加到workbook,获取imageID
  5. 将图片imageID添加到worksheet
  function writeImgToCell(workbook: Workbook, worksheet: Worksheet, img: HTMLImageElement, cellName: string) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')

    const rowNumber = parseInt(cellName.slice(1))
    const columnNumber = cellName.charCodeAt(0) - 64
    const row = worksheet.getRow(rowNumber)
    const column = worksheet.getColumn(columnNumber)

    const columnWidthInEMU = Math.floor((column.width! - 0.62) * WUM_MM * MM_EMU_RATIO)
    const columnHeightInEMU = Math.floor(row.height * PT_MM_RATIO * MM_EMU_RATIO)
    const imageSizeInEMU = columnWidthInEMU < columnHeightInEMU ? columnWidthInEMU : columnHeightInEMU // 0.1inch -> mm -> px
    const rowSpaceTop = Math.floor((columnHeightInEMU - imageSizeInEMU) / 2)

    canvas.height = img.naturalWidth
    canvas.width = img.naturalWidth
    context?.drawImage(img, 0, 0)

    const myBase64Image = canvas.toDataURL()
    const imageId = workbook.addImage({
      base64: myBase64Image,
      extension: 'png'
    })

    worksheet.addImage(imageId, {
      tl: { nativeRow: 2, nativeCol: 1, nativeRowOff: rowSpaceTop, nativeColOff: 0 } as Anchor,
      br: { nativeRow: 2, nativeCol: 2, nativeRowOff: rowSpaceTop + imageSizeInEMU, nativeColOff: 0 } as Anchor,
      editAs: 'twoCell'
    })
  }

需要注意的是,必须在图片异步加载完成后再将图片添加到单元格,如下方代码所示:

const cellName = 'B3'
const img = new Image()
img.src = imageUrl //这里是图片url
img.crossOrigin = 'anonymous'
img.onload = () => {
  writeImgToCell(workbook, worksheet, img, cellName)
}

同步添加图片会导致添加图片为空

另外,我发现,用excel打开导出的xlsx后,列宽总是比给定列宽小0.62。比如下图中,显示列宽为14.38,但是我给定的列宽是15。目前还不知道是什么原因。
在这里插入图片描述

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐