☰
ExcelJS实战:在Excel单元格中精确居中插入图片的完整方案
2026/10/3 11:29:54 网站建设 项目流程

ExcelJS 是我平时处理 Node.js 环境下 Excel 生成与解析最常用的库,没有之一。它比 Python 的 openpyxl 轻,又比直接拼 XML 省心,尤其在做报表导出、自动化办公这类需求时,一个await workbook.xlsx.writeFile()就能拿到 xlsx。但最近有个需求卡了我一阵子:往某个指定的单元格里插入一张图片,而且要让图片在这个单元格里水平垂直都居中。本想着worksheet.addImage()一行搞定,结果做着做着发现坑还不少——列宽和行高的单位、图片锚点的坐标模型、偏移量的换算……这篇文章就把这套“使用 ExcelJS 向 XLSX 单元格居中插入图片”的完整思路和踩坑过程写出来,给同样被这个需求折腾的朋友一个参考。

这个需求常见于什么场景呢?批量生成带商品图、人员头像、签名章的 Excel 报表,按模板回填图片数据,或者在导出表格时给每个分类行加上小图标。无论哪种,核心诉求都一样:图片要出现在指定的格子里,位置要正,不能被拉伸变形,别人打开表格也不会乱跑。适合谁来参考:正在用 Node.js 做批量报表、需要把图片精确塞进 xlsx 单元格的开发者,无论前端后端都能用得上。

1. 整体设计与思路拆解

1.1 为什么是 ExcelJS

提到 Node.js 操作 Excel 图片,绕不开两个库:SheetJS(也就是常说的xlsx库)和 ExcelJS。xlsx库在单元格读写、公式计算这块很强,但图片支持非常弱,社区里至今还有不少 open issue 说图像功能残缺。ExcelJS 则在 README 里直接列了图片支持,虽然功能不算多,但“插入图片”这种常规操作是完整的。所以我在图片插入这个需求上没犹豫,直接选了 ExcelJS。

选型之外,还要先想清楚一个问题:ExcelJS 的图片插入本质上是“浮动对象定位”,而不是“单元格内容”。在 XLSX 的底层 XML 里,图片是通过<xdr:twoCellAnchor>、<xdr:oneCellAnchor>这一类锚点元素描述位置的。也就是说,Excel 里的图片不是“存在某个单元格里”,而是“贴在表格上,位置刚好盖住了某个区域”。理解了这一点,你对“居中”的理解就会完全不同——它不是单元格内容对齐,而是浮动图片的位置计算。

我见过不少同事第一次做这个需求,上来就搜“ExcelJS 单元格插入图片”,找到一段示例代码,复制跑通,图片是出来了,但位置总是差那么一点。根本原因就是没搞懂浮动定位模型。所以这篇文章不会只丢一个能跑的代码给你,我会把背后的换算逻辑讲透,这样你以后遇到不同行高、不同列宽、合并单元格的情况,都能自己推算出来。

1.2 居中的本质是三个换算

既然图片是浮动定位,那“在一个单元格里居中”,本质就是三件事:

  1. 算出这个单元格的显示区域有多大(宽、高,单位是像素)。
  2. 知道这张图片本身的实际像素尺寸。
  3. 算出图片左上角应该落在什么位置,公式很简单:水平偏移 = (单元格宽 - 图片宽) / 2,垂直偏移 = (单元格高 - 图片高) / 2。

这里你马上会遇到第一个坑:Excel 的行高和列宽,单位都不是像素。

列宽的单位是“字符数”,行高单位是“磅”。列宽 10,不是说 10 像素;行高 15,也不是 15 像素。要算像素就得做换算,换算公式不复杂,网上也有现成的近似算法,但很多文章不讲清楚,直接拿来用就容易偏。我后面第 2 章专门讲这套换算,还会给出一个实测可用的近似公式,以及它为什么会有误差。

1.3 方案的取舍

实现“单元格居中插入图片”有几种路子,我对比一下。

第一种,直接把图片塞进单元格范围,比如worksheet.addImage(imageId, 'B2:B2')。这种写法最简单,图片会被拉成填满整个单元格,如果你要的就是“整格铺满”,那它最方便。但问题是图片会被拉伸,比例变了,很多时候并不是我们要的效果。举个实际例子,你在商品表格里放一个 800×600 的方形图,单元格却是个长方形,拉满之后图就扁了,非常难看。

第二种,先在图片四周补白边,让图片内容天然居中。这个思路能绕开所有坐标计算,但要求你提前处理图片文件,而且单元格尺寸一变,白边比例就废了,维护成本很高。我一开始偷懒试过这种方式,后来模板稍微改了下列宽,所有图片位置全乱了,得不偿失。

第三种,也是我在这篇文章里重点推荐的做法:手动计算列宽行高的像素值,算出图片左上角的精确偏移,然后用addImage的tl锚点加ext尺寸写入。图片的宽高比不变,位置精确,单元格尺寸变了也能通过代码重新算。缺点就是需要理解单位换算,代码稍微多一点,但一次写好,后面全是复用。

这三种方案各有适用场景。如果只是临时往固定模板里塞图,第一种够用;如果是要长期维护的报表工具,建议直接上第三种,后面省心得多。

2. 核心细节解析与实操要点

2.1 列宽和行高到底怎么换算成像素

先给结论,再讲原理。

在 ExcelJS 中,worksheet.getColumn(2).width拿到的列宽,单位是“字符数”,默认字体(Calibri 11)下一个字符大约是 7 像素。加上 Excel 列头本身的一些边距,行业里常用的近似公式是:

列宽像素 = 列宽(字符) × 7 + 5

这个+5是列的内边距(padding)造成的偏差,实际在普通 Windows Excel 里算下来基本能对上,误差在 2~3 像素以内。如果你的列宽比较大,这点误差肉眼完全看不出来。

行高就简单一些,单位是磅(pt),1 磅 = 4/3 像素:

行高像素 = 行高(磅) × 4 / 3

ExcelJS 里worksheet.getRow(2).height返回的就是磅值。

还要注意一个细节:如果列宽或行高没有显式设置过,ExcelJS 返回的可能是undefined。比如一个新表,没设置任何行高,getRow(1).height可能拿不到值。遇到这种情况,就当成默认处理——Excel 的默认行高大约是 14.5~15 磅,列宽默认大约是 8.43 字符。自己代码里可以兜个底:

function getCellSizePx(worksheet, colIndex, rowIndex) { const col = worksheet.getColumn(colIndex); const row = worksheet.getRow(rowIndex); const colWidth = col.width || 8.43; // 默认列宽(字符) const rowHeight = row.height || 15; // 默认行高(磅) return { widthPx: colWidth * 7 + 5, heightPx: rowHeight * 4 / 3, }; }

这套换算我在 Windows 版 Excel、WPS、Mac 版 Excel 上都验证过,整体偏差很小。如果你发现自己的模板字体不是 Calibri,而是宋体、雅黑这类,列宽的7这个系数可能需要微调,下面第 4 章的排查部分会再展开。

2.2 图片尺寸怎么拿到

要计算居中偏移,必须知道图片本身有多大。如果你手里只有 base64 字符串或者 Buffer,可以先解码成 Buffer,再用image-size这个库读宽高:

npm install image-size
const sizeOf = require('image-size'); const imgBuffer = Buffer.from(base64Str, 'base64'); const size = sizeOf(imgBuffer); console.log(size.width, size.height); // 实际像素宽高

如果你的项目跑在浏览器环境,没有 Node 的 Buffer,也可以用image-size的浏览器版,或者把图片塞进<img>标签从naturalWidth/naturalHeight拿尺寸,思路是一样的。

拿到尺寸后,还要决定“显示尺寸”。如果图片本身 800×600,塞进一个 120×80 的格子,直接按原尺寸显示那肯定溢出。所以常规做法是等比缩放:

function fitImageToCell(imgW, imgH, cellW, cellH, padding = 4) { const maxW = cellW - padding * 2; const maxH = cellH - padding * 2; const scale = Math.min(maxW / imgW, maxH / imgH, 1); // 不超过1,小图不放大 return { width: Math.round(imgW * scale), height: Math.round(imgH * scale), }; }

Math.min(..., 1)保证图片不会因为比格子小而被强行放大,这也是很多报表里“小图保持原大小、大图等比缩小”的通用策略。举个例子,如果原图是 40×40,格子是 100×50,那么 scale = min(96/40, 42/40, 1) = 1,图片保持 40×40,再居中到格子里,视觉上更干净。

2.3 锚点偏移的两种写法

ExcelJS 的addImage(imageId, anchor)里,anchor 的tl是左上角定位。文档里有两种字段写法:

  • 整数索引 + 偏移量:{ col: 0, row: 0, colOff: xxx, rowOff: xxx }
  • 小数索引:{ col: 0.5, row: 0.5 }

先说第一种。col和row是 0 起索引,比如 A1 就是{ col: 0, row: 0 }。偏移量colOff/rowOff在 XLSX 底层走的是 EMU 单位,1 像素 = 9525 EMU。所以如果你算出来水平要偏移 30 像素,就得写:

colOff: Math.round(30 * 9525)

再说第二种,小数字段。col: 1.25等价于从第 2 列开始再往右偏移 0.25 个列宽,ExcelJS 内部会帮你把小数部分转成偏移量。这种方式写起来最直观:

tl: { col: cellColIndex - 1 + offsetX / cellWidthPx, row: cellRowIndex - 1 + offsetY / cellHeightPx, }

两种写法效果差不多,我实战中更常用第二种,因为不用记 EMU 的 9525 换算,代码可读性也高。但要注意,col使用小数时,就不要再同时填colOff,否则部分版本的 Excel 解析可能出现问题。如果你要精确到像素级,用第一种 EMU 写法更可靠。

2.4 editAs 参数别忽略

addImage的 anchor 里还可以带一个editAs字段,可选值有'twoCell'、'oneCell'、'absolute'。

  • twoCell:图片右下角锚定到另一个单元格,调整列宽行高时图片会跟着拉伸缩放。
  • oneCell:图片左上角锚定到一个单元格,移动单元格时图片跟着走,但不会随行列缩放改变大小。
  • absolute:图片位置绝对固定,不随单元格变化。

日常做“单元格居中插图”,我建议默认用oneCell,这样你后续调整行高、列宽,图片相对单元格的位置不会乱,也不会被拉伸。如果你希望图片能跟随行列宽高自动缩放,再考虑twoCell。我遇到过一个需求,用户要求拖动列宽时图片也等比放大,那时候才用的twoCell,但代价是图片比例容易被破坏,所以建议慎用。

3. 实操过程与核心环节实现

3.1 环境准备

先装依赖:

npm install exceljs image-size

然后准备一张测试图片。我从本地读了一个 PNG:

const fs = require('fs'); const ExcelJS = require('exceljs'); const sizeOf = require('image-size'); const imgBuffer = fs.readFileSync('./avatar.png'); const size = sizeOf(imgBuffer); console.log(size); // { width: 400, height: 300, type: 'png' }

3.2 完整示例代码

下面是我实测过的完整示例:创建一张空表,在第 2 行第 2 列(B2)插入一张图片,保持在单元格内等比缩放并居中。

const fs = require('fs'); const ExcelJS = require('exceljs'); const sizeOf = require('image-size'); async function insertImageCentered() { const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet('Sheet1'); // 设置目标单元格的列宽和行高 worksheet.getColumn(2).width = 20; // B列,字符单位 worksheet.getRow(2).height = 60; // 第2行,磅 // 目标单元格:B2 const colIndex = 2; // B列 const rowIndex = 2; // 第2行 worksheet.getCell(colIndex, rowIndex).value = '头像'; // 给个示例文案,方便看到格子位置 // 读取图片并添加进 workbook,拿到 imageId const imgBuffer = fs.readFileSync('./avatar.png'); const size = sizeOf(imgBuffer); const imageId = workbook.addImage({ buffer: imgBuffer, extension: 'png', }); // 1. 单元格尺寸(像素) const colWidth = worksheet.getColumn(colIndex).width || 8.43; const rowHeight = worksheet.getRow(rowIndex).height || 15; const cellWidthPx = colWidth * 7 + 5; const cellHeightPx = rowHeight * 4 / 3; // 2. 等比缩放图片,留 4px 边距 const padding = 4; const maxW = cellWidthPx - padding * 2; const maxH = cellHeightPx - padding * 2; const scale = Math.min(maxW / size.width, maxH / size.height, 1); const displayW = Math.round(size.width * scale); const displayH = Math.round(size.height * scale); // 3. 计算左上角偏移,实现水平垂直居中 const offsetX = (cellWidthPx - displayW) / 2; const offsetY = (cellHeightPx - displayH) / 2; // 4. 插入图片 worksheet.addImage(imageId, { tl: { col: colIndex - 1 + offsetX / cellWidthPx, row: rowIndex - 1 + offsetY / cellHeightPx, }, ext: { width: displayW, height: displayH, }, editAs: 'oneCell', }); await workbook.xlsx.writeFile('./output.xlsx'); console.log('done'); } insertImageCentered().catch(console.error);

这里我特意用小数索引的写法,因为最直观。如果你希望用更精确的 EMU 写法,把tl改成这样:

tl: { col: colIndex - 1, row: rowIndex - 1, colOff: Math.round(offsetX * 9525), rowOff: Math.round(offsetY * 9525), },

两者最终效果差不多,你可以都试一下,看自己项目里哪种更稳。我自己在大多数项目里其实用的就是小数索引写法,简单,而且不依赖对 EMU 单位的记忆。

3.3 参数计算过程演示

我们用上面代码里的数值实际算一遍,这样你能看得更清楚:

  • B 列列宽 20,行高 60。
  • 单元格像素宽 = 20 × 7 + 5 = 145px;单元格像素高 = 60 × 4 / 3 = 80px。
  • 假设图片原始尺寸是 400×300。
  • 去掉 4px 边距后,最大可用区域是 145 - 8 = 137px 宽,80 - 8 = 72px 高。
  • 缩放比例 = min(137 / 400, 72 / 300, 1) = min(0.3425, 0.24, 1) = 0.24。
  • 显示尺寸:宽 400 × 0.24 = 96px,高 300 × 0.24 = 72px。
  • 水平偏移 = (145 - 96) / 2 = 24.5px,垂直偏移 = (80 - 72) / 2 = 4px。
  • 锚点 col = (2 - 1) + 24.5 / 145 ≈ 1.169;锚点 row = (2 - 1) + 4 / 80 = 1.05。

注意,这里得到的是图片左上角在 (1.169, 1.05) 的锚点位置,图片自身宽 96 高 72,放完后右下角大约在 (1.169 + 96/145, 1.05 + 72/80) ≈ (1.83, 1.95),正好落进 B2 里面,且视觉上居中。

如果图片比单元格还小,比如 50×30,scale = min(137/50, 72/30, 1) = min(2.74, 2.4, 1) = 1,图片不会被放大,显示就是原尺寸 50×30,居中后偏移会更大。这个策略适合头像、logo 这种小图,避免糊成一团。

3.4 批量插入多个单元格

实际项目里你往往不是只插一张,而是循环几十行。批量处理时,建议把上面的逻辑封装成一个工具函数:

function addImageCentered(worksheet, imageId, imgW, imgH, colIndex, rowIndex, padding = 4) { const colWidth = worksheet.getColumn(colIndex).width || 8.43; const rowHeight = worksheet.getRow(rowIndex).height || 15; const cellW = colWidth * 7 + 5; const cellH = rowHeight * 4 / 3; const maxW = cellW - padding * 2; const maxH = cellH - padding * 2; const scale = Math.min(maxW / imgW, maxH / imgH, 1); const displayW = Math.round(imgW * scale); const displayH = Math.round(imgH * scale); const offsetX = (cellW - displayW) / 2; const offsetY = (cellH - displayH) / 2; worksheet.addImage(imageId, { tl: { col: colIndex - 1 + offsetX / cellW, row: rowIndex - 1 + offsetY / cellH, }, ext: { width: displayW, height: displayH }, editAs: 'oneCell', }); }

循环调用时,有一个关键点必须记住:同一张图片只需要workbook.addImage()添加一次,拿到 imageId 后可以反复使用。如果你每行都重新addImage一次,文件里会塞进大量重复的图片数据,体积直接爆炸。我之前给 500 行数据插同一张默认头像,一开始不懂,每行都 addImage,生成的文件从 200KB 变成了 20MB,后来改成复用 imageId,文件又回到了合理大小。

如果图片源不同、尺寸也不同,调用前先分别用image-size把每张图的宽高拿到,再传给函数。

4. 常见问题与排查技巧实录

4.1 xlsx is not defined 是怎么回事

这个问题经常出现在刚上手的新项目里。很多人看到教程里写ExcelJS,又看到别人写xlsx,以为是同一个东西,结果代码里混着用,就会出现xlsx is not defined。

ExcelJS 和 SheetJS(xlsx)是两套完全独立的库。如果你用的是 ExcelJS,正确引入方式是这样:

const ExcelJS = require('exceljs');

不要写成require('xlsx')再指望它有addImage方法——xlsx库的图片能力很弱,API 也完全不一样。如果是浏览器端用 CDN 方式引入,要注意全局变量名是ExcelJS,不是xlsx。我在一个前端导出项目里就见过同事把全局变量名搞混,浏览器报错以后排查半天才发现是变量名问题。

4.2 图片没有精确居中,偏差了几个像素

这种问题多半出在单位换算上。常见原因有三个:

  • 列宽换算用的系数不对。不同字体、不同 Excel 版本会有细微差别,7这个系数是基于 Calibri 11 的近似值。如果用了大字号字体,偏差会变大,这时候你需要根据实际情况微调系数,或者直接加大 padding 值,让偏差被边距吃掉。
  • 行高没有取值成功。getRow().height返回undefined时,代码偷偷用了默认 15 磅,但实际 Excel 可能是 14.5,1 像素的差别就会导致垂直方向不居中。
  • colOff和rowOff的单位搞错。如果用 EMU 写法,记得 1 像素 = 9525 EMU,别直接拿像素值填进去,否则偏移量只有实际值的万分之一,几乎等于没偏移,图片会死死贴着左上角。

排查方法很简单:先临时把代码里的图片 ext 设得很小(比如 10×10),然后生成文件看左上角位置对不对,再逐步放大,过程中对比偏移量。这样能快速定位是缩放问题还是偏移计算问题。

4.3 图片被拉伸变形了

如果你用了worksheet.addImage(imageId, 'B2:B2')这种范围式写法,图片一定会被拉伸成填满整个单元格,比例跟你原图没关系。

想保持比例,就用我前面讲的ext指定显示宽高,并且按照原图宽高等比缩放。再就是检查editAs,如果用了twoCell,用户拉宽列时图片也会跟着变形,这里推荐oneCell。

4.4 生成的文件打不开或者 Excel 提示修复

Excel 对 XLSX 里的 XML 结构还是有点挑剔的,ExcelJS 在大部分场景下很稳,但如果你的 anchor 里同时填了小数col和colOff,有些版本可能不认。这是我自己踩过的坑:用小数索引时又加了 EMU 偏移,最终文件用 WPS 能打开,Excel 却提示修复,去掉 colOff 就好了。

另一个隐蔽问题是extension和图片真实格式不一致。比如你传的是 JPG 数据,extension 却写了'png',Excel 打开时可能图片显示不出来。统一用image-size读出来,或者自己根据 Buffer 的 magic number 判断格式。

4.5 图片插入后位置对,但文字被挡住了

如果你在同一个单元格里既写了文字、又插了图片,图片默认浮动在上层,会盖住文字。想要“文字和图片共存”,常见做法是给图片留出半边位置,比如把图片偏移到单元格的右侧,文字写在左侧。真要严格的上下结构,Excel 原生做不了太精细,我一般会让单元格变大,把图片定位到文字上方,或者直接把文字做成图片一起插入。这个取舍要看你的实际模板,没有统一答案。

4.6 常见问题速查表

现象可能原因解决方案
xlsx is not defined引错库或变量名错误检查引入,确认是const ExcelJS = require('exceljs')
图片整体偏右下或左上偏移量单位错误或换算系数不准重新检查列宽行高换算,或改用小数索引写法
图片被拉伸用了范围式 anchor 或 twoCell改用ext指定等比尺寸,editAs 用 oneCell
图片模糊ext 放大超过原图尺寸图片小于单元格时保持原尺寸,不放大
Excel 提示文件修复anchor 同时填了小数 col 和 colOff二选一,不要混用
图片不显示extension 与真实格式不符用 image-size 读取或判断文件头,确保 extension 正确
文件体积过大同一张图多次 addImage复用 imageId,只 addImage 一次

5. 还可以怎么扩展

5.1 合并单元格场景

热搜词里有人提到“el-table 合并单元格”这类问题,如果你导出的表格里有合并单元格,图片居中逻辑要稍作调整。合并单元格的宽高不是某一个单元格的宽高,而是合并区域所有行列尺寸之和。ExcelJS 里可以通过worksheet.getCell('B2').master.address判断主单元格,再遍历合并范围累加尺寸。

我简单说下思路:

const master = worksheet.getCell('B2').master; // 遍历 master.range 覆盖的所有行和列,累加列宽、行高得到合并区域总尺寸

有了合并区域总宽高,再用同样的“等比缩放 + 居中偏移”公式,就能把图片放到合并区域正中间。注意锚点tl的 col/row 要取合并区域左上角的单元格索引。

5.2 与前端表格导出结合

如果需求来自前端,典型场景是:页面上的表格展示商品列表,每行有个商品图,导出 Excel 时希望图片也带进去。这种场景下,前端代码把图片转成 base64,后端(或纯前端 ExcelJS 浏览器版)按行循环调用addImageCentered就行。要注意的是浏览器环境下image-size不能用 Node 的 Buffer,可以改用FileReader或 canvas 获取图片尺寸,或者干脆由前端直接传入naturalWidth/naturalHeight。

5.3 其他方案对比

如果你用的是 Java 技术栈,可能会用 Apache POI 处理图片。POI 的图片定位模型其实也是 XDR 锚点,与 ExcelJS 逻辑类似,只是 API 不同。Python 生态的 openpyxl 也有add_image,同样支持 anchor 定位。原理上都逃不开“单元格像素尺寸 + 偏移量”这套计算。所以这篇文章里的换算思路,换到其他语言一样能用,只是 API 名字不同而已。

我在实际项目里用这套方法给一批员工信息表加照片,最初直接偷懒用范围锚点B2:B2塞图片,图是放进去了,但有的照片是竖版、有的是横版,全被拉伸得没法看。后来改成“等比缩放 + 居中偏移”之后,才算真正解决了问题。这里特别提醒一句:列宽行高的单位换算公式,在不同的 Excel 字体设置下会有细微偏差,如果你的模板里字体、字号比较特殊,插入后记得用 Excel 打开看一眼,必要时微调padding或者换算系数。这套代码我目前已经跑了几个版本,批量几千行图片插入也稳定。如果你后面要扩展,建议把addImageCentered封装成独立模块,图片源和模板解耦,后续维护会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询