Support XLSX 1904 date imports

This commit is contained in:
Курнат Андрей
2026-06-02 19:42:51 +03:00
parent 6251c70ddc
commit 231bb6c076
2 changed files with 79 additions and 6 deletions
+33
View File
@@ -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",
`<workbook><workbookPr date1904="1"/><sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`
);
zip.file(
"xl/_rels/workbook.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`
);
zip.file("xl/styles.xml", `<styleSheet><cellXfs count="3"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="20"/></cellXfs></styleSheet>`);
zip.file(
"xl/worksheets/sheet1.xml",
`<worksheet><sheetData><row r="1"><c r="A1" s="1"><v>43465</v></c><c r="B1"><v>5</v></c><c r="C1" s="2"><v>0.5</v></c></row></sheetData></worksheet>`
);
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",
+46 -6
View File
@@ -226,6 +226,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
const themeColors = await readOptionalWorkbookThemeColors(zip, parser);
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
const sheetRefs = workbookSheetRefs(workbookXml);
const date1904 = workbookUsesDate1904(workbookXml);
const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
const sheets = await Promise.all(
@@ -249,7 +250,7 @@ export async function importXlsxFile(file: File): Promise<ImportedExcellWorkbook
const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined;
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml);
const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable, themeColors, commentsXml, date1904);
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
})
@@ -358,7 +359,8 @@ export function parsedWorksheetToSheet(
relationshipsXml?: unknown,
styleTable: Array<CellFormat | undefined> = [],
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<CellFormat | undefined> = [], date1904 = false) {
const cells: Record<string, string> = {};
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<string, string> {
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) => {