Browse Source

Add Sheets conditional formatting and lookups

Introduces a new `sheets_cf.js` engine and UI for conditional formatting rules, wiring it into toolbar/menu actions, render-time effective styles, print/export styling, and sheet data normalization (`cf` per sheet). Expands the formula engine with `IFERROR`, `IFNA`, `IFS`, `AND`/`OR`/`NOT`, `VLOOKUP`, and `HLOOKUP`, adds `#N/A`, and aligns behavior with Sheets (optional `IF` false branch returns blank, case-insensitive text comparisons). Documentation and formula tests were updated to cover the new rule model, lookup behavior, and function semantics.
Toby Chui 1 day ago
parent
commit
93c585666b

+ 70 - 1
src/web/Office/README.md

@@ -74,8 +74,10 @@ Go structs are the source of truth — they mirror the JS exactly:
   documents written before the setting existed keep their behaviour.
   documents written before the setting existed keep their behaviour.
 - **Sheets** (`spreadsheet`): [`xlsx.go`](../../mod/office/xlsx.go) —
 - **Sheets** (`spreadsheet`): [`xlsx.go`](../../mod/office/xlsx.go) —
   `{sheets[{name, cells{"A1":{v,s,n}}, colW, rowH, merges, freeze,
   `{sheets[{name, cells{"A1":{v,s,n}}, colW, rowH, merges, freeze,
-  charts, filter}], active}`. Cell `v` is the raw input (`=`-prefix =
+  charts, filter, cf}], active}`. Cell `v` is the raw input (`=`-prefix =
   formula, evaluated client-side in [`sheets/formula.js`](sheets/formula.js)).
   formula, evaluated client-side in [`sheets/formula.js`](sheets/formula.js)).
+  `cf` is the sheet's conditional-format rules (below) — client-side only,
+  so the Go structs do not model it.
 - **Slides** (`presentation`): [`office.go`](../../mod/office/office.go) —
 - **Slides** (`presentation`): [`office.go`](../../mod/office/office.go) —
   `{size:[960,540], theme, slides[{id, bg, notes, objects[{type, x, y, w,
   `{size:[960,540], theme, slides[{id, bg, notes, objects[{type, x, y, w,
   h, rot, z, props}]}]}`. Object types: `text`, `image`, `shape`, `line`,
   h, rot, z, props}]}]}`. Object types: `text`, `image`, `shape`, `line`,
@@ -113,6 +115,73 @@ body before posting:
   (`rasterizeEmojiForPdf` in `docs.js`) because PDF core fonts are
   (`rasterizeEmojiForPdf` in `docs.js`) because PDF core fonts are
   Latin-1 and have no emoji glyphs.
   Latin-1 and have no emoji glyphs.
 
 
+### Sheets formula engine
+
+[`sheets/formula.js`](sheets/formula.js) is a DOM-free tokenizer, parser and
+evaluator that also runs under Node, so it is unit-tested directly:
+
+```bash
+node web/Office/sheets/test_formula.js    # exits 1 on failure
+```
+
+Functions: `IF IFS IFERROR IFNA AND OR NOT` · `VLOOKUP HLOOKUP` ·
+`SUM AVERAGE MIN MAX COUNT COUNTA` · `ROUND ABS INT` ·
+`CONCAT LEN UPPER LOWER TRIM` · `TODAY NOW`.
+
+Two deliberate departures from Excel, both matching Sheets:
+
+- **`IF`'s `value_if_false` is optional and blank when omitted.** Excel
+  answers `FALSE`, which put a stray "FALSE" in the cell for the very common
+  `=IF(A2="foo","A2 is foo")` shape.
+- **Text comparison is case-insensitive** everywhere, so `="Google"="google"`
+  is true and `VLOOKUP("oRaNgE", ...)` finds `Orange`.
+
+`VLOOKUP`/`HLOOKUP` default to the approximate ("is_sorted") mode. Excel and
+Sheets binary-search there, which returns arbitrary answers on unsorted data;
+this scans and keeps the best match at or below the key instead — identical
+on sorted data, merely imperfect rather than arbitrary on unsorted. `FALSE`
+means exact match, and a miss is `#N/A` so `IFERROR`/`IFNA` can catch it.
+
+Not implemented: `COUNTIF`/`SUMIF`/`AVERAGEIF`, `INDEX`/`MATCH`, and
+cross-sheet references. Add new functions to the `call()` switch in
+`formula.js` and pin them with a case in `test_formula.js`.
+
+### Sheets conditional formatting
+
+[`sheets/sheets_cf.js`](sheets/sheets_cf.js) (`SheetsCF`) keeps a list of
+rules per sheet in `cf` and re-evaluates them every time the grid paints.
+Rule kinds: empty/not-empty, the text tests (contains, starts/ends with, is
+exactly), numeric comparisons incl. between, date before/after/on, and a
+**custom formula**.
+
+Two things make range rules work without a separate rule kind:
+
+- A **custom formula** is parsed once and evaluated per cell, with relative
+  references shifted from the range's top-left anchor and `$`-anchored ones
+  left alone — so `=SUM($B1:$D1)>$E1` tests each row against its own total,
+  and `=SUM($B$1:$D$4)>200` tests one aggregate for the whole block.
+- An **operand** may itself be a formula, evaluated once per rule rather
+  than per cell, so "is greater than `=AVERAGE($F$2:$F$99)`" costs no more
+  than a plain number.
+
+Formulas are compiled once per distinct source and their reference nodes are
+rewritten in place before each evaluation (`compile` / `evalAt`); re-parsing
+per cell was far too slow for a repainting grid.
+
+Rules are first-match-wins **per property**, like Google Sheets: the topmost
+rule that sets a background owns the background, and a later rule can still
+contribute a text colour the first left alone. `styleAt()` stays the cell's
+own style (what the toolbar edits and what is saved) while `effStyleAt()`
+layers the matching rule on top — paint and the PDF print model read the
+second, everything that edits formatting reads the first, so a rule is never
+mistaken for something the user applied by hand.
+
+Rules ride in the document body, so they persist in `.xlsa` and reach PDF
+export through the print model. **They are dropped on `.xlsx` / `.ods`
+export** — those writers would need real DXF / style-map records. If that
+matters, the alternative is baking the resolved colours into the exported
+cells' own styles at export time.
+
 ### Saving back into a foreign format
 ### Saving back into a foreign format
 
 
 Sheets declares `saveFormats` (see
 Sheets declares `saveFormats` (see

+ 160 - 3
src/web/Office/sheets/formula.js

@@ -22,7 +22,20 @@
         dateToSerial(date) / serialToDate(n)     Excel-style 1900 date serials
         dateToSerial(date) / serialToDate(n)     Excel-style 1900 date serials
 
 
     Values: number | string | boolean | null (empty) | FErr.
     Values: number | string | boolean | null (empty) | FErr.
-    Errors: #DIV/0! #NAME? #REF! #VALUE! #CYCLE! (plus #NUM!).
+    Errors: #DIV/0! #NAME? #REF! #VALUE! #CYCLE! #NUM! #N/A.
+
+    Functions:
+        logical    IF IFS IFERROR IFNA AND OR NOT
+        lookup     VLOOKUP HLOOKUP
+        aggregate  SUM AVERAGE MIN MAX COUNT COUNTA
+        math       ROUND ABS INT
+        text       CONCAT (=CONCATENATE) LEN UPPER LOWER TRIM
+        date       TODAY NOW
+
+    IF / IFS / IFERROR evaluate only the branch they return, so
+    IF(A1=0,"",1/A1) never divides by zero. IF's value_if_false is optional
+    and blank when omitted (the Sheets rule - Excel answers FALSE there).
+    Text comparison is case-insensitive throughout, again matching Sheets.
 */
 */
 var SheetFormula = (function () {
 var SheetFormula = (function () {
     "use strict";
     "use strict";
@@ -33,7 +46,8 @@ var SheetFormula = (function () {
         REF: "#REF!",
         REF: "#REF!",
         VALUE: "#VALUE!",
         VALUE: "#VALUE!",
         CYCLE: "#CYCLE!",
         CYCLE: "#CYCLE!",
-        NUM: "#NUM!"
+        NUM: "#NUM!",
+        NA: "#N/A"          // lookup found nothing (VLOOKUP / MATCH / IFS)
     };
     };
     var ERR_LITERALS = ["#DIV/0!", "#NAME?", "#REF!", "#VALUE!", "#CYCLE!", "#NUM!", "#N/A", "#NULL!"];
     var ERR_LITERALS = ["#DIV/0!", "#NAME?", "#REF!", "#VALUE!", "#CYCLE!", "#NUM!", "#N/A", "#NULL!"];
 
 
@@ -417,6 +431,14 @@ var SheetFormula = (function () {
             return new FErr(ERR.VALUE, "Bad comparison");
             return new FErr(ERR.VALUE, "Bad comparison");
         }
         }
 
 
+        // normalized bounds of a range node, for functions that index into a
+        // range in two dimensions (lookups) rather than just sweeping it
+        function rangeBox(node) {
+            return {
+                c1: Math.min(node.a.col, node.b.col), c2: Math.max(node.a.col, node.b.col),
+                r1: Math.min(node.a.row, node.b.row), r2: Math.max(node.a.row, node.b.row)
+            };
+        }
         function eachRangeCell(node, fn) {
         function eachRangeCell(node, fn) {
             var c1 = Math.min(node.a.col, node.b.col), c2 = Math.max(node.a.col, node.b.col);
             var c1 = Math.min(node.a.col, node.b.col), c2 = Math.max(node.a.col, node.b.col);
             var r1 = Math.min(node.a.row, node.b.row), r2 = Math.max(node.a.row, node.b.row);
             var r1 = Math.min(node.a.row, node.b.row), r2 = Math.max(node.a.row, node.b.row);
@@ -477,6 +499,72 @@ var SheetFormula = (function () {
             return toStr(ev(args[idx]));
             return toStr(ev(args[idx]));
         }
         }
 
 
+        /*
+            VLOOKUP(search_key, range, index, [is_sorted]) and its transposed
+            twin HLOOKUP. `index` is 1-based *within the range*, so column 1
+            is the range's own first column, not the sheet's.
+
+            is_sorted defaults to TRUE, meaning "closest match at or below the
+            key". Excel and Sheets binary-search for that, which silently
+            returns nonsense when the data is not actually sorted; this scans
+            instead and keeps the best match at or below the key. On sorted
+            data - the only case those two define - the answer is identical,
+            and on unsorted data this one is merely imperfect rather than
+            arbitrary. FALSE means exact match only.
+        */
+        function lookup(name, args) {
+            if (args.length < 3 || args.length > 4) {
+                return new FErr(ERR.VALUE, name + " expects 3 or 4 arguments");
+            }
+            var keyv = ev(args[0]);
+            if (isErr(keyv)) return keyv;
+            if (args[1].t !== "range") {
+                return new FErr(ERR.VALUE, name + " needs a range to search, e.g. B2:D9");
+            }
+            var box = rangeBox(args[1]);
+            var idx = oneNum(args, 2);
+            if (isErr(idx)) return idx;
+            idx = Math.trunc(idx);
+            var vertical = name === "VLOOKUP";
+            var depth = vertical ? box.c2 - box.c1 + 1 : box.r2 - box.r1 + 1;
+            if (idx < 1) return new FErr(ERR.VALUE, name + " index must be 1 or more");
+            if (idx > depth) {
+                return new FErr(ERR.REF, name + " index " + idx + " is past the end of the range");
+            }
+            var sorted = true;
+            if (args.length > 3 && args[3].t !== "empty") {
+                var sv = boolify(ev(args[3]));
+                if (isErr(sv)) return sv;
+                sorted = sv;
+            }
+            var span = vertical ? box.r2 - box.r1 + 1 : box.c2 - box.c1 + 1;
+            if (span * depth > MAX_RANGE_CELLS) {
+                return new FErr(ERR.VALUE, "Range too large");
+            }
+            var keyAt = vertical ?
+                function (i) { return ctx.cell(box.c1, box.r1 + i); } :
+                function (i) { return ctx.cell(box.c1 + i, box.r1); };
+            var resultAt = vertical ?
+                function (i) { return ctx.cell(box.c1 + idx - 1, box.r1 + i); } :
+                function (i) { return ctx.cell(box.c1 + i, box.r1 + idx - 1); };
+
+            var best = -1, bestVal = null, i, cv, cmp;
+            for (i = 0; i < span; i++) {
+                cv = keyAt(i);
+                if (isErr(cv) || cv === null || cv === undefined) continue;
+                if (compare("=", keyv, cv) === true) return resultAt(i);
+                if (!sorted) continue;
+                // approximate: remember the largest entry still <= the key
+                if (compare("<=", cv, keyv) !== true) continue;
+                if (best < 0 || compare(">", cv, bestVal) === true) {
+                    best = i;
+                    bestVal = cv;
+                }
+            }
+            if (best >= 0) return resultAt(best);
+            return new FErr(ERR.NA, name + " found no match for " + toStr(keyv));
+        }
+
         function call(n) {
         function call(n) {
             var name = n.name === "CONCATENATE" ? "CONCAT" : n.name;
             var name = n.name === "CONCATENATE" ? "CONCAT" : n.name;
             var args = n.args;
             var args = n.args;
@@ -488,9 +576,78 @@ var SheetFormula = (function () {
                     }
                     }
                     var cond = boolify(ev(args[0]));
                     var cond = boolify(ev(args[0]));
                     if (isErr(cond)) return cond;
                     if (isErr(cond)) return cond;
+                    // only the taken branch is evaluated, so
+                    // IF(A1=0,"",1/A1) never divides by zero
                     if (cond) return ev(args[1]);
                     if (cond) return ev(args[1]);
-                    return args.length > 2 ? ev(args[2]) : false;
+                    // value_if_false is optional and blank by default (the
+                    // Sheets rule); Excel would answer FALSE here
+                    return args.length > 2 ? ev(args[2]) : null;
+                }
+                case "IFERROR":
+                case "IFNA": {
+                    if (args.length < 1 || args.length > 2) {
+                        return new FErr(ERR.VALUE, name + " expects 1 or 2 arguments");
+                    }
+                    var tryv;
+                    try { tryv = ev(args[0]); }
+                    catch (e) { tryv = isErr(e) ? e : new FErr(ERR.VALUE, "Formula error"); }
+                    var caught = name === "IFNA" ?
+                        (isErr(tryv) && tryv.code === ERR.NA) : isErr(tryv);
+                    if (!caught) return tryv;
+                    return args.length > 1 ? ev(args[1]) : null;
+                }
+                case "IFS": {
+                    // condition / value pairs, first true one wins
+                    if (args.length < 2 || args.length % 2 !== 0) {
+                        return new FErr(ERR.VALUE, "IFS expects condition/value pairs");
+                    }
+                    for (var ifsI = 0; ifsI < args.length; ifsI += 2) {
+                        var ifsC = boolify(ev(args[ifsI]));
+                        if (isErr(ifsC)) return ifsC;
+                        if (ifsC) return ev(args[ifsI + 1]);
+                    }
+                    return new FErr(ERR.NA, "No IFS condition was true");
+                }
+                case "AND":
+                case "OR": {
+                    if (!args.length) return new FErr(ERR.VALUE, name + " needs an argument");
+                    // blanks are skipped, the way a spreadsheet ignores empty
+                    // cells inside a range handed to AND/OR
+                    var seen = 0, acc = name === "AND";
+                    for (var lI = 0; lI < args.length; lI++) {
+                        var vals = [];
+                        if (args[lI].t === "range") {
+                            var lStop = eachRangeCell(args[lI], function (cv) {
+                                if (isErr(cv)) return cv;
+                                vals.push(cv);
+                                return undefined;
+                            });
+                            if (lStop !== undefined) return lStop;
+                        } else {
+                            var lv = ev(args[lI]);
+                            if (isErr(lv)) return lv;
+                            vals.push(lv);
+                        }
+                        for (var vI = 0; vI < vals.length; vI++) {
+                            if (vals[vI] === null || vals[vI] === undefined) continue;
+                            var b = boolify(vals[vI]);
+                            if (isErr(b)) return b;
+                            seen++;
+                            if (name === "AND") acc = acc && b;
+                            else acc = acc || b;
+                        }
+                    }
+                    if (!seen) return new FErr(ERR.VALUE, name + " found no logical values");
+                    return acc;
+                }
+                case "NOT": {
+                    if (args.length !== 1) return new FErr(ERR.VALUE, "NOT expects 1 argument");
+                    var nv = boolify(ev(args[0]));
+                    return isErr(nv) ? nv : !nv;
                 }
                 }
+                case "VLOOKUP":
+                case "HLOOKUP":
+                    return lookup(name, args);
                 case "SUM": {
                 case "SUM": {
                     st = collect(args);
                     st = collect(args);
                     if (st.err) return st.err;
                     if (st.err) return st.err;

+ 1 - 0
src/web/Office/sheets/index.html

@@ -45,5 +45,6 @@
     <div id="shPrintArea"></div>
     <div id="shPrintArea"></div>
     <script src="sheets.js"></script>
     <script src="sheets.js"></script>
     <script src="sheets_io.js"></script>
     <script src="sheets_io.js"></script>
+    <script src="sheets_cf.js"></script>
 </body>
 </body>
 </html>
 </html>

+ 57 - 0
src/web/Office/sheets/sheets.css

@@ -325,6 +325,63 @@ body.dark {
 }
 }
 .sh-filter-list input[type="checkbox"] { width: auto !important; }
 .sh-filter-list input[type="checkbox"] { width: auto !important; }
 
 
+/* ============ conditional formatting dialog ============ */
+.sh-cf-empty {
+    color: var(--of-fg-soft);
+    font-size: 13px;
+    line-height: 1.5;
+    margin-bottom: 10px;
+}
+.sh-cf-list { max-height: 46vh; overflow-y: auto; }
+.sh-cf-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px;
+    border: 1px solid var(--of-border);
+    border-radius: 4px;
+    margin-bottom: 6px;
+    cursor: pointer;
+}
+.sh-cf-row:hover { background: var(--of-hover); }
+.sh-cf-swatch, .sh-cf-preview {
+    flex: 0 0 auto;
+    min-width: 46px;
+    padding: 6px 8px;
+    text-align: center;
+    font-size: 13px;
+    border: 1px solid var(--of-border);
+    border-radius: 3px;
+    color: var(--of-fg);
+}
+.sh-cf-info { flex: 1; min-width: 0; }
+.sh-cf-desc {
+    font-size: 13px;
+    color: var(--of-fg);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.sh-cf-range { font-size: 12px; color: var(--of-fg-soft); }
+.sh-cf-del { flex: 0 0 auto; }
+.sh-cf-add { margin-top: 4px; }
+.sh-cf-hint {
+    font-size: 12px;
+    color: var(--of-fg-soft);
+    line-height: 1.45;
+    margin-top: 6px;
+}
+.sh-cf-style { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
+.sh-cf-actions {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+    margin-top: 14px;
+    padding-top: 12px;
+    border-top: 1px solid var(--of-border);
+}
+.sh-cf-actions .of-btn.danger { margin-right: auto; }
+
 /* chart dialog data grid (shared look with slides) */
 /* chart dialog data grid (shared look with slides) */
 .sh-grid-table { border-collapse: collapse; width: 100%; }
 .sh-grid-table { border-collapse: collapse; width: 100%; }
 .sh-grid-table td { padding: 1px; }
 .sh-grid-table td { padding: 1px; }

+ 55 - 5
src/web/Office/sheets/sheets.js

@@ -39,14 +39,25 @@
                          rowField: 0,            // fields are 0-based col
                          rowField: 0,            // fields are 0-based col
                          colField: 1,            // offsets in range, -1=none
                          colField: 1,            // offsets in range, -1=none
                          valField: 2,
                          valField: 2,
-                         agg: "sum"|"count"|"avg"|"min"|"max" }
+                         agg: "sum"|"count"|"avg"|"min"|"max" },
+                cf: [ { id, range: "A1:F100",    // conditional formatting
+                        type: "gt"|"contains"|   // (sheets_cf.js); evaluated
+                              "formula"|...,     // top-down while painting
+                        v1, v2,                  // operands, as typed
+                        style: { bg, fc, b, i, u } } ]
             }
             }
         ],
         ],
         active: 0
         active: 0
     }
     }
 
 
     Formula engine: formula.js (SheetFormula). Charts/import/export/print:
     Formula engine: formula.js (SheetFormula). Charts/import/export/print:
-    sheets_io.js (SheetsIO). Cross-sheet references are not supported yet.
+    sheets_io.js (SheetsIO). Conditional formatting: sheets_cf.js (SheetsCF).
+    Cross-sheet references are not supported yet.
+
+    Cell styling has two layers: styleAt() is what the cell itself carries
+    (what the toolbar edits and what gets saved), effStyleAt() adds whatever
+    conditional rule currently matches. Paint and export read the second;
+    anything that edits or reports formatting reads the first.
 */
 */
 
 
 var SheetsApp = (function () {
 var SheetsApp = (function () {
@@ -93,7 +104,7 @@ var SheetsApp = (function () {
         return {
         return {
             name: name, color: null, cols: 26, rows: 200,
             name: name, color: null, cols: 26, rows: 200,
             cells: {}, colW: {}, rowH: {}, merges: [],
             cells: {}, colW: {}, rowH: {}, merges: [],
-            freeze: { r: 0, c: 0 }, filter: null, charts: []
+            freeze: { r: 0, c: 0 }, filter: null, charts: [], cf: []
         };
         };
     }
     }
     function defaultBody() {
     function defaultBody() {
@@ -118,6 +129,7 @@ var SheetsApp = (function () {
             s.freeze.c = clamp(parseInt(s.freeze.c, 10) || 0, 0, 10);
             s.freeze.c = clamp(parseInt(s.freeze.c, 10) || 0, 0, 10);
             s.filter = s.filter || null;
             s.filter = s.filter || null;
             s.charts = Array.isArray(s.charts) ? s.charts : [];
             s.charts = Array.isArray(s.charts) ? s.charts : [];
+            s.cf = Array.isArray(s.cf) ? s.cf : [];
         });
         });
         b.active = clamp(parseInt(b.active, 10) || 0, 0, b.sheets.length - 1);
         b.active = clamp(parseInt(b.active, 10) || 0, 0, b.sheets.length - 1);
         return b;
         return b;
@@ -136,6 +148,22 @@ var SheetsApp = (function () {
         var cell = sheet().cells[key(c, r)];
         var cell = sheet().cells[key(c, r)];
         return (cell && cell.s) ? cell.s : {};
         return (cell && cell.s) ? cell.s : {};
     }
     }
+    /*
+        What a cell actually looks like: its own style with any matching
+        conditional-format rule layered on top. Painting and PDF export use
+        this; everything that edits or reports the cell's OWN formatting
+        (the toolbar, the Format menu, applyStyle) keeps using styleAt, so a
+        rule never gets mistaken for something the user set by hand.
+    */
+    function effStyleAt(c, r) {
+        var s = styleAt(c, r);
+        var cf = window.SheetsCF ? SheetsCF.styleFor(c, r) : null;
+        if (!cf) return s;
+        var out = {}, k;
+        for (k in s) if (Object.prototype.hasOwnProperty.call(s, k)) out[k] = s[k];
+        for (k in cf) if (Object.prototype.hasOwnProperty.call(cf, k)) out[k] = cf[k];
+        return out;
+    }
     function setRaw(c, r, v) {
     function setRaw(c, r, v) {
         var s = sheet(), k = key(c, r);
         var s = sheet(), k = key(c, r);
         if (v === "" || v === null || v === undefined) {
         if (v === "" || v === null || v === undefined) {
@@ -405,6 +433,9 @@ var SheetsApp = (function () {
         renderTabs();
         renderTabs();
         syncFxBar();
         syncFxBar();
         updateStatusStats();
         updateStatusStats();
+        // a full render can mean a different sheet or document, so the
+        // toolbar's toggles have to be re-read rather than left as they were
+        syncToolbarFromSel();
     }
     }
     function cellClasses(c, r, s, fmtd, inSel) {
     function cellClasses(c, r, s, fmtd, inSel) {
         var cls = "sh-cell";
         var cls = "sh-cell";
@@ -435,7 +466,7 @@ var SheetsApp = (function () {
         if (rect.w === 0 || rect.h === 0) return "";
         if (rect.w === 0 || rect.h === 0) return "";
         if (pinX !== null) rect = { x: pinX, y: rect.y, w: rect.w, h: rect.h };
         if (pinX !== null) rect = { x: pinX, y: rect.y, w: rect.w, h: rect.h };
         if (pinY !== null) rect = { x: rect.x, y: pinY, w: rect.w, h: rect.h };
         if (pinY !== null) rect = { x: rect.x, y: pinY, w: rect.w, h: rect.h };
-        var s = styleAt(c, r);
+        var s = effStyleAt(c, r);
         var text, fmtd;
         var text, fmtd;
         if ((s.fmt || "general") === "text") {
         if ((s.fmt || "general") === "text") {
             text = String(rawAt(c, r) || "");
             text = String(rawAt(c, r) || "");
@@ -967,7 +998,8 @@ var SheetsApp = (function () {
                     rowH.push(h !== undefined ? h : DEF_ROWH);
                     rowH.push(h !== undefined ? h : DEF_ROWH);
                     var row = [];
                     var row = [];
                     for (var cc = ur.c1; cc <= ur.c2; cc++) {
                     for (var cc = ur.c1; cc <= ur.c2; cc++) {
-                        var st = styleAt(cc, r);
+                        // effective style: conditional colours print too
+                        var st = effStyleAt(cc, r);
                         var t, num = false;
                         var t, num = false;
                         if ((st.fmt || "general") === "text") {
                         if ((st.fmt || "general") === "text") {
                             var raw = rawAt(cc, r);
                             var raw = rawAt(cc, r);
@@ -1475,6 +1507,7 @@ var SheetsApp = (function () {
             if (rg.c1 === rg.c2 && rg.r1 === rg.r2) return null;
             if (rg.c1 === rg.c2 && rg.r1 === rg.r2) return null;
             return rangeStr(rg);
             return rangeStr(rg);
         }).filter(function (m) { return !!m; });
         }).filter(function (m) { return !!m; });
+        if (window.SheetsCF) SheetsCF.shiftRanges(axis, index, count);
         setActive(clamp(head.c, 0, s.cols - 1), clamp(head.r, 0, s.rows - 1));
         setActive(clamp(head.c, 0, s.cols - 1), clamp(head.r, 0, s.rows - 1));
         commit();
         commit();
     }
     }
@@ -2046,6 +2079,9 @@ var SheetsApp = (function () {
         $tb.append(tbtn("filter", "Create / remove filter on selection", function () {
         $tb.append(tbtn("filter", "Create / remove filter on selection", function () {
             if (window.SheetsIO) SheetsIO.toggleFilter();
             if (window.SheetsIO) SheetsIO.toggleFilter();
         }, "shBtnFilter"));
         }, "shBtnFilter"));
+        $tb.append(tbtn("paint brush", "Conditional formatting", function () {
+            if (window.SheetsCF) SheetsCF.open();
+        }, "shBtnCondFmt"));
     }
     }
     function syncToolbarFromSel() {
     function syncToolbarFromSel() {
         var s = styleAt(anchor.c, anchor.r);
         var s = styleAt(anchor.c, anchor.r);
@@ -2053,6 +2089,7 @@ var SheetsApp = (function () {
         $("#shBtnItalic").toggleClass("active", !!s.i);
         $("#shBtnItalic").toggleClass("active", !!s.i);
         $("#shBtnUnderline").toggleClass("active", !!s.u);
         $("#shBtnUnderline").toggleClass("active", !!s.u);
         $("#shBtnFilter").toggleClass("active", !!sheet().filter);
         $("#shBtnFilter").toggleClass("active", !!sheet().filter);
+        $("#shBtnCondFmt").toggleClass("active", (sheet().cf || []).length > 0);
         $("#shBtnCurrency").toggleClass("active", s.fmt === "currency");
         $("#shBtnCurrency").toggleClass("active", s.fmt === "currency");
         $("#shBtnPercent").toggleClass("active", s.fmt === "percent");
         $("#shBtnPercent").toggleClass("active", s.fmt === "percent");
     }
     }
@@ -2097,6 +2134,16 @@ var SheetsApp = (function () {
             { label: "Underline", icon: "underline", key: "Ctrl+U", action: function () { toggleStyleFlag("u"); } },
             { label: "Underline", icon: "underline", key: "Ctrl+U", action: function () { toggleStyleFlag("u"); } },
             { label: "Wrap text", checked: function () { return !!styleAt(anchor.c, anchor.r).wrap; }, action: function () { toggleStyleFlag("wrap"); } },
             { label: "Wrap text", checked: function () { return !!styleAt(anchor.c, anchor.r).wrap; }, action: function () { toggleStyleFlag("wrap"); } },
             { sep: true },
             { sep: true },
+            {
+                label: "Conditional formatting...", icon: "paint brush",
+                action: function () { if (window.SheetsCF) SheetsCF.open(); }
+            },
+            {
+                label: "Clear conditional formatting", icon: "eraser",
+                enabled: function () { return (sheet().cf || []).length > 0; },
+                action: function () { if (window.SheetsCF) SheetsCF.clearForSelection(); }
+            },
+            { sep: true },
             { label: "Merge cells", icon: "compress", action: mergeSelection },
             { label: "Merge cells", icon: "compress", action: mergeSelection },
             { label: "Unmerge", icon: "expand", action: unmergeSelection },
             { label: "Unmerge", icon: "expand", action: unmergeSelection },
             { sep: true },
             { sep: true },
@@ -2355,6 +2402,9 @@ var SheetsApp = (function () {
         valueAt: valueAt,
         valueAt: valueAt,
         displayText: displayText,
         displayText: displayText,
         rawAt: rawAt,
         rawAt: rawAt,
+        // evaluation context for conditional-format formulas: shares the
+        // active sheet's memoized calculator, so rules cost a lookup
+        calcCtx: function () { return calc.ctx; },
         usedRange: usedRange,
         usedRange: usedRange,
         parseRange: parseRange,
         parseRange: parseRange,
         rangeStr: rangeStr,
         rangeStr: rangeStr,

+ 579 - 0
src/web/Office/sheets/sheets_cf.js

@@ -0,0 +1,579 @@
+/*
+    ArozOS Office - Sheets: conditional formatting.
+    Requires sheets.js (SheetsApp core API) and formula.js.
+
+    A sheet carries its rules in `cf: [rule, ...]`, evaluated top-down every
+    time the grid paints:
+
+        rule = {
+            id:    "cf-...",              // stable id, used by the editor
+            range: "A1:F1000",            // where the rule applies
+            type:  "gt" | "contains" | "formula" | ...   (see CONDS)
+            v1:    "200",                 // operand(s), kept as typed
+            v2:    "",                    // second operand for between
+            style: { bg, fc, b, i, u }    // what a matching cell gets
+        }
+
+    Rules are first-match-wins *per property*, the way Google Sheets does it:
+    the topmost matching rule that sets a background decides the background,
+    and a later rule can still contribute a text color the first one left
+    alone. Only bg/fc/b/i/u are conditional - number format, alignment and
+    borders always come from the cell's own style.
+
+    Operands may be formulas themselves ("=AVERAGE($F$2:$F$99)"), which is
+    what makes range rules work: the operand is evaluated once against the
+    sheet, so "is greater than =AVERAGE(...)" highlights above-average cells.
+    A "Custom formula" rule instead evaluates its formula per cell, with
+    relative references shifted from the range's top-left corner, so
+    "=$F2>=SUM($C2:$E2)" tests every row against its own total.
+*/
+
+var SheetsCF = (function () {
+    "use strict";
+
+    var Core = SheetsApp;
+    var F = SheetFormula;
+
+    function esc(t) { return OfficeApp.escapeHtml(t); }
+
+    /* ================= condition catalogue ================= */
+    /* args: how many operand boxes the editor shows.
+       kind: which help text / placeholder the editor uses. */
+    var CONDS = [
+        { id: "notempty", label: "Is not empty", args: 0 },
+        { id: "empty", label: "Is empty", args: 0 },
+        { id: "contains", label: "Text contains", args: 1, kind: "text" },
+        { id: "notcontains", label: "Text does not contain", args: 1, kind: "text" },
+        { id: "startswith", label: "Text starts with", args: 1, kind: "text" },
+        { id: "endswith", label: "Text ends with", args: 1, kind: "text" },
+        { id: "exact", label: "Text is exactly", args: 1, kind: "text" },
+        { id: "eq", label: "Is equal to", args: 1, kind: "num" },
+        { id: "ne", label: "Is not equal to", args: 1, kind: "num" },
+        { id: "gt", label: "Is greater than", args: 1, kind: "num" },
+        { id: "gte", label: "Is greater than or equal to", args: 1, kind: "num" },
+        { id: "lt", label: "Is less than", args: 1, kind: "num" },
+        { id: "lte", label: "Is less than or equal to", args: 1, kind: "num" },
+        { id: "between", label: "Is between", args: 2, kind: "num" },
+        { id: "notbetween", label: "Is not between", args: 2, kind: "num" },
+        { id: "dbefore", label: "Date is before", args: 1, kind: "date" },
+        { id: "dafter", label: "Date is after", args: 1, kind: "date" },
+        { id: "don", label: "Date is", args: 1, kind: "date" },
+        { id: "formula", label: "Custom formula is", args: 1, kind: "formula" }
+    ];
+    function condById(id) {
+        for (var i = 0; i < CONDS.length; i++) if (CONDS[i].id === id) return CONDS[i];
+        return CONDS[0];
+    }
+
+    var DEFAULT_STYLE = { bg: "#b7e1cd", fc: "", b: false, i: false, u: false };
+
+    /* ================= value helpers ================= */
+    function textOf(v) {
+        if (v === null || v === undefined) return "";
+        if (F.isErr(v)) return v.code;
+        if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
+        if (typeof v === "number") return F.numToText(v);
+        return String(v);
+    }
+    // numeric view of a value, or null when it is not a number
+    function numOf(v) {
+        if (typeof v === "number") return v;
+        if (typeof v === "boolean") return v ? 1 : 0;
+        if (typeof v === "string") {
+            var t = v.trim();
+            if (t !== "" && !isNaN(Number(t))) return Number(t);
+        }
+        return null;
+    }
+    /* Date operands are typed by hand, so accept both the ISO form the app
+       displays and the M/D/Y form a user is likely to paste. */
+    function dateSerialOf(s) {
+        var t = String(s).trim();
+        var m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t);
+        if (m) return F.dateToSerial(new Date(+m[1], +m[2] - 1, +m[3]));
+        m = /^(\d{1,2})[/](\d{1,2})[/](\d{4})$/.exec(t);
+        if (m) return F.dateToSerial(new Date(+m[3], +m[1] - 1, +m[2]));
+        return null;
+    }
+
+    /* ================= compiled formulas =================
+       Parsing per cell would be far too slow on a repainting grid, so each
+       distinct formula is parsed once and its reference nodes are collected.
+       Evaluating for a cell just rewrites those nodes in place - relative
+       refs shifted by the cell's offset from the range anchor, absolute ones
+       left alone - which is what makes "=$F2>100" mean row-by-row. */
+    var compiled = {};      // formula source -> {ast, refs} | null when broken
+
+    function collectRefs(node, out) {
+        if (!node || typeof node !== "object") return;
+        if (node.t === "ref") {
+            out.push({ n: node, c: node.col, r: node.row, absC: !!node.absC, absR: !!node.absR });
+            return;
+        }
+        if (node.t === "range") {
+            [node.a, node.b].forEach(function (e) {
+                out.push({ n: e, c: e.col, r: e.row, absC: !!e.absC, absR: !!e.absR });
+            });
+            return;
+        }
+        Object.keys(node).forEach(function (k) {
+            var v = node[k];
+            if (Array.isArray(v)) v.forEach(function (x) { collectRefs(x, out); });
+            else if (v && typeof v === "object") collectRefs(v, out);
+        });
+    }
+    function compile(src) {
+        var body = String(src).replace(/^\s*=/, "").trim();
+        if (body === "") return null;
+        if (!Object.prototype.hasOwnProperty.call(compiled, body)) {
+            var entry = null;
+            try {
+                var ast = F.parse(body);
+                var refs = [];
+                collectRefs(ast, refs);
+                entry = { ast: ast, refs: refs };
+            } catch (e) {
+                entry = null;   // a malformed rule simply never matches
+            }
+            compiled[body] = entry;
+        }
+        return compiled[body];
+    }
+    // shift the compiled formula's relative refs, then evaluate it
+    function evalAt(entry, dC, dR) {
+        if (!entry) return null;
+        var ok = true;
+        entry.refs.forEach(function (x) {
+            var c = x.absC ? x.c : x.c + dC;
+            var r = x.absR ? x.r : x.r + dR;
+            if (c < 0 || r < 0) ok = false;
+            x.n.col = c;
+            x.n.row = r;
+        });
+        if (!ok) return null;   // shifted off the sheet: treat as no match
+        try {
+            return F.evaluate(entry.ast, Core.calcCtx());
+        } catch (e) {
+            return null;
+        }
+    }
+    /* An operand is either a literal the user typed or a formula. Formulas
+       are evaluated once per rule (not per cell) against the anchor, so
+       "=AVERAGE($F$2:$F$99)" costs the same as a plain number. */
+    function operandValue(raw, anchor) {
+        var s = String(raw === undefined || raw === null ? "" : raw);
+        if (/^\s*=/.test(s)) return evalAt(compile(s), anchor.c, anchor.r);
+        return F.literalValue(s);
+    }
+
+    /* ================= rule evaluation ================= */
+    function matches(rule, c, r, anchor) {
+        var cond = condById(rule.type);
+        if (cond.id === "formula") {
+            var v = evalAt(compile(rule.v1), c - anchor.c, r - anchor.r);
+            if (v === null || F.isErr(v)) return false;
+            if (typeof v === "boolean") return v;
+            if (typeof v === "number") return v !== 0;
+            return String(v).toUpperCase() === "TRUE";
+        }
+        var val = Core.valueAt(c, r);
+        if (F.isErr(val)) return false;
+        var isBlank = val === null || val === undefined || val === "";
+        if (cond.id === "empty") return isBlank;
+        if (cond.id === "notempty") return !isBlank;
+        if (isBlank) return false;   // every other test needs something to test
+
+        var a = operandValue(rule.v1, anchor);
+        if (cond.kind === "text") {
+            var hay = textOf(val).toLowerCase();
+            var needle = textOf(a).toLowerCase();
+            if (needle === "") return false;
+            switch (cond.id) {
+                case "contains": return hay.indexOf(needle) >= 0;
+                case "notcontains": return hay.indexOf(needle) < 0;
+                case "startswith": return hay.lastIndexOf(needle, 0) === 0;
+                case "endswith": return hay.length >= needle.length &&
+                    hay.indexOf(needle, hay.length - needle.length) >= 0;
+                case "exact": return hay === needle;
+            }
+            return false;
+        }
+        if (cond.kind === "date") {
+            var cellSerial = numOf(val);
+            var want = numOf(a);
+            if (want === null) want = dateSerialOf(rule.v1);
+            if (cellSerial === null || want === null) return false;
+            // compare whole days, so a timestamp still matches its date
+            var cd = Math.floor(cellSerial), wd = Math.floor(want);
+            if (cond.id === "dbefore") return cd < wd;
+            if (cond.id === "dafter") return cd > wd;
+            return cd === wd;
+        }
+        // numeric comparisons; equality also works for plain text
+        var n = numOf(val), an = numOf(a);
+        if (cond.id === "eq" || cond.id === "ne") {
+            var same = (n !== null && an !== null) ? n === an :
+                textOf(val).toLowerCase() === textOf(a).toLowerCase();
+            return cond.id === "eq" ? same : !same;
+        }
+        if (n === null || an === null) return false;
+        switch (cond.id) {
+            case "gt": return n > an;
+            case "gte": return n >= an;
+            case "lt": return n < an;
+            case "lte": return n <= an;
+            case "between":
+            case "notbetween": {
+                var b = numOf(operandValue(rule.v2, anchor));
+                if (b === null) return false;
+                var lo = Math.min(an, b), hi = Math.max(an, b);
+                var within = n >= lo && n <= hi;
+                return cond.id === "between" ? within : !within;
+            }
+        }
+        return false;
+    }
+
+    /* ================= render hook ================= */
+    var rangeCache = {};    // range string -> parsed range | null
+
+    function rangeOf(str) {
+        if (!Object.prototype.hasOwnProperty.call(rangeCache, str)) {
+            rangeCache[str] = Core.parseRange(str);
+        }
+        return rangeCache[str];
+    }
+    // caches are keyed by text, so they only need clearing when rules change
+    function invalidate() {
+        compiled = {};
+        rangeCache = {};
+    }
+
+    function rules() {
+        var s = Core.sheet();
+        return (s && Array.isArray(s.cf)) ? s.cf : [];
+    }
+    /*
+        The conditional part of a cell's style, or null when no rule applies.
+        Called for every painted cell, so it stays allocation-free until
+        something actually matches.
+    */
+    function styleFor(c, r) {
+        var list = rules();
+        if (!list.length) return null;
+        var out = null;
+        for (var i = 0; i < list.length; i++) {
+            var rule = list[i];
+            if (!rule || !rule.style) continue;
+            var rg = rangeOf(rule.range || "");
+            if (!rg || c < rg.c1 || c > rg.c2 || r < rg.r1 || r > rg.r2) continue;
+            var anchor = { c: rg.c1, r: rg.r1 };
+            if (!matches(rule, c, r, anchor)) continue;
+            var st = rule.style;
+            if (!out) out = {};
+            // first rule to set a property owns it
+            if (st.bg && !out.bg) out.bg = st.bg;
+            if (st.fc && !out.fc) out.fc = st.fc;
+            if (st.b && !out.b) out.b = true;
+            if (st.i && !out.i) out.i = true;
+            if (st.u && !out.u) out.u = true;
+        }
+        return out;
+    }
+
+    /* row/column insert or delete moves the ranges rules point at */
+    function shiftRanges(axis, index, count) {
+        var s = Core.sheet();
+        if (!Array.isArray(s.cf) || !s.cf.length) return;
+        s.cf.forEach(function (rule) {
+            var rg = Core.parseRange(rule.range || "");
+            if (!rg) return;
+            var lo = axis === "col" ? "c1" : "r1", hi = axis === "col" ? "c2" : "r2";
+            [lo, hi].forEach(function (kk) {
+                var v = rg[kk];
+                if (count > 0) { if (v >= index) rg[kk] = v + count; }
+                else {
+                    var del = -count;
+                    if (v >= index + del) rg[kk] = v - del;
+                    else if (v >= index) rg[kk] = index;
+                }
+            });
+            rule.range = Core.rangeStr(rg);
+        });
+        invalidate();
+    }
+
+    /* ================= rule descriptions ================= */
+    function describe(rule) {
+        var cond = condById(rule.type);
+        if (cond.args === 0) return cond.label;
+        if (cond.id === "between" || cond.id === "notbetween") {
+            return cond.label + " " + (rule.v1 || "?") + " and " + (rule.v2 || "?");
+        }
+        return cond.label + " " + (rule.v1 || "?");
+    }
+    function swatchCss(st) {
+        var css = "background:" + (st.bg || "transparent") + ";";
+        if (st.fc) css += "color:" + st.fc + ";";
+        if (st.b) css += "font-weight:700;";
+        if (st.i) css += "font-style:italic;";
+        if (st.u) css += "text-decoration:underline;";
+        return css;
+    }
+
+    /* ================= editor UI ================= */
+    function genId() {
+        return "cf-" + Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
+    }
+    function newRule() {
+        return {
+            id: genId(),
+            range: Core.rangeStr(Core.selRange()),
+            type: "notempty", v1: "", v2: "",
+            style: {
+                bg: DEFAULT_STYLE.bg, fc: DEFAULT_STYLE.fc,
+                b: false, i: false, u: false
+            }
+        };
+    }
+    function commitRules() {
+        invalidate();
+        Core.commit();
+        Core.renderAll();
+    }
+
+    // the dialog swaps between the rule list and the single-rule editor
+    function open() {
+        var $body = $('<div class="sh-cf"></div>');
+        OfficeApp.dialog({
+            title: "Conditional format rules",
+            body: $body,
+            buttons: [{ label: "Done", primary: true }]
+        });
+        showList($body);
+    }
+
+    function showList($body) {
+        var list = rules();
+        $body.empty();
+        if (!list.length) {
+            $body.append('<div class="sh-cf-empty">No rules on this sheet yet. ' +
+                'A rule paints cells in a range whenever their value matches a condition.</div>');
+        }
+        var $rows = $('<div class="sh-cf-list"></div>');
+        list.forEach(function (rule, i) {
+            var $row = $('<div class="sh-cf-row"></div>');
+            $row.append($('<div class="sh-cf-swatch"></div>')
+                .attr("style", swatchCss(rule.style || {})).text("123"));
+            $row.append($('<div class="sh-cf-info"><div class="sh-cf-desc"></div>' +
+                '<div class="sh-cf-range"></div></div>')
+                .find(".sh-cf-desc").text(describe(rule)).end()
+                .find(".sh-cf-range").text(rule.range || "").end());
+            var $del = $('<button type="button" class="of-tbtn sh-cf-del" ' +
+                'title="Delete rule"><i class="trash alternate outline icon"></i></button>');
+            $del.on("click", function (e) {
+                e.stopPropagation();
+                Core.sheet().cf.splice(i, 1);
+                commitRules();
+                showList($body);
+            });
+            $row.append($del);
+            $row.on("click", function () { showEditor($body, rule, false); });
+            $rows.append($row);
+        });
+        $body.append($rows);
+        var $add = $('<button type="button" class="of-btn sh-cf-add">' +
+            '<i class="plus icon"></i> Add another rule</button>');
+        $add.on("click", function () { showEditor($body, newRule(), true); });
+        $body.append($add);
+    }
+
+    function showEditor($body, rule, isNew) {
+        $body.empty();
+        var $ed = $(
+            '<label>Apply to range</label>' +
+            '<div style="display:flex;gap:6px;">' +
+            '<input type="text" id="shCfRange" style="flex:1;min-width:0;">' +
+            '<button type="button" class="of-tbtn" id="shCfPick" title="Select the range on the grid"' +
+            ' style="flex:0 0 auto;border:1px solid var(--of-border);"><i class="crosshairs icon"></i></button>' +
+            "</div>" +
+            '<label style="margin-top:10px;">Format cells if...</label>' +
+            '<select id="shCfType"></select>' +
+            '<div id="shCfArgs"></div>' +
+            '<div class="sh-cf-hint" id="shCfHint"></div>' +
+            '<label style="margin-top:10px;">Formatting style</label>' +
+            '<div class="sh-cf-style">' +
+            '<div class="sh-cf-preview" id="shCfPreview">123</div>' +
+            '<button type="button" class="of-tbtn" id="shCfB" title="Bold"><i class="bold icon"></i></button>' +
+            '<button type="button" class="of-tbtn" id="shCfI" title="Italic"><i class="italic icon"></i></button>' +
+            '<button type="button" class="of-tbtn" id="shCfU" title="Underline"><i class="underline icon"></i></button>' +
+            '<span id="shCfFcSlot"></span><span id="shCfBgSlot"></span>' +
+            "</div>"
+        );
+        $body.append($ed);
+
+        var draft = {
+            id: rule.id, range: rule.range, type: rule.type,
+            v1: rule.v1 || "", v2: rule.v2 || "",
+            style: $.extend({}, rule.style)
+        };
+
+        $ed.filter("#shCfRange").val(draft.range);
+        $body.find("#shCfRange").val(draft.range).on("change", function () {
+            draft.range = $(this).val().trim();
+        });
+        $body.find("#shCfPick").on("click", function () {
+            Core.pickRangeFromGrid(function (rgStr) {
+                if (rgStr) {
+                    draft.range = rgStr;
+                    $body.find("#shCfRange").val(rgStr);
+                }
+            });
+        });
+
+        var $type = $body.find("#shCfType");
+        CONDS.forEach(function (cd) {
+            $type.append($("<option></option>").attr("value", cd.id).text(cd.label));
+        });
+        $type.val(draft.type);
+
+        var HINTS = {
+            formula: "Relative references are read from the top-left cell of the range, " +
+                "so =$F2&gt;=SUM($C2:$E2) tests every row against its own total.",
+            text: "Matching ignores upper/lower case.",
+            date: "Type a date as YYYY-MM-DD or MM/DD/YYYY.",
+            num: "A value, or a formula such as =AVERAGE($F$2:$F$99) to compare " +
+                "against the whole range."
+        };
+        function renderArgs() {
+            var cond = condById(draft.type);
+            var $args = $body.find("#shCfArgs").empty();
+            var ph = cond.kind === "formula" ? "=$F2>100" :
+                (cond.kind === "date" ? "2026-01-31" :
+                    (cond.kind === "text" ? "text to look for" : "value or =formula"));
+            if (cond.args >= 1) {
+                $args.append($('<input type="text" id="shCfV1" style="margin-top:6px;">')
+                    .attr("placeholder", ph).val(draft.v1)
+                    .on("input", function () { draft.v1 = $(this).val(); }));
+            }
+            if (cond.args >= 2) {
+                $args.append($('<input type="text" id="shCfV2" style="margin-top:6px;">')
+                    .attr("placeholder", "and").val(draft.v2)
+                    .on("input", function () { draft.v2 = $(this).val(); }));
+            }
+            $body.find("#shCfHint").html(HINTS[cond.kind] || "");
+        }
+        $type.on("change", function () {
+            draft.type = $(this).val();
+            renderArgs();
+        });
+        renderArgs();
+
+        function paintPreview() {
+            $body.find("#shCfPreview").attr("style", swatchCss(draft.style));
+            $body.find("#shCfB").toggleClass("active", !!draft.style.b);
+            $body.find("#shCfI").toggleClass("active", !!draft.style.i);
+            $body.find("#shCfU").toggleClass("active", !!draft.style.u);
+        }
+        [["#shCfB", "b"], ["#shCfI", "i"], ["#shCfU", "u"]].forEach(function (p) {
+            $body.find(p[0]).on("click", function () {
+                draft.style[p[1]] = !draft.style[p[1]];
+                paintPreview();
+            });
+        });
+        var $fc = OfficeColorPicker.swatchInput({
+            id: "shCfFc", title: "Text color", value: draft.style.fc || "#202124",
+            allowNone: true, noneLabel: "Automatic"
+        });
+        $fc.on("change", function () {
+            draft.style.fc = $fc.val() || "";
+            paintPreview();
+        });
+        $body.find("#shCfFcSlot").append($fc);
+        var $bg = OfficeColorPicker.swatchInput({
+            id: "shCfBg", title: "Fill color", value: draft.style.bg || DEFAULT_STYLE.bg,
+            allowNone: true, noneLabel: "No fill"
+        });
+        $bg.on("change", function () {
+            draft.style.bg = $bg.val() || "";
+            paintPreview();
+        });
+        $body.find("#shCfBgSlot").append($bg);
+        paintPreview();
+
+        var $actions = $('<div class="sh-cf-actions"></div>');
+        var $cancel = $('<button type="button" class="of-btn">Cancel</button>');
+        $cancel.on("click", function () { showList($body); });
+        var $save = $('<button type="button" class="of-btn primary">' +
+            (isNew ? "Add rule" : "Save rule") + "</button>");
+        $save.on("click", function () {
+            if (!Core.parseRange(draft.range)) {
+                OfficeApp.toast("Invalid range: " + draft.range, "error");
+                return;
+            }
+            var cond = condById(draft.type);
+            if (cond.args >= 1 && String(draft.v1).trim() === "") {
+                OfficeApp.toast("This condition needs a value", "error");
+                return;
+            }
+            if (cond.kind === "formula" && !compile(draft.v1)) {
+                OfficeApp.toast("That formula cannot be parsed", "error");
+                return;
+            }
+            if (!draft.style.bg && !draft.style.fc &&
+                !draft.style.b && !draft.style.i && !draft.style.u) {
+                OfficeApp.toast("Pick at least one formatting change", "error");
+                return;
+            }
+            var s = Core.sheet();
+            if (!Array.isArray(s.cf)) s.cf = [];
+            var at = -1;
+            for (var i = 0; i < s.cf.length; i++) if (s.cf[i].id === draft.id) at = i;
+            if (at >= 0) s.cf[at] = draft; else s.cf.push(draft);
+            commitRules();
+            showList($body);
+        });
+        $actions.append($cancel).append($save);
+        if (!isNew) {
+            var $del = $('<button type="button" class="of-btn danger">Delete</button>');
+            $del.on("click", function () {
+                var s = Core.sheet();
+                s.cf = (s.cf || []).filter(function (x) { return x.id !== draft.id; });
+                commitRules();
+                showList($body);
+            });
+            $actions.prepend($del);
+        }
+        $body.append($actions);
+    }
+
+    /* clear every rule that covers the current selection */
+    function clearForSelection() {
+        var s = Core.sheet();
+        if (!Array.isArray(s.cf) || !s.cf.length) {
+            OfficeApp.toast("This sheet has no conditional formatting", "error");
+            return;
+        }
+        var sel = Core.selRange();
+        var before = s.cf.length;
+        s.cf = s.cf.filter(function (rule) {
+            var rg = Core.parseRange(rule.range || "");
+            if (!rg) return false;
+            // drop rules whose range overlaps what the user selected
+            var hit = !(rg.c2 < sel.c1 || rg.c1 > sel.c2 || rg.r2 < sel.r1 || rg.r1 > sel.r2);
+            return !hit;
+        });
+        commitRules();
+        OfficeApp.setStatus("Removed " + (before - s.cf.length) + " conditional format rule(s)");
+    }
+
+    return {
+        styleFor: styleFor,
+        shiftRanges: shiftRanges,
+        invalidate: invalidate,
+        open: open,
+        clearForSelection: clearForSelection,
+        describe: describe,
+        conditions: CONDS
+    };
+})();

+ 64 - 3
src/web/Office/sheets/test_formula.js

@@ -22,11 +22,28 @@ function eq(name, got, want) {
 
 
 /* grid fixture:
 /* grid fixture:
    A1=10 B1=20 C1=hello  A2=30 B2=40 C2==A1+B1
    A1=10 B1=20 C1=hello  A2=30 B2=40 C2==A1+B1
-   A3==C3 (self cycle)   B3="5" (numeric string)  A4=TRUE */
+   A3==C3 (self cycle)   B3="5" (numeric string)  A4=TRUE   D9 stays empty
+
+   F1:H5  lookup table, unsorted keys (exact-match VLOOKUP / HLOOKUP):
+          Fruit  Quantity Price
+          Apple  11       1.50
+          Banana 15       2.03
+          Lemon  9        3.10
+          Orange 5        1.01
+   I1:J5  ascending bands, for approximate VLOOKUP: 0/F 60/D 70/C 80/B 90/A */
 var grid = {
 var grid = {
     "0,0": "10", "1,0": "20", "2,0": "hello",
     "0,0": "10", "1,0": "20", "2,0": "hello",
     "0,1": "30", "1,1": "40", "2,1": "=A1+B1",
     "0,1": "30", "1,1": "40", "2,1": "=A1+B1",
-    "0,2": "=A3", "1,2": "5", "0,3": "TRUE"
+    "0,2": "=A3", "1,2": "5", "0,3": "TRUE",
+
+    "5,0": "Fruit", "6,0": "Quantity", "7,0": "Price",
+    "5,1": "Apple", "6,1": "11", "7,1": "1.50",
+    "5,2": "Banana", "6,2": "15", "7,2": "2.03",
+    "5,3": "Lemon", "6,3": "9", "7,3": "3.10",
+    "5,4": "Orange", "6,4": "5", "7,4": "1.01",
+
+    "8,0": "0", "9,0": "F", "8,1": "60", "9,1": "D", "8,2": "70", "9,2": "C",
+    "8,3": "80", "9,3": "B", "8,4": "90", "9,4": "A"
 };
 };
 var calc = F.createCalculator(function (c, r) { return grid[c + "," + r]; });
 var calc = F.createCalculator(function (c, r) { return grid[c + "," + r]; });
 function run(f) {
 function run(f) {
@@ -64,7 +81,15 @@ eq("numeric string coerced", run("B3+1"), 6);
 /* functions */
 /* functions */
 eq("IF true", run('IF(A1>5,"big","small")'), "big");
 eq("IF true", run('IF(A1>5,"big","small")'), "big");
 eq("IF false", run('IF(A1>50,"big","small")'), "small");
 eq("IF false", run('IF(A1>50,"big","small")'), "small");
-eq("IF no else", run("IF(FALSE,1)"), false);
+// value_if_false is optional and blank by default, as in Sheets; answering
+// FALSE here is the Excel rule and put a stray "FALSE" in users' cells
+eq("IF no else is blank", run("IF(FALSE,1)"), null);
+eq("IF no else, blank cell", run('IF(D9 = "foo","D9 is foo")'), null);
+eq("IF empty branch", run('IF(TRUE, , "False")'), null);
+eq("IF text compare is case-insensitive", run('IF("Google"="google","Equal","Unequal")'), "Equal");
+eq("IF nested", run('IF(IF(1>0,TRUE,FALSE),"Reached","Unreached")'), "Reached");
+eq("IF non-logical text", run('IF(C1,"t","f")'), "#VALUE!");
+eq("IF short-circuits the untaken branch", run('IF(A1=0,"zero",A1/2)'), 5);
 eq("CONCAT", run('CONCAT("x",A1,"y")'), "x10y");
 eq("CONCAT", run('CONCAT("x",A1,"y")'), "x10y");
 eq("CONCATENATE alias", run('CONCATENATE(1,2)'), "12");
 eq("CONCATENATE alias", run('CONCATENATE(1,2)'), "12");
 eq("ROUND", run("ROUND(3.14159,2)"), 3.14);
 eq("ROUND", run("ROUND(3.14159,2)"), 3.14);
@@ -76,6 +101,42 @@ eq("LEN", run('LEN("hello")'), 5);
 eq("UPPER", run('UPPER("aBc")'), "ABC");
 eq("UPPER", run('UPPER("aBc")'), "ABC");
 eq("TRIM", run('TRIM("  a   b  ")'), "a b");
 eq("TRIM", run('TRIM("  a   b  ")'), "a b");
 
 
+/* logical helpers */
+eq("AND both true", run("AND(1>0,2>1)"), true);
+eq("AND one false", run("AND(1>0,2>3)"), false);
+eq("OR one true", run("OR(1>2,2>1)"), true);
+eq("OR none true", run("OR(1>2,3>4)"), false);
+eq("NOT", run("NOT(1>2)"), true);
+eq("AND over a range of numbers", run("AND(A1:B2)"), true);   // non-zero = true
+eq("AND over text is an error", run("AND(A1:C1)"), "#VALUE!");
+eq("AND ignores blank cells", run("AND(TRUE,D9)"), true);
+eq("AND with no logicals", run("AND(D9)"), "#VALUE!");
+eq("IFERROR catches", run('IFERROR(1/0,"oops")'), "oops");
+eq("IFERROR passes good values", run("IFERROR(A1,0)"), 10);
+eq("IFERROR blank default", run("IFERROR(1/0)"), null);
+eq("IFNA ignores other errors", run('IFNA(1/0,"x")'), "#DIV/0!");
+eq("IFS first true wins", run('IFS(1>2,"a",2>1,"b")'), "b");
+eq("IFS none true", run('IFS(1>2,"a",3>4,"b")'), "#N/A");
+eq("IFS odd args", run('IFS(1>2,"a",2>1)'), "#VALUE!");
+
+/* lookups - the fruit table lives at F1:H5 in the fixture */
+eq("VLOOKUP exact", run('VLOOKUP("Orange",F1:H5,3,FALSE)'), 1.01);
+eq("VLOOKUP index 2", run('VLOOKUP("Orange",F1:H5,2,FALSE)'), 5);
+eq("VLOOKUP index 1 echoes the key", run('VLOOKUP("Lemon",F1:H5,1,FALSE)'), "Lemon");
+eq("VLOOKUP is case-insensitive", run('VLOOKUP("oRaNgE",F1:H5,3,FALSE)'), 1.01);
+eq("VLOOKUP miss", run('VLOOKUP("Kiwi",F1:H5,3,FALSE)'), "#N/A");
+eq("VLOOKUP wrapped in IFERROR", run('IFERROR(VLOOKUP("Kiwi",F1:H5,3,FALSE),"none")'), "none");
+eq("VLOOKUP index past range", run('VLOOKUP("Lemon",F1:H5,9,FALSE)'), "#REF!");
+eq("VLOOKUP index below 1", run('VLOOKUP("Lemon",F1:H5,0,FALSE)'), "#VALUE!");
+eq("VLOOKUP needs a range", run('VLOOKUP("Lemon",F1,2,FALSE)'), "#VALUE!");
+eq("VLOOKUP approximate", run("VLOOKUP(85,I1:J5,2)"), "B");
+eq("VLOOKUP approximate exact hit", run("VLOOKUP(90,I1:J5,2)"), "A");
+eq("VLOOKUP approximate below all", run("VLOOKUP(-5,I1:J5,2)"), "#N/A");
+eq("VLOOKUP defaults to approximate", run("VLOOKUP(75,I1:J5,2)"), "C");
+eq("VLOOKUP exact mode misses 75", run("VLOOKUP(75,I1:J5,2,FALSE)"), "#N/A");
+eq("HLOOKUP across a header row", run('HLOOKUP("Price",F1:H5,4,FALSE)'), 3.1);
+eq("VLOOKUP feeding IF", run('IF(VLOOKUP("Orange",F1:H5,2,FALSE)>3,"in stock","low")'), "in stock");
+
 /* errors */
 /* errors */
 eq("div by zero", run("1/0"), "#DIV/0!");
 eq("div by zero", run("1/0"), "#DIV/0!");
 eq("bad name", run("NOSUCHFN(1)"), "#NAME?");
 eq("bad name", run("NOSUCHFN(1)"), "#NAME?");