@aiquants/spreadsheet 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +1103 -0
  2. package/LICENSE.md +155 -0
  3. package/README.md +817 -0
  4. package/dist/index.cjs +21 -0
  5. package/dist/index.d.cts +1960 -0
  6. package/dist/index.d.ts +1960 -0
  7. package/dist/index.js +7711 -0
  8. package/dist/styles/spreadsheet.css +2 -0
  9. package/dist/styles/spreadsheet.standalone.css +3 -0
  10. package/package.json +94 -0
  11. package/src/Spreadsheet.tsx +2313 -0
  12. package/src/clipboard/clipPayload.ts +1242 -0
  13. package/src/clipboard/fixtures/excel.fixture.ts +301 -0
  14. package/src/clipboard/fixtures/html.fixture.ts +90 -0
  15. package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
  16. package/src/clipboard/htmlInterop.ts +1694 -0
  17. package/src/clipboard/numberFormatCodec.ts +870 -0
  18. package/src/clipboard/pasteSpecial.ts +608 -0
  19. package/src/clipboard/tiling.ts +370 -0
  20. package/src/clipboard/tsv.ts +186 -0
  21. package/src/clipboard/useClipboard.ts +1154 -0
  22. package/src/commands/selfCalc.ts +279 -0
  23. package/src/commands/useCommands.ts +946 -0
  24. package/src/editor/useCellEditor.ts +708 -0
  25. package/src/grid/CellView.tsx +51 -0
  26. package/src/grid/GridBody.tsx +489 -0
  27. package/src/header/ColumnHeader.tsx +167 -0
  28. package/src/header/RowHeader.tsx +144 -0
  29. package/src/header/columnName.ts +31 -0
  30. package/src/header/resizeGates.ts +146 -0
  31. package/src/header/resizeStrips.ts +79 -0
  32. package/src/header/useHeaderResize.ts +598 -0
  33. package/src/header/useHeaderSelection.ts +454 -0
  34. package/src/index.ts +122 -0
  35. package/src/keyboard/compositionGuard.ts +53 -0
  36. package/src/keyboard/ensureCellVisible.ts +77 -0
  37. package/src/keyboard/shortcuts.ts +718 -0
  38. package/src/keyboard/useGridKeyboard.ts +940 -0
  39. package/src/model/axisModel.ts +186 -0
  40. package/src/model/cellAnnotations.ts +92 -0
  41. package/src/model/cellCoord.ts +114 -0
  42. package/src/model/cellFormat.ts +562 -0
  43. package/src/model/cellPaint.ts +537 -0
  44. package/src/model/conditionalFormat.ts +805 -0
  45. package/src/model/dataAdapter.ts +478 -0
  46. package/src/model/dataValidation.ts +542 -0
  47. package/src/model/formulaRefs.ts +984 -0
  48. package/src/model/frozenPartition.ts +100 -0
  49. package/src/model/labels.ts +228 -0
  50. package/src/model/viewStore.ts +124 -0
  51. package/src/overlay/SelectionOverlay.tsx +1425 -0
  52. package/src/overlay/useFillHandle.ts +432 -0
  53. package/src/popover/CommentPopover.tsx +109 -0
  54. package/src/popover/LinkPopover.tsx +108 -0
  55. package/src/popover/ListPopover.tsx +161 -0
  56. package/src/popover/MessagePopover.tsx +36 -0
  57. package/src/popover/NotePopover.tsx +106 -0
  58. package/src/popover/NoteTip.tsx +26 -0
  59. package/src/popover/PopoverLayer.tsx +210 -0
  60. package/src/popover/placement.ts +138 -0
  61. package/src/popover/usePopovers.ts +642 -0
  62. package/src/selection/selectionState.ts +239 -0
  63. package/src/selection/useAutoScroller.ts +226 -0
  64. package/src/selection/usePointDrag.ts +273 -0
  65. package/src/selection/useSelectionEngine.ts +687 -0
  66. package/src/styles/components.entry.css +10 -0
  67. package/src/styles/spreadsheet.css +1029 -0
  68. package/src/styles/standalone.entry.css +9 -0
@@ -0,0 +1,805 @@
1
+ /**
2
+ * @fileoverview Conditional-format vocabulary, the 2-D 256 × 256 bucket index, condition
3
+ * evaluation and the ONE painted-format seam (v0.8, ADR-13 — the `getConditionalFormats`
4
+ * capability). The consumer declares rules through `getConditionalFormats()`; the root builds
5
+ * `buildCfIndex(rules)` once per (rules identity, renderVersion) with a fresh `createCfMemo()`
6
+ * at the same key; every surface that needs a cell's PAINTED format (paint, autofit, editor,
7
+ * announcements, caret gate, `getPaintedFormat`) goes through `resolvePaintedFormat` = base
8
+ * (`getCellFormat`) ⊕ CF. React-free and total (spec 01 §8.2).
9
+ *
10
+ * Engine contract (each clause is load-bearing and mutation-gated):
11
+ * - 2-D BUCKET INDEX + COLUMN SLICE: rules are bucketed by 256-row band × 256-column band (key
12
+ * `rowBand * 0x10000 + colBand`), each bucket materialised LAZILY on first touch by ONE ordered
13
+ * scan of the rules array — an untouched bucket is absent from the Map, a whole-column rule
14
+ * lands in one column band per touched row band, and every bucket list preserves GLOBAL rule
15
+ * order (`stopIfTrue` depends on it). Inside a bucket every touched COLUMN gets its own slice
16
+ * (key `rowBand * 2^32 + col`), materialised lazily by ONE ordered scan of the bucket list and
17
+ * holding only the rules whose ranges intersect that column within the row band — a sheet of
18
+ * ≤ 256 columns has one column band, so without the slice every whole-column rule would be
19
+ * walked for every cell. `buildCfIndex` itself is O(1); materialisation costs O(rules) per
20
+ * touched bucket + O(bucket) per touched column; per cell the walk is O(rules intersecting the
21
+ * cell's column within its 256-row band) — for whole-column rules exactly the rules on that
22
+ * column.
23
+ * - EVALUATION ORDER: the bucket is walked in array order; range containment is checked before
24
+ * any evaluation (a rule outside the cell reads nothing); every TRUE rule contributes its
25
+ * style, an EARLIER rule wins for a property both set (Excel precedence — first match per
26
+ * property), and a true rule with `stopIfTrue` ends the walk.
27
+ * - LAZY TEXT: `readText` is the caller's once-only thunk and is called only by the conditions
28
+ * that need the text (`cellValue`, `text`, `empty`, `notEmpty`, `colorScale`);
29
+ * `customFormula` never reads it.
30
+ * - `cellValue`: numeric comparison (as doubles) when the text and every used bound satisfy the
31
+ * strict decimal grammar of cellFormat.ts (`parseNumber` — the single source of "what is a
32
+ * number"), else code-unit string comparison of the raw strings; `between` / `notBetween` are
33
+ * inclusive and never match when `b` is missing; an op outside the vocabulary never matches.
34
+ * - `text`: exact, case-sensitive needle ops; `empty` / `notEmpty` test the empty string.
35
+ * - `colorScale`: the value is `parseNumber(text)` (non-numeric ⇒ the rule does not apply); a
36
+ * point without `value` takes the extrema from `getRangeExtrema` — undeclared ⇒ the rule is
37
+ * skipped (never substituted), declared ⇒ called once per range of the rule and memoised per
38
+ * rule id, `null` from any range ⇒ skipped; the contribution is `{ ...style, background }`
39
+ * with the sRGB-interpolated colour (`interpolateScale`: clamped linear map between the two
40
+ * ends, inverted ends swapped as pairs, `mid` honoured only strictly inside the ends, an
41
+ * unparseable colour or a non-finite bound ⇒ null).
42
+ * - `customFormula`: `evaluateFormula(formula, cell)` receives the rule's formula VERBATIM (no
43
+ * reference shifting — the consumer owns relative semantics) when declared, memoised per
44
+ * (rule id, row, col); truth = `true` or a finite non-zero number; undeclared ⇒ skipped.
45
+ * - FAIL-SOFT rules: an unknown condition type, an unknown op, empty / non-intersecting ranges
46
+ * and an unparseable scale colour skip the rule whole; nothing is ever substituted. The only
47
+ * fail-fast guard is a non-array rules argument (a consumer bug ⇒ RangeError).
48
+ * - MEMO: `CfMemo` holds the extrema, formula answers and per-cell strict-decimal text parses
49
+ * (shared by cellValue and colorScale, stored as the finite double so the walk never
50
+ * re-converts a canonical string) of one renderVersion; each Map is cleared whole when
51
+ * it reaches CF_MEMO_LIMIT (4,096) entries. The numeric bounds of a `cellValue` condition
52
+ * are parsed once per condition OBJECT (module WeakMap — the same immutability contract as
53
+ * the painted-format intern), never once per cell, and stored as doubles too.
54
+ * - PAINTED-FORMAT INTERN: with no true rule the base is returned BY IDENTITY; otherwise the
55
+ * merged format (`applyCfStyle` = `mergeFormat`: canonical, base identity when unchanged) is
56
+ * interned per base object — by style identity when exactly one identity-stable style
57
+ * contributed, else by the accumulated style's key — so an unchanged adapter answer yields
58
+ * the IDENTICAL painted object across re-slices and index rebuilds (cellPaint's WeakMap hits).
59
+ * Formats and rule styles are treated as immutable values (like cellPaint): a consumer that
60
+ * mutates a style object in place must hand out a new object for the change to paint.
61
+ * `getCellFormat` undeclared ⇒ undefined without touching the index (CF cannot exist without
62
+ * the format capability — the mount-time RangeError of the root).
63
+ * - `widthRelevantRules`: the autofit pre-filter — rules whose style sets `bold` or `italic`
64
+ * PLUS every `stopIfTrue` rule (a dropped rule can neither own a bold / italic property nor
65
+ * end the walk, so the bold / italic outcome of the sliced walk equals the painted one exactly;
66
+ * dropping a barrier would let autofit measure a font paint never applies), memoised by
67
+ * rules-array identity and returning the input itself when every rule is relevant.
68
+ *
69
+ * 条件付き書式の語彙・2 次元 256 × 256 バケット索引・条件評価・唯一の塗り書式合流点 (v0.8、
70
+ * ADR-13 — getConditionalFormats 能力)。root が (rules identity, renderVersion) 毎に
71
+ * `buildCfIndex(rules)` と `createCfMemo()` を構築し、塗り書式を要する全面 (paint / autofit /
72
+ * エディタ / 読み上げ / キャレットゲート / getPaintedFormat) が `resolvePaintedFormat` = base
73
+ * (getCellFormat) ⊕ CF を通る。React 非依存・全域関数 (spec 01 §8.2)。
74
+ * エンジン契約 (全て変異検証で封印): 256 行帯 × 256 列帯の 2 次元バケット (キー
75
+ * `rowBand * 0x10000 + colBand`) を初回参照で規則配列の 1 回走査により遅延実体化 (未参照バケットは
76
+ * Map に無い、全列規則は触れた行帯の 1 列帯だけ、バケット内は大域規則順 — stopIfTrue の根拠、
77
+ * buildCfIndex は O(1))、バケット内は触れた列毎の列スライス (キー `rowBand * 2^32 + col`、バケット
78
+ * list の 1 回走査で遅延実体化、行帯内でその列と交差する規則だけ — 256 列以下のシートは列帯が 1 つ
79
+ * なので、スライス無しでは全列規則が全セルで走査される。実体化は触れたバケット毎 O(規則数) +
80
+ * 触れた列毎 O(バケット)、セル毎の walk は O(行帯内でその列と交差する規則) = 全列規則ならその列の
81
+ * 規則だけ)、評価は配列順で containment を先に検査し、真の規則は全て寄与 (同一
82
+ * プロパティは先勝ち = Excel 優先順、stopIfTrue の真で打ち切り)、readText は text を要する条件
83
+ * だけが呼ぶ (customFormula は読まない)、cellValue は text と使う境界が全て厳密 10 進なら double
84
+ * 比較・他は文字列比較 (between は両端含み、b 欠落は不成立、語彙外 op は不成立)、text は完全一致、
85
+ * colorScale は parseNumber 非該当なら不適用・value 省略点は getRangeExtrema (未宣言は規則
86
+ * スキップ、宣言時はレンジ毎 1 回 + 規則 id で memo、null はスキップ)・寄与は
87
+ * `{ ...style, background }` (sRGB 補間: 両端のクランプ線形・逆順は対で入替・mid は両端の内側だけ
88
+ * 有効・解釈不能な色 / 非有限境界は null)、customFormula は数式を原文のまま
89
+ * evaluateFormula(formula, cell) へ (参照シフトなし、(規則 id, row, col) で memo、真 = true か
90
+ * 有限非 0 数、未宣言はスキップ)、不正規則 (未知 type / op、空・非交差レンジ、解釈不能な色) は
91
+ * 丸ごとスキップ (代替なし、唯一の fail-fast は非配列の rules = RangeError)、memo は各 Map
92
+ * 4,096 件で全消去 (セル text の厳密 10 進解析も (row, col) 毎に memo し cellValue / colorScale が
93
+ * 共有 — walk が正準文字列を再変換しないよう有限 double で保持、cellValue の境界解析は条件 object
94
+ * 毎の WeakMap で同じく double 保持 — 塗り intern と同じ不変契約)、塗り書式は
95
+ * 真の規則なしなら base を identity で返し、あれば applyCfStyle
96
+ * (= mergeFormat) の結果を base object 毎に intern (単一の identity 安定 style は style
97
+ * identity、他は累積 style のキー) するため同じアダプタ答えは同一 object、getCellFormat 未宣言は
98
+ * 索引に触れず undefined、widthRelevantRules は bold / italic を設定する規則と stopIfTrue の規則の
99
+ * autofit 用事前抽出 (落とした規則は bold / italic を持たず walk も止めないため bold / italic の結果は
100
+ * 塗りと厳密一致、障壁を落とすと塗りが当てないフォントで計る。配列 identity で memo、全件該当なら
101
+ * 入力そのもの)。
102
+ */
103
+
104
+ import { type CellCoord, type CellRange, rangeContains } from "./cellCoord.ts"
105
+ import { type CellFormat, mergeFormat, parseNumber } from "./cellFormat.ts"
106
+ import { normalizeColor } from "./cellPaint.ts"
107
+
108
+ /** The Sheets subset a rule may paint: background / colour / four text traits. Borders, alignment and number formats are out of scope (deviation row). / 規則が塗れる Sheets 部分集合: 背景 / 文字色 / 4 つの文字特性。罫線・揃え・数値書式は範囲外 (逸脱行)。 */
109
+ export type ConditionalFormatStyle = { background?: string; color?: string; bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean }
110
+
111
+ /** Ordered comparison vocabulary of `cellValue` (the same eight words as the validation engine); between / notBetween include both bounds. / cellValue の比較語彙 (検証エンジンと同じ 8 語)。between / notBetween は両端含み。 */
112
+ export type CompareOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "between" | "notBetween"
113
+
114
+ /** Needle vocabulary of the `text` condition (exact, case-sensitive). / text 条件の needle 語彙 (完全一致・大文字小文字区別)。 */
115
+ export type CfTextOp = "contains" | "notContains" | "startsWith" | "endsWith"
116
+
117
+ /** One colour-scale point; `value` omitted = the range extremum from `getRangeExtrema` (min / max) or the arithmetic midpoint (mid). / 色スケールの 1 点。value 省略 = getRangeExtrema のレンジ極値 (min / max) か算術中点 (mid)。 */
118
+ export type ScalePoint = { color: string; value?: number }
119
+
120
+ /** Numeric comparison when the text and the used bounds are strict decimals, else string comparison. / text と使う境界が厳密 10 進なら数値比較、他は文字列比較。 */
121
+ export type CellValueCondition = { type: "cellValue"; op: CompareOp; a: string; b?: string }
122
+
123
+ /** Case-sensitive needle test on the raw text. / 素の text に対する大文字小文字区別の needle 判定。 */
124
+ export type TextCondition = { type: "text"; op: CfTextOp; value: string }
125
+
126
+ /** The text is the empty string. / text が空文字。 */
127
+ export type EmptyCondition = { type: "empty" }
128
+
129
+ /** The text is not the empty string. / text が空文字でない。 */
130
+ export type NotEmptyCondition = { type: "notEmpty" }
131
+
132
+ /** sRGB interpolation between two or three points over the numeric value; non-numeric text does not apply. / 数値に対する 2 点または 3 点の sRGB 補間。非数値 text は不適用。 */
133
+ export type ColorScaleCondition = { type: "colorScale"; min: ScalePoint; mid?: ScalePoint; max: ScalePoint }
134
+
135
+ /** `adapter.evaluateFormula(formula, cell)` verbatim; truth = `true` or a finite non-zero number. / evaluateFormula(formula, cell) を原文のまま。真 = true か有限非 0 数。 */
136
+ export type CustomFormulaCondition = { type: "customFormula"; formula: string }
137
+
138
+ /** The condition vocabulary (six members). / 条件の語彙 (6 メンバー)。 */
139
+ export type CfCondition = CellValueCondition | TextCondition | EmptyCondition | NotEmptyCondition | ColorScaleCondition | CustomFormulaCondition
140
+
141
+ /** One rule as the consumer stores it: consumer-scoped `id`, the ranges it applies to, its condition, the style it paints, and whether a match ends the walk. / consumer が保持する規則 1 件: consumer スコープの id、適用レンジ、条件、塗る style、真で打ち切るか。 */
142
+ export type ConditionalFormatRule = { id: string; ranges: readonly CellRange[]; condition: CfCondition; style: ConditionalFormatStyle; stopIfTrue?: boolean }
143
+
144
+ /** One rule as a paste hands it to the consumer (`PasteEditContext.conditionalFormats`): the source rule's id travels as `sourceRuleId`, the consumer mints the new id. / paste が consumer へ渡す規則 1 件: 元規則の id は sourceRuleId、新 id は consumer が採番。 */
145
+ export type ConditionalFormatRuleInput = { sourceRuleId: string; ranges: readonly CellRange[]; condition: CfCondition; style: ConditionalFormatStyle; stopIfTrue?: boolean }
146
+
147
+ /** Numeric extrema of a range (the `getRangeExtrema` answer). / レンジの数値極値 (getRangeExtrema の答え)。 */
148
+ export type RangeExtrema = { min: number; max: number }
149
+
150
+ /** What `evaluateFormula` may answer. / evaluateFormula が返し得る値。 */
151
+ export type FormulaValue = string | number | boolean | null
152
+
153
+ /**
154
+ * The three adapter reads this engine performs — a structural subset of SpreadsheetDataAdapter
155
+ * (any adapter is passable; declaring a member is the capability branch, not a fallback).
156
+ * このエンジンが行うアダプタ読み 3 種 — SpreadsheetDataAdapter の構造的部分集合 (どのアダプタも
157
+ * 渡せる。メンバー宣言が能力分岐であり、フォールバックではない)。
158
+ */
159
+ export type ConditionalFormatAdapter = {
160
+ getCellFormat?: (row: number, col: number) => CellFormat | undefined
161
+ getRangeExtrema?: (range: CellRange) => RangeExtrema | null
162
+ evaluateFormula?: (formula: string, cell: CellCoord) => FormulaValue
163
+ }
164
+
165
+ /** Rows per bucket band. / バケット行帯の行数。 */
166
+ export const CF_BUCKET_ROWS = 256
167
+
168
+ /** Columns per bucket band. / バケット列帯の列数。 */
169
+ export const CF_BUCKET_COLS = 256
170
+
171
+ /** Multiplier of the row band in a column-slice key (`rowBand * 2^32 + col`): columns below 2^32 and row bands below 2^21 keep the key exact in a double. / 列スライスキー (`rowBand * 2^32 + col`) の行帯乗数: 列 < 2^32・行帯 < 2^21 でキーは double で正確。 */
172
+ const COLUMN_KEY_BASE = 2 ** 32
173
+
174
+ /** Whole-Map clearing threshold of every bounded memo here (the CfMemo maps, the painted-format key intern). / ここにある有界 memo (CfMemo の Map、塗り書式のキー intern) の全消去しきい値。 */
175
+ export const CF_MEMO_LIMIT = 4096
176
+
177
+ /**
178
+ * The 2-D bucket index: `rules` in global order, the lazily materialised buckets keyed by
179
+ * `rowBand * 0x10000 + colBand` (row / column bands of 256) and the lazily materialised column
180
+ * slices keyed by `rowBand * 2^32 + col`. A bucket list holds, in global order, every rule with
181
+ * a range intersecting the bucket rectangle; a column slice holds, in global order, the rules of
182
+ * the bucket whose ranges intersect that one column within the row band; each Map holds only the
183
+ * entries touched so far.
184
+ * 2 次元バケット索引: 大域順の `rules`、`rowBand * 0x10000 + colBand` (256 行帯 / 列帯) をキーに
185
+ * 遅延実体化するバケット、`rowBand * 2^32 + col` をキーに遅延実体化する列スライス。バケットの
186
+ * list はバケット矩形と交差する規則を大域順で持ち、列スライスはそのバケットのうち行帯内でその
187
+ * 1 列と交差する規則を大域順で持ち、各 Map には参照済みの項目だけがある。
188
+ */
189
+ export type CfIndex = { readonly rules: readonly ConditionalFormatRule[]; readonly buckets: Map<number, readonly ConditionalFormatRule[]>; readonly columns: Map<number, readonly ConditionalFormatRule[]> }
190
+
191
+ /**
192
+ * The bounded per-renderVersion memo: `extrema` per rule id (colorScale — a `null` answer is a
193
+ * memoised "no numeric value"), `formulas` per `rule id \u0000 row \u0000 col` (customFormula),
194
+ * `numbers` per `row \u0000 col` (the strict-decimal parse of the cell's text as its finite
195
+ * double — a `null` answer is a memoised "not a number"; cellValue and colorScale share it, so a
196
+ * walk of N rules parses the text once, not N times, and never re-converts a canonical string).
197
+ * Each Map is cleared whole when it reaches CF_MEMO_LIMIT.
198
+ * renderVersion 毎の有界 memo: `extrema` は規則 id 毎 (colorScale — null も「数値なし」の登録済み
199
+ * 答え)、`formulas` は `規則 id \u0000 row \u0000 col` 毎 (customFormula)、`numbers` は
200
+ * `row \u0000 col` 毎 (セル text の厳密 10 進解析の有限 double — null も「非数値」の登録済み答え。
201
+ * cellValue と colorScale が共有し、N 規則の walk でも解析は 1 回で正準文字列の再変換もなし)。
202
+ * 各 Map は CF_MEMO_LIMIT で全消去。
203
+ */
204
+ export type CfMemo = { readonly extrema: Map<string, RangeExtrema | null>; readonly formulas: Map<string, FormulaValue>; readonly numbers: Map<string, number | null> }
205
+
206
+ /** Shared frozen empty list so empty buckets / empty answers never allocate. / 空バケット / 空答えが確保しない共有の凍結空配列。 */
207
+ const EMPTY_RULES: readonly ConditionalFormatRule[] = Object.freeze([])
208
+
209
+ /** The style keys in a fixed order (the accumulated-style intern key and the earlier-wins overlay iterate it). / 固定順の style キー (累積 style の intern キーと先勝ち合成が走査)。 */
210
+ const STYLE_KEYS = ["background", "color", "bold", "italic", "underline", "strikethrough"] as const satisfies readonly (keyof ConditionalFormatStyle)[]
211
+
212
+ /** Compile-time proof that STYLE_KEYS lists EVERY style key (a key added to the type but not here would be silently dropped by the overlay). / STYLE_KEYS が全 style キーを網羅することの型水準の証明 (型に増えたキーが表に無いと合成で黙って落ちる)。 */
213
+ const styleKeysCoverEveryKey: [Exclude<keyof ConditionalFormatStyle, (typeof STYLE_KEYS)[number]>] extends [never] ? true : never = true
214
+ void styleKeysCoverEveryKey
215
+
216
+ /** Fail-fast guard shared by the two array-taking entry points (a non-array would silently mean "no rules"). / 配列を取る 2 つの入口が共有する fail-fast (非配列は黙って「規則なし」になってしまう)。 */
217
+ const assertRules = (rules: readonly ConditionalFormatRule[], fn: string): void => {
218
+ if (!Array.isArray(rules)) {
219
+ throw new RangeError(`[spreadsheet] ${fn} requires an array of conditional-format rules, received ${typeof rules}`)
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Registers a memo / index value, clearing the whole Map first when it has reached
225
+ * CF_MEMO_LIMIT; returns the value. Correct for every Map here because each entry is a pure
226
+ * function of its key (plus the rules array / renderVersion the Map is scoped to) — a cleared
227
+ * entry recomputes identically on its next touch.
228
+ * memo / 索引値を登録 (CF_MEMO_LIMIT 到達時は先に全消去)、値を返す。ここにある全 Map で正しい
229
+ * のは、各項目がキー (と Map のスコープである rules 配列 / renderVersion) の純関数であるため —
230
+ * 消去された項目は次の参照で同一内容に再計算される。
231
+ */
232
+ const memoSet = <K, V>(map: Map<K, V>, key: K, value: V): V => {
233
+ // 上限到達で全消去 (個別追い出しの帳簿を持たない — 語彙は renderVersion 単位で有限)
234
+ if (map.size >= CF_MEMO_LIMIT) {
235
+ map.clear()
236
+ }
237
+ map.set(key, value)
238
+ return value
239
+ }
240
+
241
+ /**
242
+ * Builds the 2-D bucket index for one rules array — O(1): buckets and column slices materialise
243
+ * lazily on first touch (`rulesForCell` / `resolvePaintedFormat`). Build once per (rules
244
+ * identity, renderVersion); the index never re-reads the array after a bucket is materialised.
245
+ * 規則配列 1 つの 2 次元バケット索引を構築 — O(1): バケットと列スライスは初回参照 (rulesForCell /
246
+ * resolvePaintedFormat) で遅延実体化。(rules identity, renderVersion) 毎に 1 回構築し、実体化後は
247
+ * 配列を再読しない。
248
+ */
249
+ export const buildCfIndex = (rules: readonly ConditionalFormatRule[]): CfIndex => {
250
+ assertRules(rules, "buildCfIndex")
251
+ return { rules, buckets: new Map(), columns: new Map() }
252
+ }
253
+
254
+ /** Whether any range of the rule intersects the bucket rectangle (a NaN field compares false, so a malformed range never indexes). / 規則のいずれかのレンジがバケット矩形と交差するか (NaN は比較 false で不正レンジは索引されない)。 */
255
+ const intersectsBucket = (ranges: readonly CellRange[], top: number, bottom: number, left: number, right: number): boolean => {
256
+ for (const range of ranges) {
257
+ if (range.startRow <= bottom && range.endRow >= top && range.startCol <= right && range.endCol >= left) {
258
+ return true
259
+ }
260
+ }
261
+ return false
262
+ }
263
+
264
+ /** The bucket list of the cell, materialised on first touch by one ordered scan of `rules` and stored in the bounded buckets Map (memoSet — cleared whole at CF_MEMO_LIMIT). / セルのバケット list (初回参照で rules を 1 回順走査して実体化し、有界の buckets Map へ登録 — memoSet が CF_MEMO_LIMIT で全消去)。 */
265
+ const bucketFor = (index: CfIndex, row: number, col: number): readonly ConditionalFormatRule[] => {
266
+ const rowBand = Math.floor(row / CF_BUCKET_ROWS)
267
+ const colBand = Math.floor(col / CF_BUCKET_COLS)
268
+ const key = rowBand * 0x10000 + colBand
269
+ const hit = index.buckets.get(key)
270
+ if (hit !== undefined) {
271
+ return hit
272
+ }
273
+ // 初回参照: 規則配列を 1 回だけ順に走査し、バケット矩形と交差する規則を配列順のまま積む (大域規則順の保存 — stopIfTrue の根拠)
274
+ const top = rowBand * CF_BUCKET_ROWS
275
+ const left = colBand * CF_BUCKET_COLS
276
+ const list: ConditionalFormatRule[] = []
277
+ for (const rule of index.rules) {
278
+ if (intersectsBucket(rule.ranges, top, top + CF_BUCKET_ROWS - 1, left, left + CF_BUCKET_COLS - 1)) {
279
+ list.push(rule)
280
+ }
281
+ }
282
+ // 有界登録 — 索引 Map を無限に育てると編集なしの長時間スクロールが冷メモだけでヒープを単調に増やす
283
+ return memoSet(index.buckets, key, list.length === 0 ? EMPTY_RULES : list)
284
+ }
285
+
286
+ /**
287
+ * The column slice of the cell — the rules of its bucket whose ranges intersect the cell's
288
+ * column within the 256-row band, in global order — materialised on first touch by one ordered
289
+ * scan of the bucket list (never of the rules array) and stored in the bounded columns Map
290
+ * (memoSet — cleared whole at CF_MEMO_LIMIT). The shared frozen empty list when none.
291
+ * セルの列スライス — そのバケットのうち 256 行帯内でセルの列と交差する規則を大域順で —
292
+ * 初回参照でバケット list を 1 回順走査して実体化し (規則配列は走査しない)、有界の columns Map
293
+ * へ登録 (memoSet が CF_MEMO_LIMIT で全消去)。無ければ共有の凍結空配列。
294
+ */
295
+ const columnSliceFor = (index: CfIndex, row: number, col: number): readonly ConditionalFormatRule[] => {
296
+ const rowBand = Math.floor(row / CF_BUCKET_ROWS)
297
+ const key = rowBand * COLUMN_KEY_BASE + col
298
+ const hit = index.columns.get(key)
299
+ if (hit !== undefined) {
300
+ return hit
301
+ }
302
+ // 初回参照: バケット (必要なら実体化) を 1 回だけ順走査し、行帯内でこの 1 列と交差する規則を配列順のまま積む (大域規則順の保存)
303
+ const bucket = bucketFor(index, row, col)
304
+ const top = rowBand * CF_BUCKET_ROWS
305
+ const list: ConditionalFormatRule[] = []
306
+ for (const rule of bucket) {
307
+ if (intersectsBucket(rule.ranges, top, top + CF_BUCKET_ROWS - 1, col, col)) {
308
+ list.push(rule)
309
+ }
310
+ }
311
+ // 有界登録 — 列スライスは rowBands × cols の幾何上限まで育ち得るため、バケット以上に有界化が効く
312
+ return memoSet(index.columns, key, list.length === 0 ? EMPTY_RULES : list)
313
+ }
314
+
315
+ /** Whether any range of the rule contains the cell. / 規則のいずれかのレンジがセルを含むか。 */
316
+ const containsCell = (ranges: readonly CellRange[], cell: CellCoord): boolean => {
317
+ for (const range of ranges) {
318
+ if (rangeContains(range, cell)) {
319
+ return true
320
+ }
321
+ }
322
+ return false
323
+ }
324
+
325
+ /**
326
+ * The rules whose ranges contain the cell, in global rule order — the cell's column slice
327
+ * (bucket and slice materialised on first touch) narrowed by range containment. A shared frozen
328
+ * empty list when none.
329
+ * セルを含むレンジを持つ規則を大域規則順で — セルの列スライス (バケットとスライスは初回参照で
330
+ * 実体化) を containment で絞る。無ければ共有の凍結空配列。
331
+ */
332
+ export const rulesForCell = (index: CfIndex, row: number, col: number): readonly ConditionalFormatRule[] => {
333
+ const slice = columnSliceFor(index, row, col)
334
+ if (slice.length === 0) {
335
+ return slice
336
+ }
337
+ const cell: CellCoord = { row, col }
338
+ const out = slice.filter((rule) => containsCell(rule.ranges, cell))
339
+ return out.length === 0 ? EMPTY_RULES : out
340
+ }
341
+
342
+ /** A fresh empty memo (one per renderVersion). / 新しい空 memo (renderVersion 毎に 1 つ)。 */
343
+ export const createCfMemo = (): CfMemo => ({ extrema: new Map(), formulas: new Map(), numbers: new Map() })
344
+
345
+ /** Three-way comparison of two values of the same primitive kind. / 同種原始値 2 つの三値比較。 */
346
+ const order = <T extends number | string>(x: T, y: T): -1 | 0 | 1 => (x < y ? -1 : x > y ? 1 : 0)
347
+
348
+ /** The op verdict from the text's order against `a` (and against `b` — null when unused or missing). / text の a との順序 (と b との順序 — 未使用 / 欠落は null) からの op 判定。 */
349
+ const verdict = (op: CompareOp, toA: -1 | 0 | 1, toB: -1 | 0 | 1 | null): boolean => {
350
+ switch (op) {
351
+ case "eq":
352
+ return toA === 0
353
+ case "neq":
354
+ return toA !== 0
355
+ case "gt":
356
+ return toA > 0
357
+ case "gte":
358
+ return toA >= 0
359
+ case "lt":
360
+ return toA < 0
361
+ case "lte":
362
+ return toA <= 0
363
+ case "between":
364
+ return toB !== null && toA >= 0 && toB <= 0
365
+ case "notBetween":
366
+ return toB !== null && (toA < 0 || toB > 0)
367
+ default:
368
+ // 語彙外の op は不成立 (fail-soft)
369
+ return false
370
+ }
371
+ }
372
+
373
+ /** The parsed numeric bounds of one cellValue condition as finite doubles (`null` = not a strict decimal, or `b` absent). / cellValue 条件 1 件の解析済み数値境界 (有限 double、null = 厳密 10 進でない、または b 不在)。 */
374
+ type ParsedBounds = { readonly a: number | null; readonly b: number | null }
375
+
376
+ /** Bounds memo keyed by condition-object identity (conditions are immutable values, like styles / formats). / 条件 object identity をキーとする境界 memo (条件は style / 書式と同じ不変値)。 */
377
+ const boundsMemo = new WeakMap<CellValueCondition, ParsedBounds>()
378
+
379
+ /** The finite double of a strict-decimal parse (`null` passes through) — the hot walk compares doubles, never canonical strings. / 厳密 10 進解析の有限 double (null はそのまま) — ホットな walk は正準文字列でなく double を比較。 */
380
+ const toDouble = (parsed: Intl.StringNumericLiteral | null): number | null => (parsed === null ? null : Number(parsed))
381
+
382
+ /**
383
+ * The numeric bounds of a cellValue condition, parsed ONCE per condition object — a whole-column
384
+ * rule walked over 1,200 window cells re-parses nothing (the dominant redundant cost of the
385
+ * cellValue walk). The WeakMap relies on the same immutability contract as the painted-format
386
+ * intern: an in-place mutation of a condition must hand out a new object to take effect.
387
+ * cellValue 条件の数値境界を条件 object 毎に 1 回だけ解析 — 全列規則が窓 1,200 セルを歩いても
388
+ * 再解析ゼロ (cellValue walk の支配的な冗長コスト)。WeakMap は塗り intern と同じ不変契約に依拠:
389
+ * 条件の in-place 変更は新 object の払い出しで初めて効く。
390
+ */
391
+ const parsedBounds = (condition: CellValueCondition): ParsedBounds => {
392
+ const hit = boundsMemo.get(condition)
393
+ if (hit !== undefined) {
394
+ return hit
395
+ }
396
+ const b = condition.b
397
+ const bounds: ParsedBounds = { a: toDouble(parseNumber(condition.a)), b: b === undefined ? null : toDouble(parseNumber(b)) }
398
+ boundsMemo.set(condition, bounds)
399
+ return bounds
400
+ }
401
+
402
+ /**
403
+ * The memoised strict-decimal parse of the cell's text as its finite double — once per
404
+ * (row, col) per memo (bounded via memoSet), shared by every cellValue and colorScale rule of
405
+ * the cell's walk (which then compares / interpolates the double with no further conversion).
406
+ * Text is stable within a renderVersion (the same contract `formulas` / `extrema` already rely
407
+ * on).
408
+ * セル text の厳密 10 進解析の有限 double の memo — memo につき (row, col) 毎に 1 回 (memoSet で
409
+ * 有界)。セルの walk 上の全 cellValue / colorScale 規則が共有し、追加変換なしで double を比較 /
410
+ * 補間。text は renderVersion 内で安定 (`formulas` / `extrema` が既に依拠する契約と同一)。
411
+ */
412
+ const cellNumber = (memo: CfMemo, cell: CellCoord, readText: () => string): number | null => {
413
+ const key = `${cell.row}\u0000${cell.col}`
414
+ const cached = memo.numbers.get(key)
415
+ // null も「非数値」の登録済み答え — undefined だけが未登録 (undefined は決して格納しない)
416
+ if (cached !== undefined) {
417
+ return cached
418
+ }
419
+ return memoSet(memo.numbers, key, toDouble(parseNumber(readText())))
420
+ }
421
+
422
+ /** `cellValue`: double comparison when the text and every used bound are strict decimals, else string comparison of the raw strings; the text double is memoised per (row, col) and the bound doubles per condition object. / cellValue: text と使う境界が全て厳密 10 進なら double 比較、他は素の文字列比較。text の double は (row, col) 毎、境界の double は条件 object 毎に memo。 */
423
+ const matchCellValue = (condition: CellValueCondition, cell: CellCoord, readText: () => string, memo: CfMemo): boolean => {
424
+ const usesB = condition.op === "between" || condition.op === "notBetween"
425
+ const bounds = parsedBounds(condition)
426
+ const numText = cellNumber(memo, cell, readText)
427
+ const numB = usesB ? bounds.b : null
428
+ // 数値モード: text と使う境界が全て厳密 10 進 (double 比較 — 表示エンジンの十分条件)
429
+ if (numText !== null && bounds.a !== null && (!usesB || numB !== null)) {
430
+ return verdict(condition.op, order(numText, bounds.a), numB === null ? null : order(numText, numB))
431
+ }
432
+ // 文字列モード: 素の文字列のコード単位順 (trim もしない)。両端 op の b 欠落は null の toB として verdict が不成立にする (書きかけの規則は塗らない)
433
+ const text = readText()
434
+ const b = condition.b
435
+ return verdict(condition.op, order(text, condition.a), b === undefined ? null : order(text, b))
436
+ }
437
+
438
+ /** `text`: exact, case-sensitive needle ops; an op outside the vocabulary never matches. / text: 完全一致の needle op。語彙外 op は不成立。 */
439
+ const matchText = (condition: TextCondition, text: string): boolean => {
440
+ switch (condition.op) {
441
+ case "contains":
442
+ return text.includes(condition.value)
443
+ case "notContains":
444
+ return !text.includes(condition.value)
445
+ case "startsWith":
446
+ return text.startsWith(condition.value)
447
+ case "endsWith":
448
+ return text.endsWith(condition.value)
449
+ default:
450
+ // 語彙外の op は不成立 (fail-soft)
451
+ return false
452
+ }
453
+ }
454
+
455
+ /** A colour as four 0-255 channels (alpha 255 when opaque). / 色を 0-255 の 4 チャネルで (不透明は alpha 255)。 */
456
+ type Rgba = readonly [number, number, number, number]
457
+
458
+ /** Parses through the paint engine's normaliser so every colour form it accepts interpolates; null when it rejects the text. / 塗りエンジンの正規化を通し、受理される全色形を補間可能にする。拒否は null。 */
459
+ const parseRgba = (color: string): Rgba | null => {
460
+ const hex = normalizeColor(color)
461
+ if (hex === null) {
462
+ return null
463
+ }
464
+ // "#rrggbb" か "#rrggbbaa" (不透明 alpha は 6 桁に畳まれている)
465
+ const channel = (at: number): number => Number.parseInt(hex.slice(at, at + 2), 16)
466
+ return [channel(1), channel(3), channel(5), hex.length === 9 ? channel(7) : 255]
467
+ }
468
+
469
+ /** One channel → two lowercase hex digits. / 1 チャネル → 小文字 16 進 2 桁。 */
470
+ const byteToHex = (value: number): string => value.toString(16).padStart(2, "0")
471
+
472
+ /** Linear sRGB blend at `t` ∈ [0, 1], rounded per channel; opaque results collapse to six digits (the paint engine's canonical spelling). / t ∈ [0, 1] の線形 sRGB 混合 (チャネル毎に丸め)。不透明は 6 桁へ畳む (塗りエンジンの正準綴り)。 */
473
+ const blend = (from: Rgba, to: Rgba, t: number): string => {
474
+ const at = (i: 0 | 1 | 2 | 3): number => Math.round(from[i] + (to[i] - from[i]) * t)
475
+ const rgb = `#${byteToHex(at(0))}${byteToHex(at(1))}${byteToHex(at(2))}`
476
+ const alpha = at(3)
477
+ return alpha === 255 ? rgb : `${rgb}${byteToHex(alpha)}`
478
+ }
479
+
480
+ /** Position of `value` in [a, b] clamped to [0, 1]; a degenerate segment (a === b) maps values at or below it to 0 and the rest to 1. / [a, b] 内の value の位置を [0, 1] にクランプ。退化区間 (a === b) は a 以下を 0、他を 1。 */
481
+ const ratio = (value: number, a: number, b: number): number => (a === b ? (value <= a ? 0 : 1) : Math.min(1, Math.max(0, (value - a) / (b - a))))
482
+
483
+ /**
484
+ * sRGB interpolation between scale points: a point without `value` takes the corresponding
485
+ * extremum (`mid` without `value` = the arithmetic midpoint); ends given in inverted order are
486
+ * swapped as (value, colour) pairs; `mid` is honoured only when its value lies strictly between
487
+ * the ends; the value is clamped to the ends. Null (the rule does not apply) when the value or a
488
+ * used bound is not finite, or any point's colour does not normalise.
489
+ * スケール点間の sRGB 補間: value 省略点は対応する極値 (mid の省略は算術中点)、逆順の両端は
490
+ * (value, 色) の対で入替、mid は両端の厳密な内側にあるときだけ有効、value は両端でクランプ。
491
+ * value / 使う境界が非有限、またはいずれかの点の色が正規化できなければ null (規則は不適用)。
492
+ */
493
+ export const interpolateScale = (value: number, min: ScalePoint, mid: ScalePoint | undefined, max: ScalePoint, extrema: RangeExtrema): string | null => {
494
+ let lo = min.value === undefined ? extrema.min : min.value
495
+ let hi = max.value === undefined ? extrema.max : max.value
496
+ let colorLo = parseRgba(min.color)
497
+ let colorHi = parseRgba(max.color)
498
+ const colorMid = mid === undefined ? undefined : parseRgba(mid.color)
499
+ // 非有限の値 / 境界、正規化できない色はどれも補間不能 — 規則は不適用 (代替色を作らない)
500
+ const finite = Number.isFinite(value) && Number.isFinite(lo) && Number.isFinite(hi)
501
+ if (!finite || colorLo === null || colorHi === null || colorMid === null) {
502
+ return null
503
+ }
504
+ // 逆順の両端は (value, 色) の対で入替え、以後は昇順軸で扱う (min 側の色は min 側の値に付いたまま)
505
+ if (lo > hi) {
506
+ const swapValue = lo
507
+ lo = hi
508
+ hi = swapValue
509
+ const swapColor = colorLo
510
+ colorLo = colorHi
511
+ colorHi = swapColor
512
+ }
513
+ if (mid !== undefined && colorMid !== undefined) {
514
+ const m = mid.value === undefined ? (lo + hi) / 2 : mid.value
515
+ // 中間点は両端の厳密な内側にあるときだけ 2 区間補間 (外側 / 一致 / 非有限は 2 点補間へ)
516
+ if (lo < m && m < hi) {
517
+ return value <= m ? blend(colorLo, colorMid, ratio(value, lo, m)) : blend(colorMid, colorHi, ratio(value, m, hi))
518
+ }
519
+ }
520
+ return blend(colorLo, colorHi, ratio(value, lo, hi))
521
+ }
522
+
523
+ /**
524
+ * The extrema a colorScale rule interpolates over, memoised per rule id: both `value`s given ⇒
525
+ * those values (no adapter read); otherwise `getRangeExtrema` once per range of the rule
526
+ * (undeclared ⇒ null, never substituted; a `null` answer from any range ⇒ null, memoised).
527
+ * colorScale 規則が補間する極値 (規則 id で memo): 両 value 指定ならその値 (アダプタ読みなし)、
528
+ * 他は規則のレンジ毎に getRangeExtrema 1 回 (未宣言は null で代替なし、いずれかの null 答えは
529
+ * null として memo)。
530
+ */
531
+ const resolveExtrema = (rule: ConditionalFormatRule, condition: ColorScaleCondition, adapter: ConditionalFormatAdapter, memo: CfMemo): RangeExtrema | null => {
532
+ const cached = memo.extrema.get(rule.id)
533
+ // null も「数値なし」の登録済み答え — undefined だけが未登録 (undefined は決して格納しない)
534
+ if (cached !== undefined) {
535
+ return cached
536
+ }
537
+ const { min, max } = condition
538
+ // 両端の value が揃えばそれが極値 — アダプタは読まない
539
+ if (min.value !== undefined && max.value !== undefined) {
540
+ return memoSet(memo.extrema, rule.id, { min: min.value, max: max.value })
541
+ }
542
+ // 能力分岐: getRangeExtrema 未宣言なら規則ごとスキップ (代替値を作らない)
543
+ if (adapter.getRangeExtrema === undefined) {
544
+ return null
545
+ }
546
+ let lo = Number.POSITIVE_INFINITY
547
+ let hi = Number.NEGATIVE_INFINITY
548
+ for (const range of rule.ranges) {
549
+ const answer = adapter.getRangeExtrema(range)
550
+ // いずれかのレンジに数値が無ければ規則全体が不適用 (memo して再読しない)
551
+ if (answer === null) {
552
+ return memoSet(memo.extrema, rule.id, null)
553
+ }
554
+ lo = Math.min(lo, answer.min)
555
+ hi = Math.max(hi, answer.max)
556
+ }
557
+ return memoSet(memo.extrema, rule.id, { min: lo, max: hi })
558
+ }
559
+
560
+ /** The interpolated background of a colorScale rule for the cell, or null when the rule does not apply; the text parse is the per-(row, col) memo shared with cellValue. / colorScale 規則のセルに対する補間背景色、不適用なら null。text の解析は cellValue と共有の (row, col) memo。 */
561
+ const scaleBackground = (rule: ConditionalFormatRule, condition: ColorScaleCondition, cell: CellCoord, readText: () => string, adapter: ConditionalFormatAdapter, memo: CfMemo): string | null => {
562
+ const parsed = cellNumber(memo, cell, readText)
563
+ // 非数値 text には色スケールが無い
564
+ if (parsed === null) {
565
+ return null
566
+ }
567
+ const extrema = resolveExtrema(rule, condition, adapter, memo)
568
+ if (extrema === null) {
569
+ return null
570
+ }
571
+ return interpolateScale(parsed, condition.min, condition.mid, condition.max, extrema)
572
+ }
573
+
574
+ /** `customFormula` truth for the cell: the memoised `evaluateFormula(formula, cell)` answer is `true` or a finite non-zero number; undeclared ⇒ false. / customFormula のセルに対する真偽: memo 済み evaluateFormula(formula, cell) の答えが true か有限非 0 数。未宣言は false。 */
575
+ const formulaTruth = (rule: ConditionalFormatRule, condition: CustomFormulaCondition, cell: CellCoord, adapter: ConditionalFormatAdapter, memo: CfMemo): boolean => {
576
+ // 能力分岐: evaluateFormula 未宣言なら規則ごとスキップ
577
+ if (adapter.evaluateFormula === undefined) {
578
+ return false
579
+ }
580
+ const key = `${rule.id}\u0000${cell.row}\u0000${cell.col}`
581
+ const cached = memo.formulas.get(key)
582
+ // FormulaValue は undefined を含まないので undefined だけが未登録 (null / false は登録済みの答え)
583
+ const value = cached !== undefined ? cached : memoSet(memo.formulas, key, adapter.evaluateFormula(condition.formula, cell))
584
+ return value === true || (typeof value === "number" && Number.isFinite(value) && value !== 0)
585
+ }
586
+
587
+ /**
588
+ * Evaluates one rule for one cell: the rule's `style` BY IDENTITY when the condition holds
589
+ * (colorScale: a fresh `{ ...style, background }` with the interpolated colour), null when it
590
+ * does not or the rule is skipped (unknown type / op, undeclared capability, non-numeric text
591
+ * for a scale, unparseable scale colour). `readText` is called only by conditions that need the
592
+ * text; the memo serves `getRangeExtrema` / `evaluateFormula` and the per-(row, col) text parse
593
+ * of `cellValue` / `colorScale`.
594
+ * 規則 1 件をセル 1 つで評価: 条件成立なら規則の `style` を identity で (colorScale は補間色を
595
+ * 載せた新規 `{ ...style, background }`)、不成立 / スキップ (未知 type / op、能力未宣言、スケールの
596
+ * 非数値 text、解釈不能なスケール色) なら null。readText は text を要する条件だけが呼び、memo は
597
+ * getRangeExtrema / evaluateFormula と cellValue / colorScale の (row, col) 毎 text 解析に仕える。
598
+ */
599
+ export const evaluateCondition = (rule: ConditionalFormatRule, cell: CellCoord, readText: () => string, adapter: ConditionalFormatAdapter, memo: CfMemo): ConditionalFormatStyle | null => {
600
+ const condition = rule.condition
601
+ switch (condition.type) {
602
+ case "cellValue":
603
+ return matchCellValue(condition, cell, readText, memo) ? rule.style : null
604
+ case "text":
605
+ return matchText(condition, readText()) ? rule.style : null
606
+ case "empty":
607
+ return readText() === "" ? rule.style : null
608
+ case "notEmpty":
609
+ return readText() !== "" ? rule.style : null
610
+ case "colorScale": {
611
+ const background = scaleBackground(rule, condition, cell, readText, adapter, memo)
612
+ return background === null ? null : { ...rule.style, background }
613
+ }
614
+ case "customFormula":
615
+ return formulaTruth(rule, condition, cell, adapter, memo) ? rule.style : null
616
+ default:
617
+ // 語彙外の type は規則ごとスキップ (fail-soft)
618
+ return null
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Applies a CF style over a base format — the style is a partial override (`mergeFormat`
624
+ * semantics: a set key replaces, an unset key keeps the base), the result is canonical, `base`
625
+ * BY IDENTITY when nothing changes, undefined when nothing survives.
626
+ * CF style を base 書式へ上書き合成 — style は部分上書き (mergeFormat 意味論: 設定キーは置換、
627
+ * 未設定キーは base 保持)、結果は正準、無変化なら `base` を identity で、何も残らなければ undefined。
628
+ */
629
+ export const applyCfStyle = (base: CellFormat | undefined, style: ConditionalFormatStyle): CellFormat | undefined => mergeFormat(base, style)
630
+
631
+ /** Typed single-key write on a style (a union-typed key cannot be assigned directly under strict mode). / style への型付き単一キー書込 (union 型のキーは strict では直接代入できない)。 */
632
+ const setStyleKey = <K extends keyof ConditionalFormatStyle>(target: ConditionalFormatStyle, key: K, value: ConditionalFormatStyle[K]): void => {
633
+ target[key] = value
634
+ }
635
+
636
+ /**
637
+ * Earlier-wins overlay: fills into `acc` only the keys it does not set yet, copying on the first
638
+ * fill; returns `acc` ITSELF when `style` adds nothing (identity stays meaningful).
639
+ * 先勝ち合成: acc が未設定のキーだけを補い、初回の補填で複製する。style が何も足さなければ
640
+ * `acc` そのものを返す (identity が意味を保つ)。
641
+ */
642
+ const overlayUnder = (acc: ConditionalFormatStyle, style: ConditionalFormatStyle): ConditionalFormatStyle => {
643
+ let out = acc
644
+ for (const key of STYLE_KEYS) {
645
+ if (acc[key] === undefined && style[key] !== undefined) {
646
+ // 先勝ち: 先行規則が未設定のキーだけ後方規則から補う (初回の補填で初めて複製)
647
+ if (out === acc) {
648
+ out = { ...acc }
649
+ }
650
+ setStyleKey(out, key, style[key])
651
+ }
652
+ }
653
+ return out
654
+ }
655
+
656
+ /** Intern key of an accumulated style: undefined / false / "" stay distinct (undefined keeps the base, false clears, "" is an empty colour text). / 累積 style の intern キー: undefined / false / "" を区別 (undefined は base 保持、false は消去、"" は空の色文字列)。 */
657
+ const styleKey = (style: ConditionalFormatStyle): string => {
658
+ let key = ""
659
+ for (const k of STYLE_KEYS) {
660
+ const value = style[k]
661
+ key += value === undefined ? "" : typeof value === "boolean" ? (value ? "t" : "f") : `s${value.length}:${value}`
662
+ key += "\u0000"
663
+ }
664
+ return key
665
+ }
666
+
667
+ /** Per-base intern store: by style identity (one identity-stable contribution) and by accumulated-style key (bounded). / base 毎の intern 保管: style identity (単一の identity 安定寄与) とキー (有界)。 */
668
+ type PaintedStore = { readonly byStyle: WeakMap<ConditionalFormatStyle, CellFormat>; readonly byKey: Map<string, CellFormat> }
669
+
670
+ /** Module-level intern keyed by the base format OBJECT (undefined base ⇒ the NO_BASE sentinel), surviving index rebuilds. / base 書式 object をキーにするモジュール水準の intern (undefined base は NO_BASE 番兵)。索引再構築を跨いで生きる。 */
671
+ const paintedStores = new WeakMap<CellFormat, PaintedStore>()
672
+
673
+ /** Sentinel key for an undefined base. / undefined base の番兵キー。 */
674
+ const NO_BASE: CellFormat = Object.freeze({})
675
+
676
+ /** The intern store of a base (created on first use). / base の intern 保管 (初回利用で生成)。 */
677
+ const storeFor = (base: CellFormat | undefined): PaintedStore => {
678
+ const key = base === undefined ? NO_BASE : base
679
+ const hit = paintedStores.get(key)
680
+ if (hit !== undefined) {
681
+ return hit
682
+ }
683
+ const store: PaintedStore = { byStyle: new WeakMap(), byKey: new Map() }
684
+ paintedStores.set(key, store)
685
+ return store
686
+ }
687
+
688
+ /** Interned `applyCfStyle(base, style)`: by style identity when the contribution is identity-stable, else by the accumulated style's key; an undefined result is not stored (recomputing it is free). / intern 済み applyCfStyle(base, style): identity 安定なら style identity、他は累積 style のキー。undefined の結果は格納しない (再計算は無料)。 */
689
+ const internPainted = (base: CellFormat | undefined, style: ConditionalFormatStyle, identityStable: boolean): CellFormat | undefined => {
690
+ const store = storeFor(base)
691
+ if (identityStable) {
692
+ const hit = store.byStyle.get(style)
693
+ if (hit !== undefined) {
694
+ return hit
695
+ }
696
+ const painted = applyCfStyle(base, style)
697
+ if (painted !== undefined) {
698
+ store.byStyle.set(style, painted)
699
+ }
700
+ return painted
701
+ }
702
+ const key = styleKey(style)
703
+ const hit = store.byKey.get(key)
704
+ if (hit !== undefined) {
705
+ return hit
706
+ }
707
+ const painted = applyCfStyle(base, style)
708
+ if (painted !== undefined) {
709
+ memoSet(store.byKey, key, painted)
710
+ }
711
+ return painted
712
+ }
713
+
714
+ /**
715
+ * The ONE painted-format seam — base ⊕ CF (paint / autofit / editor / announcements / caret
716
+ * gate / getPaintedFormat). `getCellFormat` undeclared ⇒ undefined without touching the index
717
+ * (the v0.7 identity); `cfIndex` null or an empty column slice ⇒ the base BY IDENTITY; otherwise
718
+ * the cell's column slice is walked in global order (containment first, every true rule
719
+ * contributes, earlier wins per property, `stopIfTrue` ends the walk) and the merged format is
720
+ * interned per base so an unchanged answer is the identical object. `readText` is the caller's once-only
721
+ * thunk (`lazyCellText`) and is called only by conditions that need the text.
722
+ * 唯一の塗り書式合流点 — base ⊕ CF (paint / autofit / エディタ / 読み上げ / キャレットゲート /
723
+ * getPaintedFormat)。getCellFormat 未宣言は索引に触れず undefined (v0.7 恒等)、cfIndex null か
724
+ * 空の列スライスは base を identity で、他はセルの列スライスを大域順に歩き (containment を先に、
725
+ * 真の規則は全て寄与、同一プロパティは先勝ち、stopIfTrue で打ち切り) 合成書式を base 毎に intern する
726
+ * ため同じ答えは同一 object。readText は呼び手の遅延 1 回読み thunk (lazyCellText) で、text を
727
+ * 要する条件だけが呼ぶ。
728
+ */
729
+ export const resolvePaintedFormat = (adapter: ConditionalFormatAdapter, cfIndex: CfIndex | null, memo: CfMemo, row: number, col: number, readText: () => string): CellFormat | undefined => {
730
+ // 能力分岐: getCellFormat 未宣言なら書式そのものが無い (v0.7 恒等 — CF は書式能力なしには存在しない)
731
+ if (adapter.getCellFormat === undefined) {
732
+ return undefined
733
+ }
734
+ const base = adapter.getCellFormat(row, col)
735
+ if (cfIndex === null) {
736
+ return base
737
+ }
738
+ const slice = columnSliceFor(cfIndex, row, col)
739
+ if (slice.length === 0) {
740
+ return base
741
+ }
742
+ const cell: CellCoord = { row, col }
743
+ let acc: ConditionalFormatStyle | null = null
744
+ let identityStable = true
745
+ for (const rule of slice) {
746
+ // containment を評価より先に (行帯内の行はスライスが保証しない) — セル外の規則は何も読まない
747
+ if (!containsCell(rule.ranges, cell)) {
748
+ continue
749
+ }
750
+ const style = evaluateCondition(rule, cell, readText, adapter, memo)
751
+ if (style === null) {
752
+ continue
753
+ }
754
+ // colorScale の寄与はセル毎の新規 object — identity intern の対象にしない
755
+ if (style !== rule.style) {
756
+ identityStable = false
757
+ }
758
+ if (acc === null) {
759
+ acc = style
760
+ } else {
761
+ const next = overlayUnder(acc, style)
762
+ // 後方規則が何かを足したときだけ累積は新規 object になる
763
+ if (next !== acc) {
764
+ identityStable = false
765
+ acc = next
766
+ }
767
+ }
768
+ if (rule.stopIfTrue === true) {
769
+ break
770
+ }
771
+ }
772
+ // 真の規則が無ければ base を identity で (無変化セルの paint intern が当たる)
773
+ if (acc === null) {
774
+ return base
775
+ }
776
+ return internPainted(base, acc, identityStable)
777
+ }
778
+
779
+ /** Memo of the autofit pre-filter by rules-array identity. / autofit 事前抽出の配列 identity memo。 */
780
+ const widthRelevantMemo = new WeakMap<readonly ConditionalFormatRule[], readonly ConditionalFormatRule[]>()
781
+
782
+ /**
783
+ * Autofit pre-filter: the rules whose style sets `bold` or `italic` (either value — clearing a
784
+ * bold base changes glyph widths too) PLUS every rule with `stopIfTrue` (it sets no width
785
+ * property but ends the painted walk, so keeping it makes the sliced walk stop where paint
786
+ * stops — a dropped rule can neither own bold / italic nor terminate, hence exact parity), in
787
+ * global order; the input array ITSELF when every rule is relevant; memoised by array identity.
788
+ * autofit 事前抽出: style が bold か italic を設定する規則 (値は問わない — bold base の解除も
789
+ * 字幅を変える) と stopIfTrue の規則 (幅には効かないが塗りの walk を止める障壁 — 残すことで
790
+ * スライス済み walk も塗りと同じ位置で止まる。落とした規則は bold / italic を持たず walk も
791
+ * 止めないため結果は厳密一致) を大域順で。全件該当なら入力配列そのもの。配列 identity で memo。
792
+ */
793
+ export const widthRelevantRules = (rules: readonly ConditionalFormatRule[]): readonly ConditionalFormatRule[] => {
794
+ assertRules(rules, "widthRelevantRules")
795
+ const hit = widthRelevantMemo.get(rules)
796
+ if (hit !== undefined) {
797
+ return hit
798
+ }
799
+ // 幅に効く規則 (bold / italic) に加え stopIfTrue の障壁も残す — 塗りと同じ位置で walk を止めるため
800
+ const relevant = rules.filter((rule) => rule.style.bold !== undefined || rule.style.italic !== undefined || rule.stopIfTrue === true)
801
+ // 全件該当なら入力そのもの (呼び手の identity 比較が意味を持つ)
802
+ const result = relevant.length === rules.length ? rules : relevant
803
+ widthRelevantMemo.set(rules, result)
804
+ return result
805
+ }