Skip to content

Commit cece93f

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

3 files changed

Lines changed: 199 additions & 0 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
@@ -131,6 +131,7 @@ Routa 应能在 session canvas 或 artifact tab 中直接预览 Office 文档(
131131
- XLSX conditional formula evaluation now handles date-part and parity helpers used in production rules, including `YEAR`, `MONTH`, `DAY`, `WEEKDAY`, `ISODD`, and `ISEVEN`.
132132
- XLSX conditional formula evaluation now supports arithmetic expressions and numeric rounding helpers, including `+`, `-`, `*`, `/`, `^`, `ROUND`, `ROUNDUP`, `ROUNDDOWN`, `INT`, `FLOOR`, and `CEILING`.
133133
- XLSX conditional formula evaluation now supports multi-condition aggregate helpers `AVERAGEIFS`, `MINIFS`, and `MAXIFS` on sparse ranges.
134+
- XLSX conditional formula evaluation now supports lookup helpers `INDEX`, `MATCH`, `VLOOKUP`, and `XLOOKUP` for threshold-table-driven formatting rules.
134135

135136
## Codex 技术方案逆向分析
136137

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,118 @@ describe("spreadsheet conditional visuals", () => {
883883
expect(visuals.get("5:8")).toBeUndefined();
884884
});
885885

886+
it("evaluates lookup helpers in conditional format formulas", () => {
887+
const visuals = buildSpreadsheetConditionalVisuals({
888+
conditionalFormattings: [
889+
{
890+
ranges: ["A2:A5"],
891+
rules: [
892+
{
893+
fillColor: "FDE68A",
894+
formulas: ["=INDEX($D$2:$D$5,MATCH(B2,$C$2:$C$5,0))>10"],
895+
type: "expression",
896+
},
897+
],
898+
},
899+
{
900+
ranges: ["E2:E5"],
901+
rules: [
902+
{
903+
fillColor: "BAE6FD",
904+
formulas: ["=VLOOKUP(E2,$F$2:$G$5,2,FALSE)=\"High\""],
905+
type: "expression",
906+
},
907+
],
908+
},
909+
{
910+
ranges: ["H2:H5"],
911+
rules: [
912+
{
913+
fillColor: "C4B5FD",
914+
formulas: ["=XLOOKUP(H2,$I$2:$I$5,$J$2:$J$5,\"\")=\"Escalate\""],
915+
type: "expression",
916+
},
917+
],
918+
},
919+
],
920+
rows: [
921+
{
922+
cells: [
923+
{ address: "A2", value: "row-a" },
924+
{ address: "B2", value: "Task A" },
925+
{ address: "C2", value: "Task A" },
926+
{ address: "D2", value: 12 },
927+
{ address: "E2", value: "A" },
928+
{ address: "F2", value: "A" },
929+
{ address: "G2", value: "High" },
930+
{ address: "H2", value: "North" },
931+
{ address: "I2", value: "North" },
932+
{ address: "J2", value: "Escalate" },
933+
],
934+
index: 2,
935+
},
936+
{
937+
cells: [
938+
{ address: "A3", value: "row-b" },
939+
{ address: "B3", value: "Task B" },
940+
{ address: "C3", value: "Task B" },
941+
{ address: "D3", value: 8 },
942+
{ address: "E3", value: "B" },
943+
{ address: "F3", value: "B" },
944+
{ address: "G3", value: "Low" },
945+
{ address: "H3", value: "South" },
946+
{ address: "I3", value: "South" },
947+
{ address: "J3", value: "Monitor" },
948+
],
949+
index: 3,
950+
},
951+
{
952+
cells: [
953+
{ address: "A4", value: "row-c" },
954+
{ address: "B4", value: "Task C" },
955+
{ address: "C4", value: "Task C" },
956+
{ address: "D4", value: 15 },
957+
{ address: "E4", value: "C" },
958+
{ address: "F4", value: "C" },
959+
{ address: "G4", value: "High" },
960+
{ address: "H4", value: "East" },
961+
{ address: "I4", value: "East" },
962+
{ address: "J4", value: "Escalate" },
963+
],
964+
index: 4,
965+
},
966+
{
967+
cells: [
968+
{ address: "A5", value: "row-d" },
969+
{ address: "B5", value: "Task D" },
970+
{ address: "C5", value: "Task D" },
971+
{ address: "D5", value: 10 },
972+
{ address: "E5", value: "D" },
973+
{ address: "F5", value: "D" },
974+
{ address: "G5", value: "Low" },
975+
{ address: "H5", value: "West" },
976+
{ address: "I5", value: "West" },
977+
{ address: "J5", value: "Monitor" },
978+
],
979+
index: 5,
980+
},
981+
],
982+
});
983+
984+
expect(visuals.get("2:0")?.background).toBe("#FDE68A");
985+
expect(visuals.get("3:0")).toBeUndefined();
986+
expect(visuals.get("4:0")?.background).toBe("#FDE68A");
987+
expect(visuals.get("5:0")).toBeUndefined();
988+
expect(visuals.get("2:4")?.background).toBe("#BAE6FD");
989+
expect(visuals.get("3:4")).toBeUndefined();
990+
expect(visuals.get("4:4")?.background).toBe("#BAE6FD");
991+
expect(visuals.get("5:4")).toBeUndefined();
992+
expect(visuals.get("2:7")?.background).toBe("#C4B5FD");
993+
expect(visuals.get("3:7")).toBeUndefined();
994+
expect(visuals.get("4:7")?.background).toBe("#C4B5FD");
995+
expect(visuals.get("5:7")).toBeUndefined();
996+
});
997+
886998
it("evaluates common text helpers in conditional format formulas", () => {
887999
const visuals = buildSpreadsheetConditionalVisuals({
8881000
conditionalFormattings: [

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,18 @@ function evaluateFormulaFunction(name: string, args: string[], context: Conditio
259259
if (normalizedName === "MAXIFS") {
260260
return aggregateIfs(args, context, "max");
261261
}
262+
if (normalizedName === "INDEX") {
263+
return indexFormulaValue(args, context);
264+
}
265+
if (normalizedName === "MATCH") {
266+
return matchFormulaValue(args, context);
267+
}
268+
if (normalizedName === "VLOOKUP") {
269+
return vlookupFormulaValue(args, context);
270+
}
271+
if (normalizedName === "XLOOKUP") {
272+
return xlookupFormulaValue(args, context);
273+
}
262274
if (normalizedName === "TODAY") {
263275
return currentExcelSerialDay();
264276
}
@@ -490,6 +502,80 @@ function formulaRange(expression: string, context: ConditionalFormulaContext): R
490502
return parseCellRange(formulaRangeTarget(expression, context));
491503
}
492504

505+
function indexFormulaValue(args: string[], context: ConditionalFormulaContext): string {
506+
const range = formulaRange(args[0] ?? "", context);
507+
const cells = formulaRangeCells(args[0] ?? "", context);
508+
if (!range || !cells) return "";
509+
const rowOffset = Math.max(0, Math.trunc(Number(evaluateFormulaValue(args[1] ?? "1", context))) - 1);
510+
const columnOffset = Math.max(0, Math.trunc(Number(evaluateFormulaValue(args[2] ?? "1", context))) - 1);
511+
return cellsByOffset(cells, range).get(`${rowOffset}:${columnOffset}`) ?? "";
512+
}
513+
514+
function matchFormulaValue(args: string[], context: ConditionalFormulaContext): number {
515+
const cells = formulaRangeCells(args[1] ?? "", context);
516+
if (!cells) return Number.NaN;
517+
const lookupValue = evaluateFormulaValue(args[0] ?? "", context);
518+
const matchType = Number(evaluateFormulaValue(args[2] ?? "1", context));
519+
return matchLookupPosition(lookupValue, cells.map((cell) => cell.value), Number.isFinite(matchType) ? Math.trunc(matchType) : 1);
520+
}
521+
522+
function vlookupFormulaValue(args: string[], context: ConditionalFormulaContext): string {
523+
const range = formulaRange(args[1] ?? "", context);
524+
const cells = formulaRangeCells(args[1] ?? "", context);
525+
if (!range || !cells) return "";
526+
const columnOffset = Math.trunc(Number(evaluateFormulaValue(args[2] ?? "1", context))) - 1;
527+
if (!Number.isFinite(columnOffset) || columnOffset < 0 || columnOffset >= range.columnSpan) return "";
528+
const lookupValue = evaluateFormulaValue(args[0] ?? "", context);
529+
const exact = !valueToBoolean(evaluateFormulaValue(args[3] ?? "TRUE", context));
530+
const byOffset = cellsByOffset(cells, range);
531+
const firstColumn = cells
532+
.filter((cell) => cell.columnIndex === range.startColumn)
533+
.sort((left, right) => left.rowIndex - right.rowIndex);
534+
const rowPosition = exact
535+
? firstColumn.findIndex((cell) => formulaLookupValuesEqual(cell.value, lookupValue))
536+
: matchLookupPosition(lookupValue, firstColumn.map((cell) => cell.value), 1) - 1;
537+
return rowPosition >= 0 ? byOffset.get(`${rowPosition}:${columnOffset}`) ?? "" : "";
538+
}
539+
540+
function xlookupFormulaValue(args: string[], context: ConditionalFormulaContext): string {
541+
const lookupCells = formulaRangeCells(args[1] ?? "", context);
542+
const returnRange = formulaRange(args[2] ?? "", context);
543+
const returnCells = formulaRangeCells(args[2] ?? "", context);
544+
if (!lookupCells || !returnRange || !returnCells) return asString(evaluateFormulaValue(args[3] ?? "", context));
545+
const lookupValue = evaluateFormulaValue(args[0] ?? "", context);
546+
const matchIndex = lookupCells.findIndex((cell) => formulaLookupValuesEqual(cell.value, lookupValue));
547+
if (matchIndex < 0) return asString(evaluateFormulaValue(args[3] ?? "", context));
548+
return returnCells[matchIndex]?.value ?? cellsByOffset(returnCells, returnRange).get(`${matchIndex}:0`) ?? "";
549+
}
550+
551+
function matchLookupPosition(lookupValue: unknown, values: string[], matchType: number): number {
552+
if (matchType === 0) {
553+
const index = values.findIndex((value) => formulaLookupValuesEqual(value, lookupValue));
554+
return index >= 0 ? index + 1 : Number.NaN;
555+
}
556+
557+
let matchedIndex = -1;
558+
for (let index = 0; index < values.length; index += 1) {
559+
const comparison = formulaLookupCompare(values[index], lookupValue);
560+
if (matchType < 0 ? comparison >= 0 : comparison <= 0) matchedIndex = index;
561+
}
562+
return matchedIndex >= 0 ? matchedIndex + 1 : Number.NaN;
563+
}
564+
565+
function formulaLookupValuesEqual(left: unknown, right: unknown): boolean {
566+
const leftNumber = Number(left);
567+
const rightNumber = Number(right);
568+
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber === rightNumber;
569+
return asString(left).trim().toLowerCase() === asString(right).trim().toLowerCase();
570+
}
571+
572+
function formulaLookupCompare(left: unknown, right: unknown): number {
573+
const leftNumber = Number(left);
574+
const rightNumber = Number(right);
575+
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber - rightNumber;
576+
return asString(left).trim().toLowerCase().localeCompare(asString(right).trim().toLowerCase());
577+
}
578+
493579
function cellsByOffset(cells: FormulaCellValue[], range: NonNullable<ReturnType<typeof parseCellRange>>): Map<string, string> {
494580
const map = new Map<string, string>();
495581
for (const cell of cells) {

0 commit comments

Comments
 (0)