diff --git a/src/ToExcel/ExcelImage.ts b/src/ToExcel/ExcelImage.ts index fa7757d..8436470 100644 --- a/src/ToExcel/ExcelImage.ts +++ b/src/ToExcel/ExcelImage.ts @@ -1,30 +1,71 @@ -//获取图片在单元格的位置 -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"; +type AnchorAxis = { index: number; offset: 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 { index: 0, offset: pixel }; + } + + let previous = 0; + for (let index = 0; index < positions.length; index++) { + const current = positions[index]; + if (pixel < current) { + return { index, offset: pixel - previous }; + } + if (pixel === current) { + return { index: index + 1, offset: 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]; + 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 { + 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,55 +83,66 @@ 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 { 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 9f9550c..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]; } @@ -293,6 +294,9 @@ export class FortuneFile { let images = sheet.images, defaultColWidth = sheet.defaultColWidth, defaultRowHeight = sheet.defaultRowHeight; + if (images == null) { + return; + } let colhidden = {}; if (sheet.config.colhidden) { colhidden = sheet.config.colhidden; @@ -330,6 +334,11 @@ export class FortuneFile { let cx_n = 0, cy_n = 0; + // Absolute anchors already contain their pixel position and size. + if (fromCol == null || fromRow == null) { + continue; + } + if (fromCol >= this.columnWidthSet.length) { this.extendArray( fromCol, @@ -362,47 +371,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: 74, + top: 20, + 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("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=="; + 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;