From 1330940d4c5c7e920959bb99ccce08eedafdfc7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=87=E9=94=8B?= Date: Tue, 4 Aug 2026 15:32:34 +0800 Subject: [PATCH 1/2] fix image import bug --- src/ToExcel/ExcelImage.ts | 104 ++++--- src/ToFortuneSheet/FortuneFile.ts | 81 +++--- src/ToFortuneSheet/FortuneSheet.ts | 272 ++++++++++-------- ...ransformExcelToFortune.xls_preview.test.js | 116 +++++++- 4 files changed, 368 insertions(+), 205 deletions(-) diff --git a/src/ToExcel/ExcelImage.ts b/src/ToExcel/ExcelImage.ts index fa7757d..8fc08c9 100644 --- a/src/ToExcel/ExcelImage.ts +++ b/src/ToExcel/ExcelImage.ts @@ -1,30 +1,39 @@ -//获取图片在单元格的位置 -var getImagePosition = function (num: number, arr: number[]) { - let index = 0; - let minIndex; - let maxIndex; - for (let i = 0; i < arr.length; i++) { - if (num < arr[i]) { - index = i; - break; +type ImageExtension = "png" | "jpeg" | "gif"; + +// Convert a pixel offset to ExcelJS's zero-based fractional cell coordinate. +const getImagePosition = function (num: number, positions: number[]) { + if (positions.length === 0) { + return 0; + } + + let previous = 0; + for (let index = 0; index < positions.length; index++) { + const current = positions[index]; + if (num <= current) { + const size = current - previous; + return index + (size > 0 ? (num - previous) / size : 0); } + previous = current; } - if (index == 0) { - minIndex = 0; - maxIndex = 1; - return Math.abs((num - 0) / (arr[maxIndex] - arr[minIndex])) + index; - } else if (index == arr.length - 1) { - minIndex = arr.length - 2; - maxIndex = arr.length - 1; - } else { - minIndex = index - 1; - maxIndex = index; + const lastIndex = positions.length - 1; + const lastSize = + lastIndex > 0 + ? positions[lastIndex] - positions[lastIndex - 1] + : positions[0]; + return positions.length + (lastSize > 0 ? (num - previous) / lastSize : 0); +}; + +const getImageExtension = function (src: string): ImageExtension { + const match = /^data:image\/([^;,]+)[;,]/i.exec(src || ""); + const imageType = match?.[1]?.toLowerCase(); + if (imageType === "jpg" || imageType === "jpeg") { + return "jpeg"; + } + if (imageType === "gif") { + return "gif"; } - let min = arr[minIndex]; - let max = arr[maxIndex]; - let radio = Math.abs((num - min) / (max - min)) + index; - return radio; + return "png"; }; var setImages = function (table: any, worksheet: any, workbook: any) { @@ -42,33 +51,33 @@ var setImages = function (table: any, worksheet: any, workbook: any) { const item = images[key]; const imageId = workbook.addImage({ base64: myBase64Image, - extension: "png", + extension: getImageExtension(myBase64Image), }); if (!visibledatacolumn || !visibledatarow) { const defaultColWidth = localTable.defaultColWidth || 73; const defaultRowHeight = localTable.defaultRowHeight || 19; - + const rowCount = localTable.data.length; const colCount = localTable.data[0].length; - + visibledatacolumn = []; visibledatarow = []; let lastVal = 0; - for (let i=0; i= this.columnWidthSet.length) { this.extendArray( fromCol, @@ -362,47 +370,44 @@ export class FortuneFile { } y_n = y_n + fromRowOff; - if (toCol >= this.columnWidthSet.length) { - this.extendArray( - toCol, - this.columnWidthSet, - defaultColWidth, - colhidden, - columnlen - ); - } - if (toCol == 0) { - cx_n = 0; + if (toCol == null || toRow == null) { + // One-cell anchors store their size in xdr:ext rather than xdr:to. + cx_n = imageObject.originWidth || 0; + cy_n = imageObject.originHeight || 0; } else { - cx_n = this.columnWidthSet[toCol - 1]; - } - cx_n = cx_n + toColOff - x_n; + if (toCol >= this.columnWidthSet.length) { + this.extendArray( + toCol, + this.columnWidthSet, + defaultColWidth, + colhidden, + columnlen + ); + } + if (toCol == 0) { + cx_n = 0; + } else { + cx_n = this.columnWidthSet[toCol - 1]; + } + cx_n = cx_n + toColOff - x_n; + + if (toRow >= this.rowHeightSet.length) { + this.extendArray( + toRow, + this.rowHeightSet, + defaultRowHeight, + rowhidden, + rowlen + ); + } + if (toRow == 0) { + cy_n = 0; + } else { + cy_n = this.rowHeightSet[toRow - 1]; + } - if (toRow >= this.rowHeightSet.length) { - this.extendArray( - toRow, - this.rowHeightSet, - defaultRowHeight, - rowhidden, - rowlen - ); + cy_n = cy_n + toRowOff - y_n; } - if (toRow == 0) { - cy_n = 0; - } else { - cy_n = this.rowHeightSet[toRow - 1]; - } - - cy_n = cy_n + toRowOff - y_n; - - console.log(defaultColWidth, colhidden, columnlen); - console.log(fromCol, this.columnWidthSet[fromCol], fromColOff); - console.log( - toCol, - this.columnWidthSet[toCol], - toColOff, - JSON.stringify(this.columnWidthSet) - ); imageObject.originWidth = cx_n; imageObject.originHeight = cy_n; diff --git a/src/ToFortuneSheet/FortuneSheet.ts b/src/ToFortuneSheet/FortuneSheet.ts index 1f43df8..b27e095 100644 --- a/src/ToFortuneSheet/FortuneSheet.ts +++ b/src/ToFortuneSheet/FortuneSheet.ts @@ -291,136 +291,168 @@ export class FortuneSheet extends FortuneSheetBase { let drawingFile = allFileOption.drawingFile, drawingRelsFile = allFileOption.drawingRelsFile; if (drawingFile != null && drawingRelsFile != null) { - let twoCellAnchors = this.readXml.getElementsByTagName( - "xdr:twoCellAnchor", - drawingFile + this.parseImages(drawingFile, drawingRelsFile); + } + } + + private parseImages(drawingFile: string, drawingRelsFile: string) { + const anchors = this.readXml.getElementsByTagName( + "xdr:twoCellAnchor|xdr:oneCellAnchor|xdr:absoluteAnchor", + drawingFile + ); + + for (const anchor of anchors) { + const blips = anchor.getInnerElements("a:blip"); + if (blips == null || blips.length === 0) { + continue; + } + + const relationshipId = getXmlAttibute( + blips[0].attributeList, + "r:embed", + null + ); + const imageObject: any = this.getBase64ByRid( + relationshipId, + drawingRelsFile ); + if (imageObject == null) { + continue; + } - if (twoCellAnchors != null && twoCellAnchors.length > 0) { - for (let i = 0; i < twoCellAnchors.length; i++) { - let twoCellAnchor = twoCellAnchors[i]; - let editAs = getXmlAttibute( - twoCellAnchor.attributeList, - "editAs", - "twoCell" - ); + if (anchor.elementString.indexOf(" 0 && - xdr_blipfills.length > 0 - ) { - let xdrFrom = xdrFroms[0], - xdrTo = xdrTos[0], - xdr_blipfill = xdr_blipfills[0]; - - let rembed = getXmlAttibute( - xdr_blipfill.attributeList, - "r:embed", - null - ); - - let imageObject = this.getBase64ByRid(rembed, drawingRelsFile); - - // let aoff = xdr_xfrm.getInnerElements("a:off"), aext = xdr_xfrm.getInnerElements("a:ext"); - - // if(aoff!=null && aext!=null && aoff.length>0 && aext.length>0){ - // let aoffAttribute = aoff[0].attributeList, aextAttribute = aext[0].attributeList; - // let x = getXmlAttibute(aoffAttribute, "x", null); - // let y = getXmlAttibute(aoffAttribute, "y", null); - - // let cx = getXmlAttibute(aextAttribute, "cx", null); - // let cy = getXmlAttibute(aextAttribute, "cy", null); - - // if(x!=null && y!=null && cx!=null && cy!=null && imageObject !=null){ - // let x_n = getPxByEMUs(parseInt(x), "c"),y_n = getPxByEMUs(parseInt(y)); - // let cx_n = getPxByEMUs(parseInt(cx), "c"),cy_n = getPxByEMUs(parseInt(cy)); - - let x_n = 0, - y_n = 0; - let cx_n = 0, - cy_n = 0; - - imageObject.fromCol = this.getXdrValue( - xdrFrom.getInnerElements("xdr:col") - ); - imageObject.fromColOff = getPxByEMUs( - this.getXdrValue(xdrFrom.getInnerElements("xdr:colOff")) - ); - imageObject.fromRow = this.getXdrValue( - xdrFrom.getInnerElements("xdr:row") - ); - imageObject.fromRowOff = getPxByEMUs( - this.getXdrValue(xdrFrom.getInnerElements("xdr:rowOff")) - ); - - imageObject.toCol = this.getXdrValue( - xdrTo.getInnerElements("xdr:col") - ); - imageObject.toColOff = getPxByEMUs( - this.getXdrValue(xdrTo.getInnerElements("xdr:colOff")) - ); - imageObject.toRow = this.getXdrValue( - xdrTo.getInnerElements("xdr:row") - ); - imageObject.toRowOff = getPxByEMUs( - this.getXdrValue(xdrTo.getInnerElements("xdr:rowOff")) - ); - - imageObject.originWidth = cx_n; - imageObject.originHeight = cy_n; - - if (editAs == "absolute") { - imageObject.type = "3"; - } else if (editAs == "oneCell") { - imageObject.type = "2"; - } else { - imageObject.type = "1"; - } + const defaultEditAs = + anchor.elementString.indexOf(" { + const imageSource = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + const grid = Array.from({ length: 10 }, () => Array(10).fill(null)); + const sourceSheet = { + id: "roundtrip-sheet", + name: "Roundtrip", + data: grid, + config: {}, + defaultColWidth: 73, + defaultRowHeight: 19, + images: [ + { + id: "roundtrip-image", + src: imageSource, + left: 73, + top: 19, + width: 120, + height: 60, + originWidth: 120, + originHeight: 60, + }, + ], + }; + const exportRef = { + current: { + getAllSheets: () => [sourceSheet], + }, + }; + + const exported = await transformFortuneToExcel(exportRef, "xlsx", false); + const exportedBuffer = Buffer.from(await exported.arrayBuffer()); + const exportedFile = new File([exportedBuffer], "roundtrip.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + + let importedSheets; + await transformExcelToFortune( + exportedFile, + (sheets) => { + importedSheets = sheets; + }, + () => {}, + { current: {} } + ); + + assert.equal(importedSheets.length, 1); + assert.equal(importedSheets[0].images.length, 1); + const [image] = importedSheets[0].images; + assert.equal(image.src, imageSource); + assert.equal(image.type, "2"); + assert.equal(image.fromCol, 1); + assert.equal(image.fromRow, 1); + assert.equal(image.width, 120); + assert.equal(image.height, 60); +}); + +test("an absolute-anchor image is imported with its pixel bounds", async () => { + const imageSource = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Absolute"); + const imageId = workbook.addImage({ + base64: imageSource, + extension: "gif", + }); + worksheet.addImage(imageId, { + tl: { col: 0, row: 0 }, + ext: { width: 120, height: 60 }, + }); + + const zip = await JSZip.loadAsync(await workbook.xlsx.writeBuffer()); + const drawingPath = "xl/drawings/drawing1.xml"; + const oneCellDrawing = await zip.file(drawingPath).async("string"); + const absoluteDrawing = oneCellDrawing + .replace(/]*>/, "") + .replace( + /[\s\S]*?<\/xdr:from>/, + '' + ) + .replace("", ""); + zip.file(drawingPath, absoluteDrawing); + + const file = new File( + [await zip.generateAsync({ type: "nodebuffer" })], + "absolute-anchor.xlsx", + { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + ); + + let importedSheets; + await transformExcelToFortune( + file, + (sheets) => { + importedSheets = sheets; + }, + () => {}, + { current: {} } + ); + + const [image] = importedSheets[0].images; + assert.equal(image.type, "3"); + assert.equal(image.left, 96); + assert.equal(image.top, 48); + assert.equal(image.width, 120); + assert.equal(image.height, 60); +}); + test("converted xls_preview.xlsx sheets can be mounted in Workbook", async () => { const { setSheetsCalls } = await loadFixtureIntoFortune(); const [sheets] = setSheetsCalls; From 1a231ef6f01112347a8d6efdfae82a78d766186c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=87=E9=94=8B?= Date: Tue, 4 Aug 2026 16:30:39 +0800 Subject: [PATCH 2/2] fix image import bug and add test --- src/ToExcel/ExcelImage.ts | 74 ++++-- src/ToExcel/ExcelStyle.ts | 19 +- src/ToFortuneSheet/FortuneFile.ts | 3 +- ...ransformExcelToFortune.xls_preview.test.js | 213 +++++++++++++++++- 4 files changed, 287 insertions(+), 22 deletions(-) diff --git a/src/ToExcel/ExcelImage.ts b/src/ToExcel/ExcelImage.ts index 8fc08c9..8436470 100644 --- a/src/ToExcel/ExcelImage.ts +++ b/src/ToExcel/ExcelImage.ts @@ -1,17 +1,24 @@ type ImageExtension = "png" | "jpeg" | "gif"; +type AnchorAxis = { index: number; offset: number }; -// Convert a pixel offset to ExcelJS's zero-based fractional cell coordinate. -const getImagePosition = function (num: number, positions: number[]) { +const EMU_PER_PIXEL_AT_96_DPI = 9525; + +// Use native OOXML offsets. ExcelJS's fractional col/row API derives offsets +// from character/point units and loses pixel accuracy for partial cells. +const getAnchorAxis = function (num: number, positions: number[]): AnchorAxis { + const pixel = Math.max(0, num || 0); if (positions.length === 0) { - return 0; + return { index: 0, offset: pixel }; } let previous = 0; for (let index = 0; index < positions.length; index++) { const current = positions[index]; - if (num <= current) { - const size = current - previous; - return index + (size > 0 ? (num - previous) / size : 0); + if (pixel < current) { + return { index, offset: pixel - previous }; + } + if (pixel === current) { + return { index: index + 1, offset: 0 }; } previous = current; } @@ -21,7 +28,32 @@ const getImagePosition = function (num: number, positions: number[]) { lastIndex > 0 ? positions[lastIndex] - positions[lastIndex - 1] : positions[0]; - return positions.length + (lastSize > 0 ? (num - previous) / lastSize : 0); + if (lastSize <= 0) { + return { index: positions.length, offset: pixel - previous }; + } + + const remaining = pixel - previous; + const additionalCells = Math.floor(remaining / lastSize); + return { + index: positions.length + additionalCells, + offset: remaining - additionalCells * lastSize, + }; +}; + +const getImageAnchor = function ( + left: number, + top: number, + columnPositions: number[], + rowPositions: number[] +) { + const column = getAnchorAxis(left, columnPositions); + const row = getAnchorAxis(top, rowPositions); + return { + nativeCol: column.index, + nativeColOff: Math.round(column.offset * EMU_PER_PIXEL_AT_96_DPI), + nativeRow: row.index, + nativeRowOff: Math.round(row.offset * EMU_PER_PIXEL_AT_96_DPI), + }; }; const getImageExtension = function (src: string): ImageExtension { @@ -67,7 +99,8 @@ var setImages = function (table: any, worksheet: any, workbook: any) { let lastVal = 0; for (let i = 0; i < rowCount; i++) { const rowHeight = localTable.config?.rowlen?.[i] || defaultRowHeight; - const rowPosition = lastVal + rowHeight; + const isHidden = localTable.config?.rowhidden?.[i] != null; + const rowPosition = lastVal + (isHidden ? 0 : rowHeight + 1); visibledatarow.push(rowPosition); lastVal = rowPosition; @@ -76,29 +109,36 @@ var setImages = function (table: any, worksheet: any, workbook: any) { lastVal = 0; for (let i = 0; i < colCount; i++) { const colWidth = localTable.config?.columnlen?.[i] || defaultColWidth; - const colPosition = lastVal + colWidth; + const isHidden = localTable.config?.colhidden?.[i] != null; + const colPosition = lastVal + (isHidden ? 0 : colWidth + 1); visibledatacolumn.push(colPosition); lastVal = colPosition; } } - const col_st = getImagePosition(item.left, visibledatacolumn); - const row_st = getImagePosition(item.top, visibledatarow); + const topLeft = getImageAnchor( + item.left, + item.top, + visibledatacolumn, + visibledatarow + ); // Preserve FortuneSheet's image movement/resize mode in the OOXML anchor. if (item.type === "1") { worksheet.addImage(imageId, { - tl: { col: col_st, row: row_st }, - br: { - col: getImagePosition(item.left + item.width, visibledatacolumn), - row: getImagePosition(item.top + item.height, visibledatarow), - }, + tl: topLeft, + br: getImageAnchor( + item.left + item.width, + item.top + item.height, + visibledatacolumn, + visibledatarow + ), editAs: "twoCell", }); } else { worksheet.addImage(imageId, { - tl: { col: col_st, row: row_st }, + tl: topLeft, ext: { width: item.width, height: item.height }, editAs: item.type === "3" ? "absolute" : "oneCell", }); diff --git a/src/ToExcel/ExcelStyle.ts b/src/ToExcel/ExcelStyle.ts index 50b1218..2168a65 100644 --- a/src/ToExcel/ExcelStyle.ts +++ b/src/ToExcel/ExcelStyle.ts @@ -11,17 +11,32 @@ const formatHyperlink = (address: string) => { return `#\'${sheetCell[0]}\'!${sheetCell[1] || "A1"}`; }; +// Inverse of the pixel conversions used by the XLSX importer. +const columnPixelsToExcelWidth = (pixels: number) => (pixels - 5) / 8 + 0.83; +const rowPixelsToPoints = (pixels: number) => pixels * (72 / 96); + var setStyleAndValue = function (table: any, worksheet: ExcelJS.Worksheet) { const cellArr = table?.data; if (!Array.isArray(cellArr)) return; + worksheet.properties.defaultColWidth = columnPixelsToExcelWidth( + table.defaultColWidth ?? 73 + ); + worksheet.properties.defaultRowHeight = rowPixelsToPoints( + table.defaultRowHeight ?? 19 + ); + cellArr.forEach(function (row, rowid) { const dbrow = worksheet.getRow(rowid + 1); - dbrow.height = (table.config?.rowlen?.[rowid] || 19) / 1.2; + const rowHeight = + table.config?.rowlen?.[rowid] ?? table.defaultRowHeight ?? 19; + dbrow.height = rowPixelsToPoints(rowHeight); row.every(function (cell: any, columnid: any) { if (rowid == 0) { const dobCol = worksheet.getColumn(columnid + 1); - dobCol.width = (table.config?.columnlen?.[columnid] || 73) / 8; + const columnWidth = + table.config?.columnlen?.[columnid] ?? table.defaultColWidth ?? 73; + dobCol.width = columnPixelsToExcelWidth(columnWidth); } if (!cell) return true; let fill = fillConvert(cell.bg); diff --git a/src/ToFortuneSheet/FortuneFile.ts b/src/ToFortuneSheet/FortuneFile.ts index 1e4b16e..c92132d 100644 --- a/src/ToFortuneSheet/FortuneFile.ts +++ b/src/ToFortuneSheet/FortuneFile.ts @@ -278,7 +278,8 @@ export class FortuneFile { let gap = def, istring = i.toString(); if (istring in hidden) { - gap = 0; + sets.push(allGap); + continue; } else if (istring in lens) { gap = lens[istring]; } diff --git a/test/transformExcelToFortune.xls_preview.test.js b/test/transformExcelToFortune.xls_preview.test.js index 2f122a5..e9ce23c 100644 --- a/test/transformExcelToFortune.xls_preview.test.js +++ b/test/transformExcelToFortune.xls_preview.test.js @@ -191,8 +191,8 @@ test("an exported image can be imported again", async () => { { id: "roundtrip-image", src: imageSource, - left: 73, - top: 19, + left: 74, + top: 20, width: 120, height: 60, originWidth: 120, @@ -233,6 +233,215 @@ test("an exported image can be imported again", async () => { assert.equal(image.height, 60); }); +test("PNG, JPEG, and GIF images preserve their anchors and bounds", async () => { + const imageSources = { + png: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + jpeg: + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/EH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/EH//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/EH//2Q==", + gif: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", + }; + const expectedImages = [ + { + id: "two-cell-png", + src: imageSources.png, + type: "1", + left: 10, + top: 8, + width: 85, + height: 35, + }, + { + id: "one-cell-jpeg", + src: imageSources.jpeg, + type: "2", + left: 146, + top: 38, + width: 64, + height: 48, + }, + { + id: "absolute-gif", + src: imageSources.gif, + type: "3", + left: 230, + top: 80, + width: 96, + height: 72, + }, + ]; + const sourceSheet = { + id: "image-matrix-sheet", + name: "Image matrix", + data: Array.from({ length: 12 }, () => Array(12).fill(null)), + config: {}, + defaultColWidth: 73, + defaultRowHeight: 19, + images: expectedImages, + }; + + const exported = await transformFortuneToExcel( + { current: { getAllSheets: () => [sourceSheet] } }, + "xlsx", + false + ); + const exportedBuffer = Buffer.from(await exported.arrayBuffer()); + const zip = await JSZip.loadAsync(exportedBuffer); + const drawing = await zip.file("xl/drawings/drawing1.xml").async("string"); + + assert.match(drawing, //); + assert.match(drawing, //); + assert.match(drawing, //); + assert.ok(zip.file("xl/media/image1.png")); + assert.ok(zip.file("xl/media/image2.jpeg")); + assert.ok(zip.file("xl/media/image3.gif")); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(exportedBuffer); + assert.equal(workbook.worksheets[0].getImages().length, 3); + + const exportedFile = new File([exportedBuffer], "image-matrix.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + let importedSheets; + await transformExcelToFortune( + exportedFile, + (sheets) => { + importedSheets = sheets; + }, + () => {}, + { current: {} } + ); + + assert.equal(importedSheets[0].images.length, 3); + for (const expected of expectedImages) { + const actual = importedSheets[0].images.find((image) => + image.src.startsWith(expected.src.slice(0, expected.src.indexOf(",") + 1)) + ); + assert.ok(actual, `missing ${expected.type} image`); + assert.equal(actual.src, expected.src); + assert.equal(actual.type, expected.type); + const tolerance = 0.01; + assert.ok( + Math.abs(actual.left - expected.left) < tolerance, + `left: expected ${expected.left}, received ${actual.left}` + ); + assert.ok( + Math.abs(actual.top - expected.top) < tolerance, + `top: expected ${expected.top}, received ${actual.top}` + ); + assert.ok( + Math.abs(actual.width - expected.width) < tolerance, + `width: expected ${expected.width}, received ${actual.width}` + ); + assert.ok( + Math.abs(actual.height - expected.height) < tolerance, + `height: expected ${expected.height}, received ${actual.height}` + ); + } +}); + +test("image bounds survive custom and hidden rows and columns", async () => { + const imageSource = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + const expected = { + id: "custom-dimensions-image", + src: imageSource, + type: "1", + left: 90, + top: 25, + width: 40, + height: 25, + }; + const sourceSheet = { + id: "custom-dimensions-sheet", + name: "Custom dimensions", + data: Array.from({ length: 6 }, () => Array(6).fill(null)), + config: { + columnlen: { 0: 100, 1: 50 }, + colhidden: { 1: 0 }, + rowlen: { 0: 30, 1: 40 }, + rowhidden: { 1: 0 }, + }, + defaultColWidth: 73, + defaultRowHeight: 19, + images: [expected], + }; + + const exported = await transformFortuneToExcel( + { current: { getAllSheets: () => [sourceSheet] } }, + "xlsx", + false + ); + const buffer = Buffer.from(await exported.arrayBuffer()); + const file = new File([buffer], "custom-dimensions.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + let importedSheets; + await transformExcelToFortune( + file, + (sheets) => { + importedSheets = sheets; + }, + () => {}, + { current: {} } + ); + + const [actual] = importedSheets[0].images; + assert.equal(actual.type, expected.type); + assert.equal(actual.left, expected.left); + assert.equal(actual.top, expected.top); + assert.equal(actual.width, expected.width); + assert.equal(actual.height, expected.height); +}); + +test("an image outside the populated grid preserves its bounds", async () => { + const imageSource = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + const expected = { + id: "outside-grid-image", + src: imageSource, + type: "2", + left: 600, + top: 160, + width: 50, + height: 30, + }; + const sourceSheet = { + id: "outside-grid-sheet", + name: "Outside grid", + data: Array.from({ length: 2 }, () => Array(2).fill(null)), + config: {}, + defaultColWidth: 73, + defaultRowHeight: 19, + images: [expected], + }; + + const exported = await transformFortuneToExcel( + { current: { getAllSheets: () => [sourceSheet] } }, + "xlsx", + false + ); + const buffer = Buffer.from(await exported.arrayBuffer()); + const file = new File([buffer], "outside-grid.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + let importedSheets; + await transformExcelToFortune( + file, + (sheets) => { + importedSheets = sheets; + }, + () => {}, + { current: {} } + ); + + const [actual] = importedSheets[0].images; + assert.equal(actual.left, expected.left); + assert.equal(actual.top, expected.top); + assert.equal(actual.width, expected.width); + assert.equal(actual.height, expected.height); +}); + test("an absolute-anchor image is imported with its pixel bounds", async () => { const imageSource = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==";