Skip to content

Commit ae47941

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

2 files changed

Lines changed: 215 additions & 0 deletions

File tree

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,131 @@ describe("spreadsheet conditional visuals", () => {
995995
expect(visuals.get("5:7")).toBeUndefined();
996996
});
997997

998+
it("evaluates ranking and percentile helpers in conditional format formulas", () => {
999+
const visuals = buildSpreadsheetConditionalVisuals({
1000+
conditionalFormattings: [
1001+
{
1002+
ranges: ["A2:A6"],
1003+
rules: [
1004+
{
1005+
fillColor: "FDE68A",
1006+
formulas: ["=RANK.EQ(A2,$A$2:$A$6)<=2"],
1007+
type: "expression",
1008+
},
1009+
],
1010+
},
1011+
{
1012+
ranges: ["B2:B6"],
1013+
rules: [
1014+
{
1015+
fillColor: "BAE6FD",
1016+
formulas: ["=B2>=PERCENTILE.INC($B$2:$B$6,0.75)"],
1017+
type: "expression",
1018+
},
1019+
],
1020+
},
1021+
{
1022+
ranges: ["C2:C6"],
1023+
rules: [
1024+
{
1025+
fillColor: "C4B5FD",
1026+
formulas: ["=OR(C2=LARGE($C$2:$C$6,2),C2=SMALL($C$2:$C$6,1))"],
1027+
type: "expression",
1028+
},
1029+
],
1030+
},
1031+
{
1032+
ranges: ["D2:D6"],
1033+
rules: [
1034+
{
1035+
fillColor: "BBF7D0",
1036+
formulas: ["=D2>=MEDIAN($D$2:$D$6)"],
1037+
type: "expression",
1038+
},
1039+
],
1040+
},
1041+
{
1042+
ranges: ["F2:F6"],
1043+
rules: [
1044+
{
1045+
fillColor: "FECACA",
1046+
formulas: ["=AND(COUNTA($F$2:$F$6)=3,COUNTBLANK($F$2:$F$6)=2)"],
1047+
type: "expression",
1048+
},
1049+
],
1050+
},
1051+
],
1052+
rows: [
1053+
{
1054+
cells: [
1055+
{ address: "A2", value: 95 },
1056+
{ address: "B2", value: 95 },
1057+
{ address: "C2", value: 10 },
1058+
{ address: "D2", value: 1 },
1059+
{ address: "F2", value: "Ready" },
1060+
],
1061+
index: 2,
1062+
},
1063+
{
1064+
cells: [
1065+
{ address: "A3", value: 80 },
1066+
{ address: "B3", value: 80 },
1067+
{ address: "C3", value: 30 },
1068+
{ address: "D3", value: 3 },
1069+
{ address: "F3", value: "" },
1070+
],
1071+
index: 3,
1072+
},
1073+
{
1074+
cells: [
1075+
{ address: "A4", value: 90 },
1076+
{ address: "B4", value: 90 },
1077+
{ address: "C4", value: 20 },
1078+
{ address: "D4", value: 5 },
1079+
{ address: "F4", value: "Blocked" },
1080+
],
1081+
index: 4,
1082+
},
1083+
{
1084+
cells: [
1085+
{ address: "A5", value: 70 },
1086+
{ address: "B5", value: 70 },
1087+
{ address: "C5", value: 5 },
1088+
{ address: "D5", value: 7 },
1089+
],
1090+
index: 5,
1091+
},
1092+
{
1093+
cells: [
1094+
{ address: "A6", value: 60 },
1095+
{ address: "B6", value: 60 },
1096+
{ address: "C6", value: 40 },
1097+
{ address: "D6", value: 9 },
1098+
{ address: "F6", value: "Done" },
1099+
],
1100+
index: 6,
1101+
},
1102+
],
1103+
});
1104+
1105+
expect(visuals.get("2:0")?.background).toBe("#FDE68A");
1106+
expect(visuals.get("3:0")).toBeUndefined();
1107+
expect(visuals.get("4:0")?.background).toBe("#FDE68A");
1108+
expect(visuals.get("5:0")).toBeUndefined();
1109+
expect(visuals.get("2:1")?.background).toBe("#BAE6FD");
1110+
expect(visuals.get("3:1")).toBeUndefined();
1111+
expect(visuals.get("4:1")?.background).toBe("#BAE6FD");
1112+
expect(visuals.get("3:2")?.background).toBe("#C4B5FD");
1113+
expect(visuals.get("5:2")?.background).toBe("#C4B5FD");
1114+
expect(visuals.get("6:2")).toBeUndefined();
1115+
expect(visuals.get("2:3")).toBeUndefined();
1116+
expect(visuals.get("4:3")?.background).toBe("#BBF7D0");
1117+
expect(visuals.get("6:3")?.background).toBe("#BBF7D0");
1118+
expect(visuals.get("2:5")?.background).toBe("#FECACA");
1119+
expect(visuals.get("5:5")?.background).toBe("#FECACA");
1120+
expect(visuals.get("6:5")?.background).toBe("#FECACA");
1121+
});
1122+
9981123
it("evaluates common text helpers in conditional format formulas", () => {
9991124
const visuals = buildSpreadsheetConditionalVisuals({
10001125
conditionalFormattings: [

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,35 @@ function evaluateFormulaFunction(name: string, args: string[], context: Conditio
235235
if (normalizedName === "COUNT") {
236236
return formulaNumericArgs(args, context).length;
237237
}
238+
if (normalizedName === "COUNTA") {
239+
return formulaRawArgs(args, context).filter((value) => asString(value).trim().length > 0).length;
240+
}
241+
if (normalizedName === "COUNTBLANK") {
242+
return countBlankFormulaValues(args, context);
243+
}
244+
if (normalizedName === "MEDIAN") {
245+
return medianFormulaNumber(formulaNumericArgs(args, context));
246+
}
247+
if (normalizedName === "LARGE" || normalizedName === "SMALL") {
248+
return rankedFormulaNumber(
249+
formulaNumericArgs([args[0] ?? ""], context),
250+
Number(evaluateFormulaValue(args[1] ?? "1", context)),
251+
normalizedName,
252+
);
253+
}
254+
if (normalizedName === "RANK" || normalizedName === "RANK.EQ") {
255+
return rankFormulaNumber(
256+
Number(evaluateFormulaValue(args[0] ?? "", context)),
257+
formulaNumericArgs([args[1] ?? ""], context),
258+
Number(evaluateFormulaValue(args[2] ?? "0", context)),
259+
);
260+
}
261+
if (normalizedName === "PERCENTILE" || normalizedName === "PERCENTILE.INC") {
262+
return percentileFormulaNumber(
263+
formulaNumericArgs([args[0] ?? ""], context),
264+
Number(evaluateFormulaValue(args[1] ?? "", context)),
265+
);
266+
}
238267
if (normalizedName === "COUNTIF") {
239268
return countIf(args, context);
240269
}
@@ -363,6 +392,20 @@ function formulaNumericArgs(args: string[], context: ConditionalFormulaContext):
363392
return values;
364393
}
365394

395+
function formulaRawArgs(args: string[], context: ConditionalFormulaContext): unknown[] {
396+
const values: unknown[] = [];
397+
for (const arg of args) {
398+
const rangeValues = formulaRangeValues(arg, context);
399+
if (rangeValues) {
400+
values.push(...rangeValues);
401+
continue;
402+
}
403+
404+
values.push(evaluateFormulaValue(arg, context));
405+
}
406+
return values;
407+
}
408+
366409
function formulaRangeValues(expression: string, context: ConditionalFormulaContext): string[] | null {
367410
const cells = formulaRangeCells(expression, context);
368411
return cells ? cells.map((cell) => cell.value) : null;
@@ -416,6 +459,53 @@ function countIfs(args: string[], context: ConditionalFormulaContext): number {
416459
}).length;
417460
}
418461

462+
function countBlankFormulaValues(args: string[], context: ConditionalFormulaContext): number {
463+
let count = 0;
464+
for (const arg of args) {
465+
const range = formulaRange(arg, context);
466+
const cells = formulaRangeCells(arg, context);
467+
if (range && cells) {
468+
const area = range.rowSpan * range.columnSpan;
469+
const explicitBlankCount = cells.filter((cell) => cell.value.trim().length === 0).length;
470+
count += area <= 100_000 ? area - cells.filter((cell) => cell.value.trim().length > 0).length : explicitBlankCount;
471+
continue;
472+
}
473+
474+
if (asString(evaluateFormulaValue(arg, context)).trim().length === 0) count += 1;
475+
}
476+
return count;
477+
}
478+
479+
function medianFormulaNumber(values: number[]): number {
480+
if (values.length === 0) return Number.NaN;
481+
const sorted = [...values].sort((left, right) => left - right);
482+
const middle = Math.floor(sorted.length / 2);
483+
return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
484+
}
485+
486+
function rankedFormulaNumber(values: number[], rank: number, mode: string): number {
487+
const position = Math.trunc(rank) - 1;
488+
if (values.length === 0 || !Number.isFinite(position) || position < 0 || position >= values.length) return Number.NaN;
489+
const sorted = [...values].sort((left, right) => mode === "SMALL" ? left - right : right - left);
490+
return sorted[position];
491+
}
492+
493+
function rankFormulaNumber(value: number, values: number[], order: number): number {
494+
if (!Number.isFinite(value) || values.length === 0) return Number.NaN;
495+
const ascending = Number.isFinite(order) && order !== 0;
496+
return 1 + values.filter((candidate) => ascending ? candidate < value : candidate > value).length;
497+
}
498+
499+
function percentileFormulaNumber(values: number[], percentile: number): number {
500+
if (values.length === 0 || !Number.isFinite(percentile) || percentile < 0 || percentile > 1) return Number.NaN;
501+
const sorted = [...values].sort((left, right) => left - right);
502+
const position = (sorted.length - 1) * percentile;
503+
const lower = Math.floor(position);
504+
const upper = Math.ceil(position);
505+
if (lower === upper) return sorted[lower];
506+
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
507+
}
508+
419509
type FormulaCriteriaRange = {
420510
cellsByOffset: Map<string, string>;
421511
criterion: { operator: string; value: unknown };

0 commit comments

Comments
 (0)