diff --git a/src/io/excellOffice.test.ts b/src/io/excellOffice.test.ts index 012e8d1..863b2e9 100644 --- a/src/io/excellOffice.test.ts +++ b/src/io/excellOffice.test.ts @@ -21,6 +21,7 @@ import { workbookPrintAreas, workbookRelationshipTargets, workbookSheetRefs, + workbookUsesDate1904, worksheetXmlToAutoFilter, worksheetXmlToCells, worksheetXmlToCellFormats, @@ -615,6 +616,38 @@ describe("excellOffice OOXML helpers", () => { ).toBe("sheet-7"); }); + it("detects the XLSX 1904 date system flag from workbook properties", () => { + expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "1" } } })).toBe(true); + expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "true" } } })).toBe(true); + expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "0" } } })).toBe(false); + }); + + it("converts imported XLSX 1904 date serials to the QExcell 1900 serial system", async () => { + const zip = new JSZip(); + zip.file( + "xl/workbook.xml", + `` + ); + zip.file( + "xl/_rels/workbook.xml.rels", + `` + ); + zip.file("xl/styles.xml", ``); + zip.file( + "xl/worksheets/sheet1.xml", + `4346550.5` + ); + + const blob = await zip.generateAsync({ + type: "blob", + mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }); + const imported = await importXlsxFile(new File([blob], "Date1904.xlsx", { type: blob.type })); + + expect(imported.sheets[0].cells).toEqual({ A1: "44927", B1: "5", C1: "0.5" }); + expect(imported.sheets[0].cellFormats).toEqual({ A1: { numberFormat: "m/d/yy" }, C1: { numberFormat: "h:mm" } }); + }); + it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => { const blob = await exportXlsxBlob({ id: "book-1", diff --git a/src/io/excellOffice.ts b/src/io/excellOffice.ts index 9814498..c306967 100644 --- a/src/io/excellOffice.ts +++ b/src/io/excellOffice.ts @@ -226,6 +226,7 @@ export async function importXlsxFile(file: File): Promise = [], themeColors: string[] = defaultXlsxThemeColors, - commentsXml?: unknown + commentsXml?: unknown, + date1904 = false ): ParsedSheet { const frozenPane = worksheetXmlToFrozenPane(worksheetXml); const autoFilter = worksheetXmlToAutoFilter(worksheetXml); @@ -383,7 +385,7 @@ export function parsedWorksheetToSheet( ...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}), ...(showGridLines === false ? { showGridLines } : {}), ...(zoomScale ? { zoomScale } : {}), - cells: worksheetXmlToCells(worksheetXml, sharedStrings), + cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904), mergedCells: worksheetXmlToMergedCells(worksheetXml), hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml), comments: worksheetCommentsXmlToComments(commentsXml), @@ -392,7 +394,7 @@ export function parsedWorksheetToSheet( }; } -export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) { +export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[], styleTable: Array = [], date1904 = false) { const cells: Record = {}; const sharedFormulas: SharedFormulaMap = {}; const parsedCells = worksheetCellRecords(worksheetXml); @@ -408,9 +410,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string parsedCells.forEach(({ address, cell }) => { const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address); + const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text; - if (text !== "") { - cells[address] = text; + if (importedText !== "") { + cells[address] = importedText; } }); @@ -1032,6 +1035,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id: return sheets[activeTab]?.id ?? ""; } +export function workbookUsesDate1904(workbookXml: unknown) { + const workbook = childRecord(workbookXml, "workbook"); + const workbookPr = childRecord(workbook, "workbookPr"); + return booleanAttribute(workbookPr.date1904 ?? workbookPr["@_date1904"]); +} + export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record { const workbook = childRecord(workbookXml, "workbook"); const definedNames = childRecord(workbook, "definedNames"); @@ -1789,6 +1798,37 @@ function normalizedNumberFormat(value?: string) { return raw.slice(0, 255); } +function date1904AdjustedCellText(cell: XmlRecord, text: string, format?: CellFormat) { + if (!isNumericXlsxCell(cell) || !isDateSerialXlsxNumberFormat(format?.numberFormat)) { + return text; + } + + const serial = Number(text); + if (!Number.isFinite(serial) || serial < 0) { + return text; + } + + return normalizedXlsxNumberText(serial + 1462); +} + +function isNumericXlsxCell(cell: XmlRecord) { + const type = typeof cell.t === "string" ? cell.t.trim() : ""; + return type === "" || type === "n"; +} + +function isDateSerialXlsxNumberFormat(value?: string) { + const pattern = normalizedNumberFormat(value) + .replace(/"[^"]*"/g, "") + .replace(/\\./g, "") + .replace(/\[[^\]]+]/g, "") + .toLowerCase(); + return /[dy]/.test(pattern) || (pattern.includes("m") && !/[hs]/.test(pattern)); +} + +function normalizedXlsxNumberText(value: number) { + return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(10))); +} + function xlsxNumberFormats(styleSheet: XmlRecord) { const formats = { ...builtinNumberFormats }; toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {