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, workbookPrintAreas,
workbookRelationshipTargets, workbookRelationshipTargets,
workbookSheetRefs, workbookSheetRefs,
workbookUsesDate1904,
worksheetXmlToAutoFilter, worksheetXmlToAutoFilter,
worksheetXmlToCells, worksheetXmlToCells,
worksheetXmlToCellFormats, worksheetXmlToCellFormats,
@@ -615,6 +616,38 @@ describe("excellOffice OOXML helpers", () => {
).toBe("sheet-7"); ).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 () => { it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
const blob = await exportXlsxBlob({ const blob = await exportXlsxBlob({
id: "book-1", 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 themeColors = await readOptionalWorkbookThemeColors(zip, parser);
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors); const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
const sheetRefs = workbookSheetRefs(workbookXml); const sheetRefs = workbookSheetRefs(workbookXml);
const date1904 = workbookUsesDate1904(workbookXml);
const printAreas = workbookPrintAreas(workbookXml, sheetRefs); const printAreas = workbookPrintAreas(workbookXml, sheetRefs);
const relationshipTargets = workbookRelationshipTargets(relationshipsXml); const relationshipTargets = workbookRelationshipTargets(relationshipsXml);
const sheets = await Promise.all( 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 sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath);
const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath); const commentsPath = worksheetCommentsPath(sheetRelationshipsXml, sheetPath);
const commentsXml = commentsPath ? await readOptionalParsedXml(zip, parser, commentsPath) : undefined; 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); const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts }; return { ...parsedSheet, ...(printAreas[sheet.id] ? { printArea: printAreas[sheet.id] } : {}), charts };
}) })
@@ -358,7 +359,8 @@ export function parsedWorksheetToSheet(
relationshipsXml?: unknown, relationshipsXml?: unknown,
styleTable: Array<CellFormat | undefined> = [], styleTable: Array<CellFormat | undefined> = [],
themeColors: string[] = defaultXlsxThemeColors, themeColors: string[] = defaultXlsxThemeColors,
commentsXml?: unknown commentsXml?: unknown,
date1904 = false
): ParsedSheet { ): ParsedSheet {
const frozenPane = worksheetXmlToFrozenPane(worksheetXml); const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
const autoFilter = worksheetXmlToAutoFilter(worksheetXml); const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
@@ -383,7 +385,7 @@ export function parsedWorksheetToSheet(
...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}), ...(Object.keys(hiddenRows).length > 0 ? { hiddenRows } : {}),
...(showGridLines === false ? { showGridLines } : {}), ...(showGridLines === false ? { showGridLines } : {}),
...(zoomScale ? { zoomScale } : {}), ...(zoomScale ? { zoomScale } : {}),
cells: worksheetXmlToCells(worksheetXml, sharedStrings), cells: worksheetXmlToCells(worksheetXml, sharedStrings, styleTable, date1904),
mergedCells: worksheetXmlToMergedCells(worksheetXml), mergedCells: worksheetXmlToMergedCells(worksheetXml),
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml), hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
comments: worksheetCommentsXmlToComments(commentsXml), 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 cells: Record<string, string> = {};
const sharedFormulas: SharedFormulaMap = {}; const sharedFormulas: SharedFormulaMap = {};
const parsedCells = worksheetCellRecords(worksheetXml); const parsedCells = worksheetCellRecords(worksheetXml);
@@ -408,9 +410,10 @@ export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string
parsedCells.forEach(({ address, cell }) => { parsedCells.forEach(({ address, cell }) => {
const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address); const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address);
const importedText = date1904 ? date1904AdjustedCellText(cell, text, styleTable[numberAttribute(cell.s)]) : text;
if (text !== "") { if (importedText !== "") {
cells[address] = text; cells[address] = importedText;
} }
}); });
@@ -1032,6 +1035,12 @@ export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id:
return sheets[activeTab]?.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> { export function workbookPrintAreas(workbookXml: unknown, sheets: Array<{ id: string; name: string }>): Record<string, string> {
const workbook = childRecord(workbookXml, "workbook"); const workbook = childRecord(workbookXml, "workbook");
const definedNames = childRecord(workbook, "definedNames"); const definedNames = childRecord(workbook, "definedNames");
@@ -1789,6 +1798,37 @@ function normalizedNumberFormat(value?: string) {
return raw.slice(0, 255); 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) { function xlsxNumberFormats(styleSheet: XmlRecord) {
const formats = { ...builtinNumberFormats }; const formats = { ...builtinNumberFormats };
toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => { toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => {