diff --git a/src/integrations/misc/__tests__/extract-text-from-xlsx.test.ts b/src/integrations/misc/__tests__/extract-text-from-xlsx.test.ts index b2f553d31c..ed0a31c528 100644 --- a/src/integrations/misc/__tests__/extract-text-from-xlsx.test.ts +++ b/src/integrations/misc/__tests__/extract-text-from-xlsx.test.ts @@ -214,6 +214,110 @@ describe("formatWorkbook", () => { expect(result).toContain("30") // Formula with result expect(result).toContain("[Formula: SUM(B1:B10)]") // Formula without result }) + + it("should read every cell of a filled-down formula", () => { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // Excel saves a formula filled down a column as one shared formula + worksheet.getCell("A1").value = { formula: "B1*2", result: 2 } + worksheet.getCell("A2").value = { sharedFormula: "A1", result: 4 } + worksheet.getCell("A3").value = { sharedFormula: "A1" } + + const result = formatWorkbook(workbook) + + expect(result).toBe("--- Sheet: Sheet1 ---\n2\n4\n[Formula: B3*2]") + }) + + it("should read a cached 0 or false formula result, not the formula", () => { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // ExcelJS keeps a falsy cached result in cell.result but leaves it out of cell.value + worksheet.getCell("A1").value = { formula: "B1*2", result: 0 } + worksheet.getCell("A2").value = { sharedFormula: "A1", result: 0 } + worksheet.getCell("A3").value = { formula: "B1>1", result: false } + + const result = formatWorkbook(workbook) + + expect(result).toBe("--- Sheet: Sheet1 ---\n0\n0\nfalse") + }) + + it("should read every cell of a merged formula through the top-left cell", () => { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // The other cells of a merged range return the top-left cell's value, but not its result + worksheet.getCell("A1").value = { formula: "C1*2", result: 4 } + worksheet.mergeCells("A1:B1") + worksheet.getCell("A2").value = { formula: "C2*2", result: 0 } + worksheet.mergeCells("A2:B2") + worksheet.getCell("A3").value = { formula: "C3+30", result: new Date("2024-10-30T00:00:00.000Z") } + worksheet.getCell("A3").numFmt = "yyyy-mm-dd" + worksheet.mergeCells("A3:B3") + + const result = formatWorkbook(workbook) + + expect(result).toBe("--- Sheet: Sheet1 ---\n4\t4\n0\t0\n2024-10-30\t2024-10-30") + }) + + it("should read formula errors and dates like plain values", () => { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + worksheet.getCell("A1").value = { formula: "1/0", result: { error: "#DIV/0!" } } + worksheet.getCell("B1").value = { formula: "A2+30", result: new Date("2024-10-30T00:00:00.000Z") } + worksheet.getCell("B1").numFmt = "yyyy-mm-dd" + + const result = formatWorkbook(workbook) + + expect(result).toBe("--- Sheet: Sheet1 ---\n[Error: #DIV/0!]\t2024-10-30") + }) + + it("should keep the time of dates and read times of day and durations", () => { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // ExcelJS reads a time of day or a duration as a Date counted from 1899-12-30 + const cells: [string, string, string][] = [ + ["2024-09-30T14:04:59.900Z", "yyyy-mm-dd h:mm", "2024-09-30 14:05:00"], + ["2024-09-30T09:30:00.000Z", "DD.MM.YYYY HH:MM", "2024-09-30 09:30:00"], + ["2024-05-01T00:00:00.000Z", "mmm yyyy", "2024-05-01"], + ["2024-09-30T00:00:00.000Z", "d-mmm", "2024-09-30"], + ["1899-12-30T06:00:00.000Z", "h:mm", "06:00:00"], + ["1899-12-30T12:00:00.000Z", "[$-x-systime]h:mm:ss AM/PM", "12:00:00"], + ["1899-12-30T18:00:00.000Z", 'h:mm" daily"', "18:00:00"], + ["1899-12-31T12:00:00.000Z", "[h]:mm:ss", "36:00:00"], + ["1899-12-31T12:00:30.000Z", "[h]:mm", "36:00"], + ["1899-12-31T01:00:30.000Z", "[m]", "1500"], + ["1899-12-31T01:00:30.000Z", "[mm]:ss", "1500:30"], + ["1899-12-30T01:30:00.000Z", "[ss]", "5400"], + ["2024-09-30T00:00:00.000Z", "mmmm", "2024-09-30"], + ["1899-12-30T18:00:00.000Z", "h:mm\\ \\d\\a\\i\\l\\y", "18:00:00"], + ] + cells.forEach(([date, numFmt], index) => { + const cell = worksheet.getRow(1).getCell(index + 1) + cell.value = new Date(date) + cell.numFmt = numFmt + }) + + const result = formatWorkbook(workbook) + + expect(result).toBe(`--- Sheet: Sheet1 ---\n${cells.map(([, , text]) => text).join("\t")}`) + }) + + it("should count durations from 1904-01-01 in a 1904 workbook", () => { + const workbook = new ExcelJS.Workbook() + workbook.properties.date1904 = true + const worksheet = workbook.addWorksheet("Sheet1") + + worksheet.getCell("A1").value = new Date("1904-01-02T12:00:00.000Z") + worksheet.getCell("A1").numFmt = "[h]:mm" + + const result = formatWorkbook(workbook) + + expect(result).toBe("--- Sheet: Sheet1 ---\n36:00") + }) }) describe("edge cases", () => { diff --git a/src/integrations/misc/extract-text-from-xlsx.ts b/src/integrations/misc/extract-text-from-xlsx.ts index 06d8bcee20..ca1444f454 100644 --- a/src/integrations/misc/extract-text-from-xlsx.ts +++ b/src/integrations/misc/extract-text-from-xlsx.ts @@ -11,12 +11,63 @@ declare module "exceljs" { const ROW_LIMIT = 50000 -function formatCellValue(cell: ExcelJS.Cell): string { - const value = cell.value +/** + * Excel stores a date or a time as a day count shown through the cell's number format, and + * ExcelJS hands it over as a UTC Date. Outside quoted text, escaped characters and [...] sections, + * a format with a day, a year, or a month with no hour or second beside it (mmm) shows a date. + * Any other format is a time of day or, with [h], [m] or [s], an elapsed duration. Those count + * from day 0 (1899-12-30, or 1904-01-01 in a 1904 workbook), so they must not read as a date. + */ +function formatDate(date: Date, cell: ExcelJS.Cell): string { + // Round to the second, as Excel shows it: a NOW() stamp of 14:04:59.9 reads 14:05:00 + const time = Math.round(date.getTime() / 1000) * 1000 + const iso = new Date(time).toISOString() + const numFmt = cell.numFmt ?? "" + const elapsed = /\[(h+|m+|s+)\]/i.exec(numFmt) + const tokens = numFmt.replace(/"[^"]*"|\\.|\[[^\]]*\]/g, "") + const isDate = !numFmt || /[dy]/i.test(tokens) || (/m/i.test(tokens) && !/[hs]/i.test(tokens)) + if (!elapsed && isDate) { + return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso.slice(0, 19).replace("T", " ") + } + if (!elapsed) { + return iso.slice(11, 19) + } + const epoch = cell.worksheet.workbook.properties.date1904 ? Date.UTC(1904, 0, 1) : Date.UTC(1899, 11, 30) + const seconds = (time - epoch) / 1000 + const pad = (n: number) => String(n).padStart(2, "0") + // Elapsed time counts in the unit of its bracket: [mm]:ss of 25 hours reads 1500:00. Seconds + // show only when the format has them: [h]:mm of 36 hours reads 36:00 + const unit = elapsed[1][0].toLowerCase() + const secondsShown = /s/i.test(tokens) ? `:${pad(seconds % 60)}` : "" + if (unit === "s") { + return String(seconds) + } + if (unit === "m") { + return `${Math.floor(seconds / 60)}${secondsShown}` + } + return `${Math.floor(seconds / 3600)}:${pad(Math.floor(seconds / 60) % 60)}${secondsShown}` +} + +function formatCellValue(mergedOrCell: ExcelJS.Cell): string { + // The other cells of a merged range return the top-left cell's value but not its formula + // result, so the whole range is read through that cell + const cell = mergedOrCell.type === ExcelJS.ValueType.Merge ? mergedOrCell.master : mergedOrCell + let value = cell.value if (value === null || value === undefined) { return "" } + // Handle formulas - read the calculated result like a plain value. The cells of a formula + // filled down or across hold { sharedFormula, result }; cell.formula gives their formula. + // The result comes from cell.result: ExcelJS leaves a cached 0 or false out of cell.value. + if (typeof value === "object" && ("formula" in value || "sharedFormula" in value)) { + const result = cell.result + if (result === undefined || result === null) { + return `[Formula: ${cell.formula}]` + } + value = result + } + // Handle error values (#DIV/0!, #N/A, etc.) if (typeof value === "object" && "error" in value) { return `[Error: ${value.error}]` @@ -24,7 +75,7 @@ function formatCellValue(cell: ExcelJS.Cell): string { // Handle dates - ExcelJS can parse them as Date objects if (value instanceof Date) { - return value.toISOString().split("T")[0] + return formatDate(value, cell) } // Handle rich text @@ -37,15 +88,6 @@ function formatCellValue(cell: ExcelJS.Cell): string { return `${value.text} (${value.hyperlink})` } - // Handle formulas - get the calculated result - if (typeof value === "object" && "formula" in value) { - if ("result" in value && value.result !== undefined && value.result !== null) { - return value.result.toString() - } else { - return `[Formula: ${value.formula}]` - } - } - return value.toString() }