@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,542 @@
1
+ /**
2
+ * @fileoverview Data-validation rule vocabulary and the pure verdict engine (v0.8 — the
3
+ * getCellValidation capability, ADR-13). The consumer declares a per-cell DataValidationRule
4
+ * via the optional adapter capability getCellValidation(row, col); the editor asks
5
+ * validateCellText at COMMIT (passing the adapter's formula evaluator for `custom` rules) and
6
+ * the grid asks isVisiblyInvalid at PAINT (never evaluating a formula) to draw the invalid
7
+ * indicator. React-free and total.
8
+ *
9
+ * Engine contract (each clause is load-bearing and mutation-gated — spec 01 §8.3):
10
+ * - BLANK IS ALWAYS VALID: the empty string satisfies every rule (Excel "ignore blank"), so a
11
+ * validated cell can always be cleared; `custom` is not even evaluated for a blank.
12
+ * - EXACT decimal comparison for `number` rules: the cell text and both bounds are parsed with
13
+ * the SAME strict-decimal grammar as cellFormat.ts (`[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d{1,4})?`,
14
+ * trimmed, no hex / grouping / ±Infinity) and compared as normalised sign / significant-digit /
15
+ * point-position triples — never through a binary double — so "9007199254740993" is greater
16
+ * than "9007199254740992" and "0.30000000000000004" is not equal to "0.3".
17
+ * - TIMEZONE-FREE dates for `date` rules: the pure-calendar grammar of cellFormat.ts
18
+ * ("YYYY-MM-DD[ T HH:mm[:ss]]" or "/" separated, leap-aware ranges, no Date object), compared
19
+ * as (year, month, day, hour, minute, second) tuples; `between` / `notBetween` are inclusive.
20
+ * - INERT INCOMPLETE RULES: a bound that does not parse (`a` / `b` for number / date, a
21
+ * non-finite `a` / `b` for textLength, a missing `b` for between / notBetween, a missing `value`
22
+ * for the needle text ops) makes the rule accept everything — a half-written rule never locks a
23
+ * cell. Bounds are never swapped: `between 10 and 5` accepts nothing but blank.
24
+ * - FAIL-FAST on a rule OUTSIDE the vocabulary: an unknown `type` or `op` is a consumer bug and
25
+ * throws RangeError regardless of the text (fail-soft would silently disable validation).
26
+ * - INTERNED list Sets per rule object (WeakMap identity), so the paint-time `list` check is O(1)
27
+ * even for a 10k-item list; a fresh rule object interns a fresh Set.
28
+ * - MESSAGE resolution: `errorMessage` when the consumer set it (an empty string is honoured as
29
+ * is), else a built-in English summary of the rule.
30
+ * - `custom` needs the caller's `evaluate`; without it the rule is not evaluated (verdict ok), and
31
+ * truth = `true` or a finite non-zero number (the conditionalFormat customFormula convention).
32
+ * - Matching is exact (case-sensitive, untrimmed) for `list` / `text` / `checkbox`; textLength
33
+ * counts Unicode code points. `isEmail` is the WHATWG valid-e-mail grammar with a mandatory
34
+ * dotted domain; `isUrl` is a WHATWG-parsable absolute http(s) URL.
35
+ *
36
+ * データ検証の規則語彙と純関数の判定エンジン (v0.8 — getCellValidation 能力、ADR-13)。
37
+ * consumer が任意能力 getCellValidation(row, col) で規則を宣言し、エディタが確定時に
38
+ * validateCellText (custom 規則にはアダプタの数式評価器を渡す)、グリッドが描画時に
39
+ * isVisiblyInvalid (数式は決して評価しない) を呼んで不正インジケータを描く。React 非依存・全域関数。
40
+ * エンジン契約 (全て変異検証で封印 — spec 01 §8.3): 空文字は常に有効 (Excel の「空白を無視」—
41
+ * 検証セルは必ず消去できる、custom も評価しない)、number は cellFormat.ts と同じ厳密 10 進文法で
42
+ * 符号 / 有効数字 / 小数点位置の正規形として正確比較 (double 非経由)、date は Date 非依存の純暦文法を
43
+ * タプル比較 (between は両端含み)、不完全な規則 (解釈不能な境界・between の b 欠落・needle 欠落) は
44
+ * 不活性 = 全受理 (書きかけの規則でセルを閉じ込めない、境界の入替もしない)、語彙外の type / op は
45
+ * consumer バグとして text に依らず RangeError、list の Set は規則 object 毎に WeakMap intern、
46
+ * メッセージは errorMessage (空文字も尊重) が無ければ組込みの英語要約、custom は evaluate 無しなら
47
+ * 未評価 (ok)・真 = true か有限非 0 数、照合は完全一致 (大文字小文字区別・trim 無し)、textLength は
48
+ * コードポイント数、isEmail は WHATWG 文法 + ドット付きドメイン必須、isUrl は WHATWG 絶対 http(s) URL。
49
+ */
50
+
51
+ /** Ordered comparison vocabulary shared by number / textLength / date rules; between / notBetween include both bounds. / number / textLength / date 規則が共有する比較語彙。between / notBetween は両端含み。 */
52
+ export type ValidationCompareOp = "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "between" | "notBetween"
53
+
54
+ /** Text-rule vocabulary: four needle ops (need `value`) and two shape checks (ignore `value`). / text 規則の語彙。needle 系 4 つ (value 必須) と形式検査 2 つ (value 無視)。 */
55
+ export type ValidationTextOp = "contains" | "notContains" | "startsWith" | "endsWith" | "isEmail" | "isUrl"
56
+
57
+ /** Options shared by every rule: `strict` rejects the commit (else warn only), `inputMessage` shows on the active cell, `errorMessage` replaces the built-in verdict message. / 全規則共通のオプション。strict は確定拒否 (無指定は警告のみ)、inputMessage は active セルの案内、errorMessage は組込みメッセージの置換。 */
58
+ export type ValidationRuleBase = { strict?: boolean; inputMessage?: string; errorMessage?: string }
59
+
60
+ /** The text must equal one of `values` exactly; `showDropdown` is a UI hint only. / text が values のいずれかと完全一致。showDropdown は UI ヒントのみ。 */
61
+ export type ListValidationRule = { type: "list"; values: readonly string[]; showDropdown?: boolean }
62
+
63
+ /** The text must be a strict decimal satisfying `op` against the decimal-string bounds `a` (and `b` for between / notBetween). / text が厳密 10 進で、10 進文字列の境界 a (between 系は b も) に対し op を満たす。 */
64
+ export type NumberValidationRule = { type: "number"; op: ValidationCompareOp; a: string; b?: string }
65
+
66
+ /** The code-point length of the text must satisfy `op` against the finite numeric bounds `a` (and `b`). / text のコードポイント数が有限数の境界 a (と b) に対し op を満たす。 */
67
+ export type TextLengthValidationRule = { type: "textLength"; op: ValidationCompareOp; a: number; b?: number }
68
+
69
+ /** The text must be a pure-calendar date satisfying `op` against the date-string bounds `a` (and `b`). / text が純暦日付で、日付文字列の境界 a (と b) に対し op を満たす。 */
70
+ export type DateValidationRule = { type: "date"; op: ValidationCompareOp; a: string; b?: string }
71
+
72
+ /** Substring / shape checks on the text; `value` is the needle for the four needle ops and ignored by isEmail / isUrl. / text の部分文字列 / 形式検査。value は needle 系 4 op の探索文字列で isEmail / isUrl では無視。 */
73
+ export type TextValidationRule = { type: "text"; op: ValidationTextOp; value?: string }
74
+
75
+ /** The text must equal `checked` or `unchecked` (declared defaults "TRUE" / "FALSE"). / text が checked か unchecked と一致 (宣言済み既定は "TRUE" / "FALSE")。 */
76
+ export type CheckboxValidationRule = { type: "checkbox"; checked?: string; unchecked?: string }
77
+
78
+ /** The consumer's formula decides; evaluated only when the caller supplies `evaluate` (commit time). / consumer の数式が判定。呼び手が evaluate を渡すとき (確定時) だけ評価。 */
79
+ export type CustomValidationRule = { type: "custom"; formula: string }
80
+
81
+ /** A per-cell data-validation rule: the shared options intersected with one of the seven typed members (§1.2). / セル毎のデータ検証規則。共通オプションと 7 種の型付きメンバーの交差 (§1.2)。 */
82
+ export type DataValidationRule = ValidationRuleBase & (ListValidationRule | NumberValidationRule | TextLengthValidationRule | DateValidationRule | TextValidationRule | CheckboxValidationRule | CustomValidationRule)
83
+
84
+ /** The outcome of validateCellText: ok, or rejected with the resolved message. / validateCellText の結果。ok か、解決済みメッセージ付きの拒否。 */
85
+ export type ValidationVerdict = { ok: true } | { ok: false; message: string }
86
+
87
+ /** A consumer-side formula evaluator handed to validateCellText for `custom` rules. / custom 規則のために validateCellText へ渡す consumer 側の数式評価器。 */
88
+ export type ValidationEvaluate = (formula: string) => string | number | boolean | null
89
+
90
+ /** The single shared ok verdict (frozen — identity-stable, never mutated by a consumer). / 共有の ok 判定 (freeze 済み — identity 安定、consumer が変更不能)。 */
91
+ const VALID: ValidationVerdict = Object.freeze({ ok: true })
92
+
93
+ /** Declared default for CheckboxValidationRule.checked. / CheckboxValidationRule.checked の宣言済み既定。 */
94
+ const CHECKBOX_CHECKED_DEFAULT = "TRUE"
95
+
96
+ /** Declared default for CheckboxValidationRule.unchecked. / CheckboxValidationRule.unchecked の宣言済み既定。 */
97
+ const CHECKBOX_UNCHECKED_DEFAULT = "FALSE"
98
+
99
+ /** The closed comparison vocabulary as a runtime set (fail-fast membership check). / 比較語彙の閉集合 (fail-fast の所属検査用)。 */
100
+ const COMPARE_OPS: ReadonlySet<string> = new Set<ValidationCompareOp>(["gt", "gte", "lt", "lte", "eq", "neq", "between", "notBetween"])
101
+
102
+ /** The closed text vocabulary as a runtime set. / text 語彙の閉集合。 */
103
+ const TEXT_OPS: ReadonlySet<string> = new Set<ValidationTextOp>(["contains", "notContains", "startsWith", "endsWith", "isEmail", "isUrl"])
104
+
105
+ /** Strict decimal grammar — identical to cellFormat.ts parseNumber (exponent bounded to 4 digits). / 厳密 10 進文法 — cellFormat.ts の parseNumber と同一 (指数は 4 桁まで)。 */
106
+ const DECIMAL_GRAMMAR = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d{1,4})?$/
107
+
108
+ /** Pure-calendar date grammar — identical to cellFormat.ts parseDate (same separator on both sides). / 純暦日付文法 — cellFormat.ts の parseDate と同一 (両側同一区切り)。 */
109
+ const DATE_GRAMMAR = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/
110
+
111
+ /**
112
+ * WHATWG valid e-mail address grammar with a mandatory dotted domain (the HTML spec allows a
113
+ * dotless host such as "a@b"; a validation rule wants a real domain).
114
+ * WHATWG の有効メールアドレス文法 + ドット付きドメイン必須 (HTML 仕様は "a@b" を許すが、検証規則には実ドメインが要る)。
115
+ */
116
+ const EMAIL_GRAMMAR = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/
117
+
118
+ /** A normalised exact decimal: sign (0 = zero), significant digits without leading / trailing zeros, and the decimal-point position relative to the digit string. / 正規化した正確 10 進。符号 (0 = ゼロ)、先頭 / 末尾ゼロを除いた有効数字、数字列に対する小数点位置。 */
119
+ type Decimal = { sign: -1 | 0 | 1; digits: string; point: number }
120
+
121
+ /** A wall-clock date-time parsed by pure calendar math (never a Date object). / 純暦計算で解釈した壁時計日時 (Date 非経由)。 */
122
+ type ParsedDate = { year: number; month: number; day: number; hour: number; minute: number; second: number }
123
+
124
+ /** Three-way comparison result. / 三値比較の結果。 */
125
+ type Ordering = -1 | 0 | 1
126
+
127
+ /** Resolved bounds of an ordered rule; `b` is null when the op does not use it. / 順序規則の解決済み境界。op が使わないとき b は null。 */
128
+ type Bounds<T> = { a: T; b: T | null }
129
+
130
+ /** Rule-family wording for the four ordered ops (the other four are shared). / 順序 4 op の規則族別の言い回し (残る 4 つは共通)。 */
131
+ type OrderWords = { readonly gt: string; readonly gte: string; readonly lt: string; readonly lte: string }
132
+
133
+ /** Wording for number / textLength messages. / number / textLength メッセージの言い回し。 */
134
+ const NUMERIC_WORDS: OrderWords = { gt: "greater than", gte: "greater than or equal to", lt: "less than", lte: "less than or equal to" }
135
+
136
+ /** Wording for date messages. / date メッセージの言い回し。 */
137
+ const DATE_WORDS: OrderWords = { gt: "after", gte: "on or after", lt: "before", lte: "on or before" }
138
+
139
+ /**
140
+ * Parses a strict decimal string into its exact normalised form: trims, applies the
141
+ * cellFormat.ts grammar (hex / grouping / empty rejected, exponent overflow to ±Infinity
142
+ * rejected), then folds the mantissa and exponent into sign / significant digits / point
143
+ * position with NO floating-point arithmetic. Null when the text is not a strict decimal.
144
+ * 厳密 10 進文字列を正確な正規形へ解析。trim → cellFormat.ts の文法 (16 進 / 区切り / 空 / 指数溢れは
145
+ * 拒否) → 仮数と指数を浮動小数無しで符号 / 有効数字 / 小数点位置へ畳む。非 10 進は null。
146
+ */
147
+ const parseDecimal = (text: string): Decimal | null => {
148
+ const t = text.trim()
149
+ // cellFormat.ts の parseNumber と同じ受理集合: 文法外と指数溢れ (1e999 → ±Infinity) は非数値
150
+ if (!(DECIMAL_GRAMMAR.test(t) && Number.isFinite(Number(t)))) {
151
+ return null
152
+ }
153
+ const negative = t.startsWith("-")
154
+ const unsigned = t.replace(/^[+-]/, "")
155
+ // 仮数と指数を分離 (指数は文法で 4 桁以内 — Number 変換は安全)
156
+ const exponentAt = unsigned.search(/[eE]/)
157
+ const mantissa = exponentAt < 0 ? unsigned : unsigned.slice(0, exponentAt)
158
+ const exponent = exponentAt < 0 ? 0 : Number(unsigned.slice(exponentAt + 1))
159
+ // 小数点を外して数字列にし、小数点位置 = 整数部の桁数 + 指数
160
+ const dot = mantissa.indexOf(".")
161
+ const integerPart = dot < 0 ? mantissa : mantissa.slice(0, dot)
162
+ const fractionPart = dot < 0 ? "" : mantissa.slice(dot + 1)
163
+ const raw = integerPart + fractionPart
164
+ // 先頭ゼロを落とす (落とした桁数だけ小数点位置を左へ)。全桁ゼロなら符号無しのゼロ
165
+ const lead = raw.search(/[^0]/)
166
+ if (lead < 0) {
167
+ return { sign: 0, digits: "", point: 0 }
168
+ }
169
+ // 末尾ゼロは小数点位置を変えずに落とす
170
+ return { sign: negative ? -1 : 1, digits: raw.slice(lead).replace(/0+$/, ""), point: integerPart.length + exponent - lead }
171
+ }
172
+
173
+ /** Flips an ordering (used to compare negatives by magnitude). / 順序の反転 (負数を絶対値で比べるため)。 */
174
+ const flip = (ordering: Ordering): Ordering => (ordering === 1 ? -1 : ordering === -1 ? 1 : 0)
175
+
176
+ /**
177
+ * Compares two normalised decimals exactly: by sign, then (same non-zero sign) by point
178
+ * position, then by the significant-digit strings — both are free of trailing zeros, so a
179
+ * lexicographic comparison is the numeric one (a proper prefix is the smaller magnitude).
180
+ * 正規化 10 進の正確比較。符号 → (同符号・非ゼロ) 小数点位置 → 有効数字列。両者とも末尾ゼロが無いため
181
+ * 辞書順比較がそのまま数値比較 (真の接頭辞は小さい方)。
182
+ */
183
+ const compareDecimals = (x: Decimal, y: Decimal): Ordering => {
184
+ if (x.sign !== y.sign) {
185
+ return x.sign < y.sign ? -1 : 1
186
+ }
187
+ if (x.sign === 0) {
188
+ return 0
189
+ }
190
+ // 絶対値の比較: 位取り (小数点位置) が違えばそれで決まり、同じなら有効数字の辞書順
191
+ const magnitude: Ordering = x.point !== y.point ? (x.point < y.point ? -1 : 1) : x.digits === y.digits ? 0 : x.digits < y.digits ? -1 : 1
192
+ return x.sign === 1 ? magnitude : flip(magnitude)
193
+ }
194
+
195
+ /** Days in a month with proleptic-Gregorian leap rules (÷4, except ÷100 unless ÷400). / 月の日数 (グレゴリオ暦の閏年規則)。 */
196
+ const monthDays = (year: number, month: number): number => {
197
+ switch (month) {
198
+ case 2:
199
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28
200
+ case 4:
201
+ case 6:
202
+ case 9:
203
+ case 11:
204
+ return 30
205
+ default:
206
+ return 31
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Parses "YYYY-MM-DD[ T HH:mm[:ss]]" (or "/" separated — the SAME separator on both sides)
212
+ * with pure integer range validation (month 1-12, leap-aware day, hour<24 / minute<60 /
213
+ * second<60) — identical to cellFormat.ts parseDate. Null on any violation or trailing garbage.
214
+ * 両側同一区切り・純整数の範囲検証 (月 1-12・閏年対応の日・時<24 / 分<60 / 秒<60) の日付解析 —
215
+ * cellFormat.ts の parseDate と同一。違反・末尾ゴミは null。
216
+ */
217
+ const parseDate = (text: string): ParsedDate | null => {
218
+ const m = DATE_GRAMMAR.exec(text.trim())
219
+ if (m === null) {
220
+ return null
221
+ }
222
+ const year = Number(m[1])
223
+ const month = Number(m[3])
224
+ const day = Number(m[4])
225
+ const hour = m[5] === undefined ? 0 : Number(m[5])
226
+ const minute = m[6] === undefined ? 0 : Number(m[6])
227
+ const second = m[7] === undefined ? 0 : Number(m[7])
228
+ // 純整数の暦検証 — 範囲外は無効日として null (黙ったロールオーバー変換を作らない)
229
+ if (month < 1 || month > 12 || day < 1 || day > monthDays(year, month) || hour > 23 || minute > 59 || second > 59) {
230
+ return null
231
+ }
232
+ return { year, month, day, hour, minute, second }
233
+ }
234
+
235
+ /** Compares two parsed dates as (year, month, day, hour, minute, second) tuples. / 解析済み日付を (年, 月, 日, 時, 分, 秒) のタプルとして比較。 */
236
+ const compareDates = (x: ParsedDate, y: ParsedDate): Ordering => {
237
+ const xs = [x.year, x.month, x.day, x.hour, x.minute, x.second]
238
+ const ys = [y.year, y.month, y.day, y.hour, y.minute, y.second]
239
+ // 上位成分から順に最初に異なる成分で決まる
240
+ for (let i = 0; i < xs.length; i += 1) {
241
+ if (xs[i] !== ys[i]) {
242
+ return xs[i] < ys[i] ? -1 : 1
243
+ }
244
+ }
245
+ return 0
246
+ }
247
+
248
+ /** Compares two finite numbers (textLength). / 有限数の比較 (textLength)。 */
249
+ const compareNumbers = (x: number, y: number): Ordering => (x < y ? -1 : x > y ? 1 : 0)
250
+
251
+ /** Whether the op consumes the second bound. / op が第 2 境界を使うかどうか。 */
252
+ const usesB = (op: ValidationCompareOp): boolean => op === "between" || op === "notBetween"
253
+
254
+ /**
255
+ * Resolves the bounds of an ordered rule through `parse`; null (= inert rule) when `a` does not
256
+ * parse, or when the op needs `b` and `b` is missing or does not parse. `b` is ignored for the
257
+ * six single-bound ops, so garbage there never disables the rule.
258
+ * 順序規則の境界を parse で解決。a が解釈不能、または op が b を要するのに b が欠落 / 解釈不能なら
259
+ * null (= 不活性)。単一境界の 6 op では b を見ない (ゴミがあっても規則を止めない)。
260
+ */
261
+ const resolveBounds = <T, R>(op: ValidationCompareOp, a: R, b: R | undefined, parse: (raw: R) => T | null): Bounds<T> | null => {
262
+ const parsedA = parse(a)
263
+ if (parsedA === null) {
264
+ return null
265
+ }
266
+ if (!usesB(op)) {
267
+ return { a: parsedA, b: null }
268
+ }
269
+ // between 系は b 必須 — 欠落 / 解釈不能は不活性
270
+ const parsedB = b === undefined ? null : parse(b)
271
+ return parsedB === null ? null : { a: parsedA, b: parsedB }
272
+ }
273
+
274
+ /** Evaluates an ordered op from the value's ordering against `a` (and `b` for between / notBetween, inclusive). / 値と a (between 系は b も、両端含み) の順序から op を評価。 */
275
+ const orderedHolds = (op: ValidationCompareOp, toA: Ordering, toB: Ordering): boolean => {
276
+ switch (op) {
277
+ case "gt":
278
+ return toA === 1
279
+ case "gte":
280
+ return toA !== -1
281
+ case "lt":
282
+ return toA === -1
283
+ case "lte":
284
+ return toA !== 1
285
+ case "eq":
286
+ return toA === 0
287
+ case "neq":
288
+ return toA !== 0
289
+ case "between":
290
+ return toA !== -1 && toB !== 1
291
+ default:
292
+ // notBetween (語彙外の op は assertRuleShape が先に拒否済み)
293
+ return toA === -1 || toB === 1
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Generic ordered check: resolves the bounds (inert ⇒ true), parses the value (unparseable ⇒
299
+ * false), then compares. Shared by number / textLength / date through their own parser and
300
+ * comparator.
301
+ * 順序規則の共通判定: 境界解決 (不活性 ⇒ true) → 値解析 (解釈不能 ⇒ false) → 比較。number /
302
+ * textLength / date がそれぞれの parser と comparator で共有。
303
+ */
304
+ const orderedRuleHolds = <T, R>(op: ValidationCompareOp, a: R, b: R | undefined, parse: (raw: R) => T | null, value: T | null, compare: (x: T, y: T) => Ordering): boolean => {
305
+ const bounds = resolveBounds(op, a, b, parse)
306
+ if (bounds === null) {
307
+ return true
308
+ }
309
+ if (value === null) {
310
+ return false
311
+ }
312
+ // b を使わない op では toB は参照されない (0 を置く — 分岐のためではなく引数の型合わせ)
313
+ return orderedHolds(op, compare(value, bounds.a), bounds.b === null ? 0 : compare(value, bounds.b))
314
+ }
315
+
316
+ /** A finite number parses to itself; NaN / ±Infinity do not parse (inert bound). / 有限数はそのまま、NaN / ±Infinity は解釈不能 (不活性境界)。 */
317
+ const parseFiniteNumber = (value: number): number | null => (Number.isFinite(value) ? value : null)
318
+
319
+ /** Counts Unicode code points (a surrogate pair is one character). / コードポイント数 (サロゲートペアは 1 文字)。 */
320
+ const codePointLength = (text: string): number => {
321
+ let count = 0
322
+ // 文字列イテレータはコードポイント単位で進む
323
+ for (const _ of text) {
324
+ count += 1
325
+ }
326
+ return count
327
+ }
328
+
329
+ /** WHATWG-parsable absolute URL whose scheme is http or https. / WHATWG で解析できる http / https スキームの絶対 URL。 */
330
+ const isHttpUrl = (text: string): boolean => URL.canParse(text) && /^https?:$/.test(new URL(text).protocol)
331
+
332
+ /** Evaluates a text rule; needle ops without a `value` are inert (true). / text 規則の評価。value の無い needle 系 op は不活性 (true)。 */
333
+ const textRuleHolds = (rule: TextValidationRule, text: string): boolean => {
334
+ switch (rule.op) {
335
+ case "isEmail":
336
+ return EMAIL_GRAMMAR.test(text)
337
+ case "isUrl":
338
+ return isHttpUrl(text)
339
+ default:
340
+ break
341
+ }
342
+ if (rule.value === undefined) {
343
+ return true
344
+ }
345
+ // needle 系 4 op: 完全一致 (大文字小文字区別・trim 無し)。空 needle は文字通り (contains "" は常に真)
346
+ switch (rule.op) {
347
+ case "contains":
348
+ return text.includes(rule.value)
349
+ case "notContains":
350
+ return !text.includes(rule.value)
351
+ case "startsWith":
352
+ return text.startsWith(rule.value)
353
+ default:
354
+ // 残る needle op は endsWith
355
+ return text.endsWith(rule.value)
356
+ }
357
+ }
358
+
359
+ /** The custom-formula truth convention: `true` or a finite non-zero number (conditionalFormat parity). / custom 数式の真偽規約: true か有限非 0 数 (conditionalFormat と同じ)。 */
360
+ const isTruthyResult = (result: string | number | boolean | null): boolean => result === true || (typeof result === "number" && Number.isFinite(result) && result !== 0)
361
+
362
+ /** Effective checkbox strings (declared defaults "TRUE" / "FALSE"). / checkbox の実効文字列 (宣言済み既定 "TRUE" / "FALSE")。 */
363
+ const checkboxStrings = (rule: CheckboxValidationRule): { checked: string; unchecked: string } => ({
364
+ checked: rule.checked === undefined ? CHECKBOX_CHECKED_DEFAULT : rule.checked,
365
+ unchecked: rule.unchecked === undefined ? CHECKBOX_UNCHECKED_DEFAULT : rule.unchecked,
366
+ })
367
+
368
+ /**
369
+ * Rejects a rule outside the vocabulary (unknown `type`, or an unknown `op` on number /
370
+ * textLength / date / text) with RangeError — a consumer bug surfaces regardless of the text.
371
+ * 語彙外の規則 (未知 type、number / textLength / date / text の未知 op) を RangeError で拒否 —
372
+ * consumer バグは text に依らず表面化。
373
+ */
374
+ const assertRuleShape = (rule: DataValidationRule): void => {
375
+ switch (rule.type) {
376
+ case "list":
377
+ case "checkbox":
378
+ case "custom":
379
+ return
380
+ case "number":
381
+ case "textLength":
382
+ case "date":
383
+ if (!COMPARE_OPS.has(rule.op)) {
384
+ throw new RangeError(`[spreadsheet] unknown data-validation op "${String(rule.op)}" for type "${rule.type}"`)
385
+ }
386
+ return
387
+ case "text":
388
+ if (!TEXT_OPS.has(rule.op)) {
389
+ throw new RangeError(`[spreadsheet] unknown data-validation op "${String(rule.op)}" for type "text"`)
390
+ }
391
+ return
392
+ default:
393
+ throw new RangeError(`[spreadsheet] unknown data-validation rule type "${String((rule as { type: unknown }).type)}"`)
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Whether a NON-BLANK text satisfies a shape-checked rule (`custom` without `evaluate` is not
399
+ * evaluated ⇒ true).
400
+ * 形を検査済みの規則を非空 text が満たすか (evaluate 無しの custom は未評価 ⇒ true)。
401
+ */
402
+ const ruleHolds = (rule: DataValidationRule, text: string, evaluate: ValidationEvaluate | undefined): boolean => {
403
+ switch (rule.type) {
404
+ case "list":
405
+ return listValuesOf(rule).has(text)
406
+ case "number":
407
+ return orderedRuleHolds(rule.op, rule.a, rule.b, parseDecimal, parseDecimal(text), compareDecimals)
408
+ case "textLength":
409
+ return orderedRuleHolds(rule.op, rule.a, rule.b, parseFiniteNumber, codePointLength(text), compareNumbers)
410
+ case "date":
411
+ return orderedRuleHolds(rule.op, rule.a, rule.b, parseDate, parseDate(text), compareDates)
412
+ case "text":
413
+ return textRuleHolds(rule, text)
414
+ case "checkbox": {
415
+ const strings = checkboxStrings(rule)
416
+ return text === strings.checked || text === strings.unchecked
417
+ }
418
+ default:
419
+ // custom — 評価器が無ければ評価しない (描画時に数式を走らせない構造的保証)
420
+ return evaluate === undefined || isTruthyResult(evaluate(rule.formula))
421
+ }
422
+ }
423
+
424
+ /** Whether the text is accepted: shape check (fail-fast), blank (always), else the rule. / text を受理するか: 形の検査 (fail-fast) → 空文字 (常に) → 規則。 */
425
+ const accepts = (rule: DataValidationRule, text: string, evaluate: ValidationEvaluate | undefined): boolean => {
426
+ assertRuleShape(rule)
427
+ return text === "" || ruleHolds(rule, text, evaluate)
428
+ }
429
+
430
+ /** Phrase for an ordered op with the family wording: "greater than 5", "between 1 and 9", ... / 順序 op の言い回し (規則族の語彙付き)。 */
431
+ const comparePhrase = (op: ValidationCompareOp, a: string, b: string | undefined, words: OrderWords): string => {
432
+ switch (op) {
433
+ case "gt":
434
+ case "gte":
435
+ case "lt":
436
+ case "lte":
437
+ return `${words[op]} ${a}`
438
+ case "eq":
439
+ return `equal to ${a}`
440
+ case "neq":
441
+ return `not equal to ${a}`
442
+ case "between":
443
+ return `between ${a} and ${b}`
444
+ default:
445
+ // 残る順序 op は notBetween
446
+ return `not between ${a} and ${b}`
447
+ }
448
+ }
449
+
450
+ /** Trims an optional bound string for display. / 表示用に任意の境界文字列を trim。 */
451
+ const trimBound = (bound: string | undefined): string | undefined => (bound === undefined ? undefined : bound.trim())
452
+
453
+ /** Built-in English summary of a rule, used when the consumer set no `errorMessage`. / consumer が errorMessage を置かないときの組込み英語要約。 */
454
+ const defaultMessage = (rule: DataValidationRule): string => {
455
+ switch (rule.type) {
456
+ case "list":
457
+ return "Value must be one of the list items."
458
+ case "number":
459
+ return `Value must be a number ${comparePhrase(rule.op, rule.a.trim(), trimBound(rule.b), NUMERIC_WORDS)}.`
460
+ case "textLength":
461
+ return `Text length must be ${comparePhrase(rule.op, String(rule.a), rule.b === undefined ? undefined : String(rule.b), NUMERIC_WORDS)}.`
462
+ case "date":
463
+ return `Value must be a date ${comparePhrase(rule.op, rule.a.trim(), trimBound(rule.b), DATE_WORDS)}.`
464
+ case "text":
465
+ return textMessage(rule)
466
+ case "checkbox": {
467
+ const strings = checkboxStrings(rule)
468
+ return `Value must be "${strings.checked}" or "${strings.unchecked}".`
469
+ }
470
+ default:
471
+ // 残る規則種別は custom
472
+ return `Value does not satisfy the formula ${rule.formula}.`
473
+ }
474
+ }
475
+
476
+ /** Built-in summary of a text rule. / text 規則の組込み要約。 */
477
+ const textMessage = (rule: TextValidationRule): string => {
478
+ switch (rule.op) {
479
+ case "contains":
480
+ return `Value must contain "${rule.value}".`
481
+ case "notContains":
482
+ return `Value must not contain "${rule.value}".`
483
+ case "startsWith":
484
+ return `Value must start with "${rule.value}".`
485
+ case "endsWith":
486
+ return `Value must end with "${rule.value}".`
487
+ case "isEmail":
488
+ return "Value must be a valid email address."
489
+ default:
490
+ // 残る text op は isUrl
491
+ return "Value must be a valid URL."
492
+ }
493
+ }
494
+
495
+ /** Interned list Sets keyed by rule object identity. / 規則 object の identity をキーにした list Set の intern。 */
496
+ const listSets = new WeakMap<ListValidationRule, ReadonlySet<string>>()
497
+
498
+ /**
499
+ * The interned Set of a list rule's values (one Set per rule OBJECT — the paint-time `list`
500
+ * check is O(1) for a 10k-item list; a fresh rule object interns a fresh Set).
501
+ * list 規則の値の intern 済み Set (規則 object 毎に 1 つ — 1 万項目の list でも描画時判定は O(1)。
502
+ * 新しい規則 object は新しい Set)。
503
+ */
504
+ export const listValuesOf = (rule: ListValidationRule): ReadonlySet<string> => {
505
+ const cached = listSets.get(rule)
506
+ if (cached !== undefined) {
507
+ return cached
508
+ }
509
+ // 初回だけ構築して intern
510
+ const set: ReadonlySet<string> = new Set(rule.values)
511
+ listSets.set(rule, set)
512
+ return set
513
+ }
514
+
515
+ /**
516
+ * Validates committed text against a rule. Blank always passes; `custom` is evaluated only when
517
+ * the caller passes `evaluate` (commit time — paint omits it); an incomplete rule (unparseable
518
+ * bound, missing `b` / `value`) is inert; a rule outside the vocabulary throws RangeError.
519
+ * Message = `rule.errorMessage` when set, else a built-in English summary of the rule.
520
+ * 確定テキストの規則検証。空文字は常に合格、custom は呼び手が evaluate を渡すとき (確定時 — 描画は
521
+ * 渡さない) だけ評価、不完全な規則 (解釈不能な境界・b / value 欠落) は不活性、語彙外の規則は
522
+ * RangeError。メッセージは errorMessage があればそれ、無ければ組込みの英語要約。
523
+ */
524
+ export const validateCellText = (rule: DataValidationRule, text: string, evaluate?: ValidationEvaluate): ValidationVerdict => {
525
+ if (accepts(rule, text, evaluate)) {
526
+ return VALID
527
+ }
528
+ // errorMessage は「置かれているか」だけを見る (空文字も consumer の意思として尊重)
529
+ return { ok: false, message: rule.errorMessage === undefined ? defaultMessage(rule) : rule.errorMessage }
530
+ }
531
+
532
+ /** Whether a rule can be judged at paint time without a formula evaluator (every type but `custom`). / 数式評価器なしに描画時判定できる規則か (custom 以外の全型)。 */
533
+ export const isValidatedAtPaint = (rule: DataValidationRule): boolean => rule.type !== "custom"
534
+
535
+ /**
536
+ * Paint-time indicator predicate: true when a paint-time rule rejects the text. `custom` is
537
+ * never evaluated here (structurally — no evaluator exists on this path), so it is never
538
+ * visibly invalid.
539
+ * 描画用の不正判定: 描画時規則が text を拒否するとき true。custom はこの経路に評価器が存在しないため
540
+ * 構造的に評価されず、決して不正表示されない。
541
+ */
542
+ export const isVisiblyInvalid = (rule: DataValidationRule, text: string): boolean => isValidatedAtPaint(rule) && !accepts(rule, text, undefined)