Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/integrations/misc/__tests__/extract-text-from-xlsx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
66 changes: 54 additions & 12 deletions src/integrations/misc/extract-text-from-xlsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,71 @@

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 ?? ""

Check warning on line 25 in src/integrations/misc/extract-text-from-xlsx.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/integrations/misc/extract-text-from-xlsx.ts:25: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
const elapsed = /\[(h+|m+|s+)\]/i.exec(numFmt)

Check warning on line 26 in src/integrations/misc/extract-text-from-xlsx.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/integrations/misc/extract-text-from-xlsx.ts:26: Survived Regex mutant (replacement: /\[(h|m+|s+)\]/i). See the job summary for the complete list and resolution guidance.
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}`

Check warning on line 48 in src/integrations/misc/extract-text-from-xlsx.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/integrations/misc/extract-text-from-xlsx.ts:48: Survived ArithmeticOperator mutant (replacement: seconds * 60). See the job summary for the complete list and resolution guidance.
}

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

Check warning on line 54 in src/integrations/misc/extract-text-from-xlsx.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/integrations/misc/extract-text-from-xlsx.ts:54: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
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) {

Check warning on line 65 in src/integrations/misc/extract-text-from-xlsx.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/integrations/misc/extract-text-from-xlsx.ts:65: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return `[Formula: ${cell.formula}]`
}
Comment on lines +63 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a cell inside a merged range, ExcelJS returns the master's value object, so "formula" in value is true. But cell.result and cell.formula look undefined on a merged cell. Would this print [Formula: undefined] for the non-master cells of a merged formula? main read value.result there. Reading from cell.master when cell.type === ExcelJS.ValueType.Merge might cover it, plus a merged-cell test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it did. The new test reproduces it on the previous commit: every cell of a merged formula but the first printed [Formula: undefined]. Fixed in 3799c56. formatCellValue now reads a merged range through cell.master when cell.type === ExcelJS.ValueType.Merge, so each cell of the range shows the top-left result, as main did through value.result. The test covers merged formulas with a number, a cached 0 and a date result.

value = result
}

// Handle error values (#DIV/0!, #N/A, etc.)
if (typeof value === "object" && "error" in value) {
return `[Error: ${value.error}]`
}

// 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
Expand All @@ -37,15 +88,6 @@
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()
}

Expand Down
Loading