Skip to content

Commit 60202b3

Browse files
phodalcodex
andcommitted
fix(office): support xlsx date formulas
Co-authored-by: Codex (GPT 5.5) <codex@openai.com>
1 parent ae47941 commit 60202b3

3 files changed

Lines changed: 191 additions & 2 deletions

File tree

docs/issues/2026-05-01-office-document-viewer-wasm-reader.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Routa 应能在 session canvas 或 artifact tab 中直接预览 Office 文档(
133133
- XLSX conditional formula evaluation now supports multi-condition aggregate helpers `AVERAGEIFS`, `MINIFS`, and `MAXIFS` on sparse ranges.
134134
- XLSX conditional formula evaluation now supports lookup helpers `INDEX`, `MATCH`, `VLOOKUP`, and `XLOOKUP` for threshold-table-driven formatting rules.
135135
- XLSX conditional formula evaluation now supports statistical/ranking helpers `COUNTA`, `COUNTBLANK`, `MEDIAN`, `LARGE`, `SMALL`, `RANK`, `RANK.EQ`, `PERCENTILE`, and `PERCENTILE.INC`.
136+
- XLSX conditional formula evaluation now supports date boundary and working-day helpers `EDATE`, `EOMONTH`, `NETWORKDAYS`, `WORKDAY`, and `DATEDIF`.
136137

137138
## Codex 技术方案逆向分析
138139

src/app/debug/office-wasm-poc/__tests__/spreadsheet-conditional-visuals.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,84 @@ describe("spreadsheet conditional visuals", () => {
12521252
expect(visuals.get("4:3")?.background).toBe("#BBF7D0");
12531253
});
12541254

1255+
it("evaluates date boundary and working-day helpers in conditional format formulas", () => {
1256+
const visuals = buildSpreadsheetConditionalVisuals({
1257+
conditionalFormattings: [
1258+
{
1259+
ranges: ["A2:A5"],
1260+
rules: [
1261+
{
1262+
fillColor: "BAE6FD",
1263+
formulas: ["=EOMONTH(A2,0)=DATE(2026,5,31)"],
1264+
type: "expression",
1265+
},
1266+
],
1267+
},
1268+
{
1269+
ranges: ["B2:B5"],
1270+
rules: [
1271+
{
1272+
fillColor: "FDE68A",
1273+
formulas: ["=AND(MONTH(EDATE(B2,1))=6,DAY(EDATE(B2,1))=4)"],
1274+
type: "expression",
1275+
},
1276+
],
1277+
},
1278+
{
1279+
ranges: ["C2:C5"],
1280+
rules: [
1281+
{
1282+
fillColor: "DDD6FE",
1283+
formulas: ["=NETWORKDAYS(C2,D2,$E$2:$E$2)<=4"],
1284+
type: "expression",
1285+
},
1286+
],
1287+
},
1288+
{
1289+
ranges: ["F2:F5"],
1290+
rules: [
1291+
{
1292+
fillColor: "BBF7D0",
1293+
formulas: ["=WORKDAY(F2,5,$E$2:$E$2)=DATE(2026,5,12)"],
1294+
type: "expression",
1295+
},
1296+
],
1297+
},
1298+
{
1299+
ranges: ["G2:G5"],
1300+
rules: [
1301+
{
1302+
fillColor: "FECACA",
1303+
formulas: ["=DATEDIF(G2,H2,\"D\")>5"],
1304+
type: "expression",
1305+
},
1306+
],
1307+
},
1308+
],
1309+
rows: [
1310+
{ cells: [{ address: "A2", value: 46146 }, { address: "B2", value: 46146 }, { address: "C2", value: 46146 }, { address: "D2", value: 46150 }, { address: "E2", value: 46148 }, { address: "F2", value: 46146 }, { address: "G2", value: 46146 }, { address: "H2", value: 46152 }], index: 2 },
1311+
{ cells: [{ address: "A3", value: 46177 }, { address: "B3", value: 46177 }, { address: "C3", value: 46146 }, { address: "D3", value: 46153 }, { address: "F3", value: 46147 }, { address: "G3", value: 46146 }, { address: "H3", value: 46150 }], index: 3 },
1312+
{ cells: [{ address: "A4", value: 45778 }, { address: "B4", value: 45778 }, { address: "C4", value: 46149 }, { address: "D4", value: 46150 }, { address: "F4", value: 46146 }, { address: "G4", value: 46146 }, { address: "H4", value: 46151 }], index: 4 },
1313+
],
1314+
});
1315+
1316+
expect(visuals.get("2:0")?.background).toBe("#BAE6FD");
1317+
expect(visuals.get("3:0")).toBeUndefined();
1318+
expect(visuals.get("4:0")).toBeUndefined();
1319+
expect(visuals.get("2:1")?.background).toBe("#FDE68A");
1320+
expect(visuals.get("3:1")).toBeUndefined();
1321+
expect(visuals.get("4:1")).toBeUndefined();
1322+
expect(visuals.get("2:2")?.background).toBe("#DDD6FE");
1323+
expect(visuals.get("3:2")).toBeUndefined();
1324+
expect(visuals.get("4:2")?.background).toBe("#DDD6FE");
1325+
expect(visuals.get("2:5")?.background).toBe("#BBF7D0");
1326+
expect(visuals.get("3:5")).toBeUndefined();
1327+
expect(visuals.get("4:5")?.background).toBe("#BBF7D0");
1328+
expect(visuals.get("2:6")?.background).toBe("#FECACA");
1329+
expect(visuals.get("3:6")).toBeUndefined();
1330+
expect(visuals.get("4:6")).toBeUndefined();
1331+
});
1332+
12551333
it("evaluates arithmetic and rounding helpers in conditional format formulas", () => {
12561334
const visuals = buildSpreadsheetConditionalVisuals({
12571335
conditionalFormattings: [

src/app/debug/office-wasm-poc/spreadsheet-conditional-formula.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,39 @@ function evaluateFormulaFunction(name: string, args: string[], context: Conditio
312312
}
313313
return Number.NaN;
314314
}
315+
if (normalizedName === "EDATE") {
316+
return addExcelSerialMonths(
317+
Number(evaluateFormulaValue(args[0] ?? "", context)),
318+
Number(evaluateFormulaValue(args[1] ?? "0", context)),
319+
);
320+
}
321+
if (normalizedName === "EOMONTH") {
322+
return endOfExcelSerialMonth(
323+
Number(evaluateFormulaValue(args[0] ?? "", context)),
324+
Number(evaluateFormulaValue(args[1] ?? "0", context)),
325+
);
326+
}
327+
if (normalizedName === "NETWORKDAYS") {
328+
return networkDaysFormulaNumber(
329+
Number(evaluateFormulaValue(args[0] ?? "", context)),
330+
Number(evaluateFormulaValue(args[1] ?? "", context)),
331+
formulaNumericArgs([args[2] ?? ""], context),
332+
);
333+
}
334+
if (normalizedName === "WORKDAY") {
335+
return workdayFormulaNumber(
336+
Number(evaluateFormulaValue(args[0] ?? "", context)),
337+
Number(evaluateFormulaValue(args[1] ?? "", context)),
338+
formulaNumericArgs([args[2] ?? ""], context),
339+
);
340+
}
341+
if (normalizedName === "DATEDIF") {
342+
return datedifFormulaNumber(
343+
Number(evaluateFormulaValue(args[0] ?? "", context)),
344+
Number(evaluateFormulaValue(args[1] ?? "", context)),
345+
asString(evaluateFormulaValue(args[2] ?? "\"D\"", context)),
346+
);
347+
}
315348
if (normalizedName === "YEAR" || normalizedName === "MONTH" || normalizedName === "DAY" || normalizedName === "WEEKDAY") {
316349
return excelSerialDatePart(Number(evaluateFormulaValue(args[0] ?? "", context)), normalizedName, Number(evaluateFormulaValue(args[1] ?? "1", context)));
317350
}
@@ -879,9 +912,14 @@ function excelSerialDay(year: number, month: number, day: number): number {
879912
return Math.floor((Date.UTC(year, month - 1, day) - EXCEL_SERIAL_EPOCH_UTC) / DAY_MS);
880913
}
881914

915+
function excelSerialUtcDate(value: number): Date | null {
916+
if (!Number.isFinite(value)) return null;
917+
return new Date(EXCEL_SERIAL_EPOCH_UTC + Math.floor(value) * DAY_MS);
918+
}
919+
882920
function excelSerialDatePart(value: number, part: string, weekdayReturnType: number): number {
883-
if (!Number.isFinite(value)) return Number.NaN;
884-
const date = new Date(EXCEL_SERIAL_EPOCH_UTC + Math.floor(value) * DAY_MS);
921+
const date = excelSerialUtcDate(value);
922+
if (!date) return Number.NaN;
885923
if (part === "YEAR") return date.getUTCFullYear();
886924
if (part === "MONTH") return date.getUTCMonth() + 1;
887925
if (part === "DAY") return date.getUTCDate();
@@ -891,6 +929,78 @@ function excelSerialDatePart(value: number, part: string, weekdayReturnType: num
891929
return weekday + 1;
892930
}
893931

932+
function addExcelSerialMonths(value: number, months: number): number {
933+
const date = excelSerialUtcDate(value);
934+
if (!date || !Number.isFinite(months)) return Number.NaN;
935+
const targetMonthIndex = date.getUTCMonth() + Math.trunc(months);
936+
const targetYear = date.getUTCFullYear() + Math.floor(targetMonthIndex / 12);
937+
const targetMonth = modulo(targetMonthIndex, 12) + 1;
938+
const targetDay = Math.min(date.getUTCDate(), daysInUtcMonth(targetYear, targetMonth));
939+
return excelSerialDay(targetYear, targetMonth, targetDay);
940+
}
941+
942+
function endOfExcelSerialMonth(value: number, months: number): number {
943+
const date = excelSerialUtcDate(value);
944+
if (!date || !Number.isFinite(months)) return Number.NaN;
945+
const targetMonthIndex = date.getUTCMonth() + Math.trunc(months);
946+
const targetYear = date.getUTCFullYear() + Math.floor(targetMonthIndex / 12);
947+
const targetMonth = modulo(targetMonthIndex, 12) + 1;
948+
return excelSerialDay(targetYear, targetMonth, daysInUtcMonth(targetYear, targetMonth));
949+
}
950+
951+
function networkDaysFormulaNumber(startValue: number, endValue: number, holidays: number[]): number {
952+
if (!Number.isFinite(startValue) || !Number.isFinite(endValue)) return Number.NaN;
953+
const start = Math.floor(startValue);
954+
const end = Math.floor(endValue);
955+
const direction = start <= end ? 1 : -1;
956+
const holidaySet = new Set(holidays.map(Math.floor));
957+
let count = 0;
958+
for (let serial = start; direction > 0 ? serial <= end : serial >= end; serial += direction) {
959+
if (isExcelWorkingDay(serial, holidaySet)) count += direction;
960+
}
961+
return count;
962+
}
963+
964+
function workdayFormulaNumber(startValue: number, daysValue: number, holidays: number[]): number {
965+
if (!Number.isFinite(startValue) || !Number.isFinite(daysValue)) return Number.NaN;
966+
const direction = daysValue < 0 ? -1 : 1;
967+
let remaining = Math.abs(Math.trunc(daysValue));
968+
let serial = Math.floor(startValue);
969+
const holidaySet = new Set(holidays.map(Math.floor));
970+
while (remaining > 0) {
971+
serial += direction;
972+
if (isExcelWorkingDay(serial, holidaySet)) remaining -= 1;
973+
}
974+
return serial;
975+
}
976+
977+
function datedifFormulaNumber(startValue: number, endValue: number, unit: string): number {
978+
const start = excelSerialUtcDate(startValue);
979+
const end = excelSerialUtcDate(endValue);
980+
if (!start || !end || endValue < startValue) return Number.NaN;
981+
const normalizedUnit = unit.trim().toUpperCase();
982+
if (normalizedUnit === "D") return Math.floor(endValue) - Math.floor(startValue);
983+
984+
const years = end.getUTCFullYear() - start.getUTCFullYear();
985+
const monthDelta = years * 12 + end.getUTCMonth() - start.getUTCMonth() - (end.getUTCDate() < start.getUTCDate() ? 1 : 0);
986+
if (normalizedUnit === "M") return Math.max(0, monthDelta);
987+
if (normalizedUnit === "Y") return Math.max(0, Math.floor(monthDelta / 12));
988+
return Number.NaN;
989+
}
990+
991+
function isExcelWorkingDay(serial: number, holidays: ReadonlySet<number>): boolean {
992+
const weekday = excelSerialDatePart(serial, "WEEKDAY", 2);
993+
return weekday >= 1 && weekday <= 5 && !holidays.has(serial);
994+
}
995+
996+
function daysInUtcMonth(year: number, month: number): number {
997+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
998+
}
999+
1000+
function modulo(value: number, divisor: number): number {
1001+
return ((value % divisor) + divisor) % divisor;
1002+
}
1003+
8941004
function parseFunctionCall(value: string): { args: string[]; name: string } | null {
8951005
const nameMatch = value.match(/^([A-Z][A-Z0-9.]*)\(/i);
8961006
if (!nameMatch || !value.endsWith(")")) return null;

0 commit comments

Comments
 (0)