@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,562 @@
1
+ /**
2
+ * @fileoverview Built-in cell display formatting (v0.7 — the getCellFormat capability). Pure,
3
+ * React-free value→string engine + horizontal-alignment resolver. The consumer declares a
4
+ * per-cell CellFormat via the optional adapter capability getCellFormat(row, col); the grid
5
+ * applies the value format to the DISPLAY string (editing / copy / TSV keep the raw
6
+ * getCellText) and the alignment to the flex cell's justify-content.
7
+ *
8
+ * Engine contract (each clause is load-bearing and mutation-gated — spec 07 §5.8):
9
+ * - EXACT decimal rendering: the validated raw string is handed to Intl.NumberFormat AS A
10
+ * STRING (NumberFormat v3 mathematical values), never converted through a binary double —
11
+ * so "1.45" with {type:"percent", scale:false, decimals:1} renders "1.5%" (not the drifted
12
+ * "1.4%") and integers beyond 2^53 keep every digit. percent scale:false divides by 100 as
13
+ * a pure string exponent shift, again without touching floating point. Literal exponents
14
+ * are grammar-bounded to 4 digits (±9999) — past ICU's own limits, inputs stay raw.
15
+ * - INTERNED formatters: Intl.NumberFormat instances are cached per exact option tuple
16
+ * (construction FAILURES are interned too) behind an identity WeakMap tier keyed on the
17
+ * value-format object (an identity-stable adapter answer skips even the key build), so
18
+ * per-cell rendering and autofit measurement never re-pay the ~40µs construction cost — nor
19
+ * the per-cell key-build cost — per cell per slice.
20
+ * - Negative-zero suppression via signDisplay:"negative" (a value that rounds to zero never
21
+ * shows a minus sign) — locale-proof, including non-ASCII numbering systems.
22
+ * - TOTAL fail-soft: a non-numeric string in a numeric format, an invalid date, or an
23
+ * out-of-range Intl option (bad decimals / currency / locale) renders the raw text
24
+ * unchanged — a header/label never breaks.
25
+ * - TRULY timezone-free dates: parsing/validation/rendering is pure integer calendar math
26
+ * (leap-year aware) — no Date object is ever constructed, so host TZ, DST gaps, and the
27
+ * two-digit-year remapping cannot affect which dates format or how.
28
+ * - Alignment "unset" is AUTO by the VALUE, not the declared format: text that actually
29
+ * parses under the cell's numeric/date format (or as a strict decimal with no format)
30
+ * resolves right, labels resolve left — the Excel / Google Sheets render-time default.
31
+ *
32
+ * 組み込みセル表示形式 (v0.7 — getCellFormat 能力)。純関数・React 非依存の値→文字列エンジン +
33
+ * 水平揃え解決。エンジン契約 (全て変異検証で封印 — spec 07 §5.8):
34
+ * 正確十進 (検証済み素文字列を Intl へ**文字列のまま**渡し二進浮動小数を経由しない —
35
+ * percent scale:false の ÷100 も文字列指数シフト)、interned フォーマッタ (構築失敗も
36
+ * インターンし、値書式 object キーの identity WeakMap 層でキー構築も省き、セル毎の Intl 構築
37
+ * コストを排除)、signDisplay:"negative" による負ゼロ抑止
38
+ * (非 ASCII 数字体系でも成立)、全経路 fail-soft (非数値 / 無効日付 / 範囲外オプションは素の
39
+ * text)、Date 非依存の純整数暦検証 (ホスト TZ / DST / 2 桁年写像の影響が構造的にゼロ)、
40
+ * align 未指定は**値による自動** — 実際に解釈できる値だけ右、ラベルは左 (Excel / Sheets の描画時既定)。
41
+ *
42
+ * v0.8 (rich cells) widens CellFormat to the full declarative cell format — vertical alignment,
43
+ * wrap, background / text colour, bold / italic / underline / strikethrough, font family / size
44
+ * (pt) and per-side borders — and adds the pure FORMAT ALGEBRA the commands and the clipboard
45
+ * share: canonicalizeFormat (the structural normal form — false booleans / undefined keys /
46
+ * empty borders dropped, every object emitted with SORTED keys so JSON.stringify of a canonical
47
+ * form is a deterministic interning key), mergeFormat (patch application with null = clear,
48
+ * identity-preserving when nothing changes so an unchanged cell emits no edit) and
49
+ * formatEquals (structural equality on canonical forms, key-order independent). Painting
50
+ * (CSS / attributes) lives in cellPaint.ts; this module stays a value model.
51
+ *
52
+ * v0.8 (リッチセル) は CellFormat を宣言的セル書式の全体 (縦揃え・折返し・背景 / 文字色・太字 /
53
+ * 斜体 / 下線 / 取消線・フォント族 / サイズ (pt)・辺毎の罫線) へ拡張し、コマンドとクリップボード
54
+ * が共有する純関数の書式代数を追加: canonicalizeFormat (構造的正準形 — false 真偽値 / undefined
55
+ * キー / 空 borders を落とし、全 object をキー辞書順で出力するため JSON.stringify がそのまま
56
+ * 決定的 intern キー)、mergeFormat (null = 消去のパッチ適用、無変化なら base を identity で返し
57
+ * 無変化セルは edit を出さない)、formatEquals (正準形の構造等価・キー順非依存)。塗り (CSS /
58
+ * 属性) は cellPaint.ts、本モジュールは値モデルに留まる。
59
+ */
60
+
61
+ /** Horizontal cell alignment. / セルの水平揃え。 */
62
+ export type CellAlign = "left" | "center" | "right"
63
+
64
+ /** Vertical cell alignment (undefined = middle, the cell's flex centring). / セルの垂直揃え (undefined = 中央 = セルの flex 中央)。 */
65
+ export type CellVerticalAlign = "top" | "middle" | "bottom"
66
+
67
+ /** Border line style vocabulary (Excel / Sheets subset). / 罫線種の語彙 (Excel / Sheets 部分集合)。 */
68
+ export type CellBorderStyle = "thin" | "medium" | "thick" | "dashed" | "dotted" | "double" | "hair"
69
+
70
+ /** One border side: line style plus an optional colour (absent = BORDER_DEFAULT_COLOR). / 罫線 1 辺: 線種 + 任意の色 (省略 = BORDER_DEFAULT_COLOR)。 */
71
+ export type CellBorder = { style: CellBorderStyle; color?: string }
72
+
73
+ /** Per-side borders of a cell (absent side = no border). / セルの辺毎の罫線 (辺の省略 = 罫線なし)。 */
74
+ export type CellBorders = { top?: CellBorder; right?: CellBorder; bottom?: CellBorder; left?: CellBorder }
75
+
76
+ /** Border colour when `CellBorder.color` is absent (a stated default, not a fallback). / 罫線色の宣言済み既定 (fallback ではなく仕様上の既定)。 */
77
+ export const BORDER_DEFAULT_COLOR = "#000000"
78
+
79
+ /**
80
+ * A value display format (type-based — "simple formats"). All fields optional beyond `type`,
81
+ * except `currency.currency` which is REQUIRED (a currency is meaningless without its code —
82
+ * the package stays host-agnostic and never assumes a region default).
83
+ * 値の表示形式 (型ベース・簡易形式)。`type` 以外は基本任意だが `currency.currency` のみ必須
84
+ * (通貨コードなしの通貨書式は無意味・パッケージは地域既定を仮定しないホスト非依存設計)。
85
+ */
86
+ export type CellValueFormat =
87
+ | { type: "number"; decimals?: number; grouping?: boolean; locale?: string }
88
+ | { type: "percent"; decimals?: number; scale?: boolean; locale?: string }
89
+ | { type: "currency"; currency: string; decimals?: number; locale?: string }
90
+ | { type: "scientific"; decimals?: number; locale?: string }
91
+ | { type: "date"; pattern?: string }
92
+ | { type: "text" }
93
+
94
+ /**
95
+ * A cell's declared display format, returned per-cell by the adapter's getCellFormat capability
96
+ * (v0.8 — the full rich-cell vocabulary): value format (undefined = raw text), horizontal
97
+ * alignment (undefined = auto by the value), vertical alignment (undefined = middle), wrap,
98
+ * background / text colour (any CSS colour text — normalised by the paint engine, unparseable =
99
+ * ignored), bold / italic / underline / strikethrough (absent = false), font family, font size
100
+ * in POINTS (painted as pt × 4 / 3 px) and per-side borders. Every key is optional; the
101
+ * canonical form (canonicalizeFormat) is what the package writes back through CellEdit.format.
102
+ * セルの宣言済み表示形式 — アダプタの getCellFormat 能力がセル毎に返す (v0.8 — リッチセルの
103
+ * 全語彙): 値書式 (undefined = 素の文字列)・水平揃え (undefined = 値による自動)・垂直揃え
104
+ * (undefined = 中央)・折返し・背景 / 文字色 (任意の CSS 色文字列 — 塗りエンジンが正規化、解釈
105
+ * 不能は無視)・太字 / 斜体 / 下線 / 取消線 (不在 = false)・フォント族・フォントサイズ (pt 単位、
106
+ * pt × 4 / 3 px で描画)・辺毎の罫線。全キー任意。パッケージが CellEdit.format で書き戻すのは
107
+ * 正準形 (canonicalizeFormat)。
108
+ */
109
+ export type CellFormat = {
110
+ value?: CellValueFormat
111
+ align?: CellAlign
112
+ verticalAlign?: CellVerticalAlign
113
+ wrap?: boolean
114
+ background?: string
115
+ color?: string
116
+ bold?: boolean
117
+ italic?: boolean
118
+ underline?: boolean
119
+ strikethrough?: boolean
120
+ fontFamily?: string
121
+ fontSize?: number
122
+ borders?: CellBorders
123
+ }
124
+
125
+ /**
126
+ * Command / handle input ONLY — never crosses the adapter. A key set to `null` CLEARS it, an
127
+ * omitted / undefined key leaves the base value untouched, any other value replaces it (borders
128
+ * are replaced as a whole set — callers spread the base sides to change one side).
129
+ * コマンド / handle 入力専用 — アダプタを跨がない。`null` のキーは消去、省略 / undefined は
130
+ * base の値を保持、それ以外は置換 (borders は辺の集合ごと置換 — 1 辺だけ変えるときは呼び手が
131
+ * base の辺を展開する)。
132
+ */
133
+ export type CellFormatPatch = { readonly [K in keyof CellFormat]?: CellFormat[K] | null }
134
+
135
+ /** The Intl-numeric subset of CellValueFormat (everything the formatter cache serves). / 数値系サブセット (キャッシュ対象)。 */
136
+ type NumericCellValueFormat = Exclude<CellValueFormat, { type: "date" } | { type: "text" }>
137
+
138
+ /** Intl fraction-digit options for a fixed decimals count (empty = the style's Intl default). / 固定小数桁の Intl オプション。 */
139
+ const fractionDigits = (decimals: number | undefined): Intl.NumberFormatOptions => (decimals === undefined ? {} : { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
140
+
141
+ /**
142
+ * Fraction-digit ceiling for {type:"number"} with decimals omitted. Intl's own default (max 3)
143
+ * silently rounds typed input and can render a nonzero value as "0" ("0.000001" → "0"); 20 is
144
+ * Intl's guaranteed-accepted maximum, so typed fraction digits are preserved DOWN TO the 20th
145
+ * place ("1e-20" renders in full; a value below 1e-20 still rounds to "0" — Intl offers no
146
+ * larger ceiling, and the raw value stays intact for editing/copy).
147
+ * decimals 省略時の {type:"number"} の小数桁上限。Intl 既定 (最大 3) は入力を黙って丸め
148
+ * "0.000001" を "0" に見せる。20 は Intl が保証する最大受理値で、入力小数桁を**第 20 位まで**
149
+ * 保持する ("1e-20" は全桁表示。1e-20 未満は "0" に丸まる — Intl にこれ以上の上限は無く、
150
+ * 素の値は編集 / コピーで無傷)。
151
+ */
152
+ const MAX_GENERAL_FRACTION_DIGITS = 20
153
+
154
+ /** Formatter-cache overflow guard: format vocabularies are per-column finite, so this is never reached in real use. / キャッシュ安全弁 (書式語彙は列単位で有限 — 実運用で到達しない)。 */
155
+ const FORMATTER_CACHE_LIMIT = 256
156
+
157
+ /** Interned Intl.NumberFormat per exact option tuple; null = construction failed (fail-soft, memoized). / オプションタプル毎の interned フォーマッタ。null = 構築失敗のインターン。 */
158
+ const formatterCache = new Map<string, Intl.NumberFormat | null>()
159
+
160
+ /** Identity tier of the formatter intern, keyed on the value-format OBJECT (mirrors internCellStyle's tier 1): GC-bounded, so the size valve of the keyed tier never touches it. / 値書式 object をキーとする intern の identity 層 (internCellStyle の第 1 層と同形): GC 有界のためキー層の安全弁の対象外。 */
161
+ const formatterByValue = new WeakMap<NumericCellValueFormat, Intl.NumberFormat | null>()
162
+
163
+ /**
164
+ * Parses text as a finite strict decimal ("[+-]digits[.digits][e±digits]" — rejects hex/binary
165
+ * literals, blank, ±Infinity overflow) and returns the TRIMMED canonical string, or null.
166
+ * The string (not a double) is what Intl later formats, keeping the pipeline exact. The return
167
+ * carries Intl's StringNumericLiteral brand — the regex IS the validator of exactly that
168
+ * grammar, so this is the single honest cast point.
169
+ * 厳密 10 進として解釈できれば trim 済み正準文字列を返す (16 / 2 進・空・±Infinity 溢れは null)。
170
+ * 後段の Intl へは double でなくこの文字列を渡すことで正確十進を貫く。戻り値の
171
+ * StringNumericLiteral ブランドは正規表現がまさにその文法の検証器であることに基づく唯一の cast 点。
172
+ * Exported (v0.8) so conditionalFormat.ts and selfCalc.ts reuse the SAME grammar (single source
173
+ * of "what is a number" for comparison rules and self-calc).
174
+ * v0.8 で export — conditionalFormat.ts / selfCalc.ts が**同一文法**を再利用する (比較規則と
175
+ * 自己計算の「数値とは何か」の単一真実源)。
176
+ */
177
+ export const parseNumber = (text: string): Intl.StringNumericLiteral | null => {
178
+ const t = text.trim()
179
+ // 10 進 (任意符号・小数・指数) のみ受理 — 0x/0b/0o・空文字・Infinity は数値と見なさない。
180
+ // 指数部は最大 4 桁 (±9999) に文法水準で制限する: double が 0 へ underflow して有限判定を
181
+ // すり抜ける巨大負指数 ("1e-1000000000") は ICU が |指数| ≥ 1e9 で throw するため、表示上
182
+ // 意味のある全域 (double は ±e308) を保ちつつ構造的に遮断する (fail-soft の穴を作らない)
183
+ if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d{1,4})?$/.test(t)) {
184
+ return null
185
+ }
186
+ // 指数溢れ ("1e999" → ±Infinity) は数値と見なさない (グロテスクな 1000 桁表示の遮断)
187
+ return Number.isFinite(Number(t)) ? (t as Intl.StringNumericLiteral) : null
188
+ }
189
+
190
+ /** Shifts a validated decimal string's exponent (exact ÷/× powers of ten with NO floating point). / 検証済み 10 進文字列の指数シフト (浮動小数を経由しない正確な 10 の冪乗除)。 */
191
+ const shiftExponent = (decimal: Intl.StringNumericLiteral, shift: number): Intl.StringNumericLiteral => {
192
+ const at = decimal.search(/[eE]/)
193
+ // 指数が無ければ付与、有れば数値部を加算 (指数は短い整数 — 文字列長で安全)。連結結果も
194
+ // 同文法に留まるためブランドを維持する
195
+ return (at < 0 ? `${decimal}e${shift}` : `${decimal.slice(0, at)}e${Number(decimal.slice(at + 1)) + shift}`) as Intl.StringNumericLiteral
196
+ }
197
+
198
+ /** Per-type Intl options (signDisplay:"negative" suppresses the sign of anything that ROUNDS to zero — the locale-proof negative-zero guard); null for an unknown future type (runtime fail-soft). / 型別 Intl オプション。signDisplay:"negative" が「丸め後ゼロ」の符号を全 locale で抑止。未知型は null。 */
199
+ const numericOptions = (value: NumericCellValueFormat): Intl.NumberFormatOptions | null => {
200
+ switch (value.type) {
201
+ case "number":
202
+ return { signDisplay: "negative", useGrouping: value.grouping ?? false, ...(value.decimals === undefined ? { maximumFractionDigits: MAX_GENERAL_FRACTION_DIGITS } : fractionDigits(value.decimals)) }
203
+ case "percent":
204
+ return { signDisplay: "negative", style: "percent", ...fractionDigits(value.decimals) }
205
+ case "currency":
206
+ return { signDisplay: "negative", style: "currency", currency: value.currency, ...fractionDigits(value.decimals) }
207
+ case "scientific":
208
+ return { signDisplay: "negative", notation: "scientific", ...fractionDigits(value.decimals) }
209
+ default: {
210
+ // 到達不能 (数値系 4 種を網羅) — 将来の型追加を型エラーで検出しつつ実行時は fail-soft
211
+ const exhaustive: never = value
212
+ void exhaustive
213
+ return null
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Returns the interned Intl.NumberFormat for a numeric value format through two tiers: an
220
+ * identity WeakMap keyed on the value-format object (an identity-stable adapter answer skips
221
+ * the JSON key build entirely — the per-cell display-path hoist), falling through to the keyed
222
+ * cache on identity miss (a fresh-but-equal object converges on the SAME formatter there),
223
+ * constructing on first sight. Construction failures — out-of-range decimals, an invalid
224
+ * currency code or locale — are interned as null, so a broken column pays ONE throw total, not
225
+ * one per cell per frame. `scale` is deliberately absent from the key (it shifts the INPUT, not
226
+ * the formatter). Value formats are immutable values (the contract cellPaint's intern already
227
+ * relies on): an in-place mutation must hand out a new object to take effect.
228
+ * 数値書式の interned フォーマッタを 2 層で返す: 値書式 object をキーとする identity WeakMap
229
+ * (identity 安定なアダプタ答えは JSON キー構築ごと省略 — セル毎表示経路の巻き上げ)、identity
230
+ * miss はキー層へ (内容同値の新規 object はそこで同一フォーマッタに合流)、初見で構築。構築失敗
231
+ * (範囲外 decimals / 不正 currency / 不正 locale) も null でインターンし、壊れた列の throw は
232
+ * 全体で 1 回に抑える。`scale` は入力側のシフトでありフォーマッタに影響しないためキーから除外。
233
+ * 値書式は不変値 (cellPaint の intern が既に依拠する契約) — in-place 変更は新 object で初めて効く。
234
+ */
235
+ const numericFormatter = (value: NumericCellValueFormat): Intl.NumberFormat | null => {
236
+ const byIdentity = formatterByValue.get(value)
237
+ // null も「構築失敗」の登録済み答え — undefined だけが未登録
238
+ if (byIdentity !== undefined) {
239
+ return byIdentity
240
+ }
241
+ // キーは型 + 全オプションの null 番兵タプル。decimals は String() 符号化 — JSON.stringify は
242
+ // NaN / Infinity を null に落とすため素のままだと「未指定」キーと衝突し、NaN 先行時に健全な
243
+ // 既定構成へ構築失敗 (null) を毒として注入してしまう ("NaN" / "Infinity" / "7" は衝突不能)
244
+ const key = JSON.stringify([value.type, value.locale ?? null, value.decimals === undefined ? null : String(value.decimals), value.type === "number" ? (value.grouping ?? false) : null, value.type === "currency" ? value.currency : null])
245
+ const cached = formatterCache.get(key)
246
+ if (cached !== undefined) {
247
+ formatterByValue.set(value, cached)
248
+ return cached
249
+ }
250
+ // 安全弁: 上限到達で全消去 (LRU 不要 — 実運用の書式語彙は列単位で有限)
251
+ if (formatterCache.size >= FORMATTER_CACHE_LIMIT) {
252
+ formatterCache.clear()
253
+ }
254
+ const options = numericOptions(value)
255
+ let formatter: Intl.NumberFormat | null = null
256
+ if (options !== null) {
257
+ try {
258
+ formatter = new Intl.NumberFormat(value.locale, options)
259
+ } catch {
260
+ formatter = null
261
+ }
262
+ }
263
+ formatterCache.set(key, formatter)
264
+ formatterByValue.set(value, formatter)
265
+ return formatter
266
+ }
267
+
268
+ /** A wall-clock date-time parsed by pure calendar math (never a Date object). / 純暦計算で解釈した壁時計日時 (Date 非経由)。 */
269
+ type ParsedDate = { year: number; month: number; day: number; hour: number; minute: number; second: number }
270
+
271
+ /** Days in a month with proleptic-Gregorian leap rules (÷4, except ÷100 unless ÷400). / 月の日数 (グレゴリオ暦の閏年規則)。 */
272
+ const monthDays = (year: number, month: number): number => {
273
+ switch (month) {
274
+ case 2:
275
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28
276
+ case 4:
277
+ case 6:
278
+ case 9:
279
+ case 11:
280
+ return 30
281
+ default:
282
+ return 31
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Parses ISO-ish "YYYY-MM-DD[ T HH:mm[:ss]]" (or "/" separated — the SAME separator on both
288
+ * sides) with pure integer range validation: month 1-12, day within the leap-aware month,
289
+ * hour<24 / minute<60 / second<60. NO Date object is involved, so rollover ("2026-13-45"),
290
+ * host-timezone DST gaps, and the Date constructor's two-digit-year remapping (years 0001-0099)
291
+ * can never corrupt or reject a real calendar date. Null on any violation or trailing garbage.
292
+ * 先頭末尾固定・両側同一区切り・純整数の範囲検証 (月 1-12・閏年対応の月内日・時<24 / 分<60 /
293
+ * 秒<60) の日付解析。Date を一切構築しないため、ロールオーバー・ホスト TZ の DST ギャップ・
294
+ * 2 桁年写像 (年 0001-0099) の影響が構造的に存在しない。違反・末尾ゴミは null。
295
+ */
296
+ const parseDate = (text: string): ParsedDate | null => {
297
+ const m = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/.exec(text.trim())
298
+ if (m === null) {
299
+ return null
300
+ }
301
+ const year = Number(m[1])
302
+ const month = Number(m[3])
303
+ const day = Number(m[4])
304
+ const hour = m[5] === undefined ? 0 : Number(m[5])
305
+ const minute = m[6] === undefined ? 0 : Number(m[6])
306
+ const second = m[7] === undefined ? 0 : Number(m[7])
307
+ // 純整数の暦検証 — 範囲外は無効日として null (黙ったロールオーバー変換を作らない)
308
+ if (month < 1 || month > 12 || day < 1 || day > monthDays(year, month) || hour > 23 || minute > 59 || second > 59) {
309
+ return null
310
+ }
311
+ return { year, month, day, hour, minute, second }
312
+ }
313
+
314
+ /** Formats a parsed date via a token pattern (yyyy MM dd HH mm ss, zero-padded — year to 4); raw text if unparseable/invalid. / トークンパターンで日付整形 (年は 4 桁 0 詰め)、解析不能 / 無効は素の text。 */
315
+ const formatDate = (text: string, pattern: string): string => {
316
+ const d = parseDate(text)
317
+ if (d === null) {
318
+ return text
319
+ }
320
+ const pad = (n: number, width: number): string => String(n).padStart(width, "0")
321
+ // 単一パス置換 (置換結果が未置換トークンを含まないため衝突なし)。トークン集合は正規表現の
322
+ // 選択肢と全単射 — default は "ss" (未知トークンは正規表現が拾わないため到達し得ない)
323
+ return pattern.replace(/yyyy|MM|dd|HH|mm|ss/g, (token) => {
324
+ switch (token) {
325
+ case "yyyy":
326
+ return pad(d.year, 4)
327
+ case "MM":
328
+ return pad(d.month, 2)
329
+ case "dd":
330
+ return pad(d.day, 2)
331
+ case "HH":
332
+ return pad(d.hour, 2)
333
+ case "mm":
334
+ return pad(d.minute, 2)
335
+ default:
336
+ return pad(d.second, 2)
337
+ }
338
+ })
339
+ }
340
+
341
+ /**
342
+ * Formats the raw canonical text for DISPLAY per a value format. undefined / {type:"text"} /
343
+ * empty text pass through unchanged; a non-numeric string in a numeric format, an invalid date,
344
+ * or an out-of-range Intl option fails soft to the raw text. The validated decimal STRING is
345
+ * handed to Intl (exact — no double conversion), and percent scale:false pre-divides by 100 via
346
+ * a string exponent shift. Editing / copy never call this (they use the raw text).
347
+ * 素の正準文字列を値書式に従い表示用へ整形。undefined / {type:"text"} / 空文字は無変更で素通し。
348
+ * 数値書式の非数値・無効日付・範囲外 Intl オプションは素の text へ fail-soft。検証済み 10 進は
349
+ * **文字列のまま** Intl へ (double 変換なしの正確十進)、percent scale:false は文字列指数シフトで
350
+ * ÷100。編集 / コピーは呼ばない (素の値)。
351
+ */
352
+ export const formatCellValue = (text: string, value: CellValueFormat | undefined): string => {
353
+ if (value === undefined || value.type === "text" || text === "") {
354
+ return text
355
+ }
356
+ if (value.type === "date") {
357
+ return formatDate(text, value.pattern ?? "yyyy/MM/dd")
358
+ }
359
+ // 数値系: 厳密 10 進解釈。非数値は素の text へ fail-soft (ラベル / 見出しを壊さない)
360
+ const decimal = parseNumber(text)
361
+ if (decimal === null) {
362
+ return text
363
+ }
364
+ const formatter = numericFormatter(value)
365
+ if (formatter === null) {
366
+ return text
367
+ }
368
+ // percent scale=false は「値が既に ×100 のパーセント」— 指数シフトで正確に ÷100 して相殺
369
+ return formatter.format(value.type === "percent" && value.scale === false ? shiftExponent(decimal, -2) : decimal)
370
+ }
371
+
372
+ /**
373
+ * Resolves the effective horizontal alignment. Explicit align wins; otherwise AUTO by the
374
+ * ACTUAL value: under a date format, text that parses as a date resolves right; under a
375
+ * numeric format or no format, text that parses as a strict decimal resolves right; everything
376
+ * else — labels, {type:"text"}, unparseable input — resolves left. The mirror with
377
+ * formatCellValue runs over the PARSERS (parseNumber/parseDate): a "Total" label in a currency
378
+ * column is left-aligned raw text, never a right-aligned impostor (the Excel / Google Sheets
379
+ * render-time default). When only the Intl OPTIONS are broken (bad currency code), the raw
380
+ * numeric text shows and stays right-aligned — consistent with the no-format auto rule.
381
+ * 実効の水平揃えを解決。明示 align 優先、なければ**実際の値**で自動 — date 書式は日付として
382
+ * 解釈できる文字列だけ右、数値系書式 / 書式なしは厳密 10 進だけ右、それ以外 (ラベル /
383
+ * {type:"text"} / 解釈不能) は左。formatCellValue との鏡映は**パーサ水準** (parseNumber /
384
+ * parseDate) — 通貨列の「Total」は左寄せの素テキストになる (Excel / Sheets の描画時既定)。
385
+ * Intl オプションだけが壊れた場合 (不正通貨コード) は数値の素文字列が表示され右寄せのまま —
386
+ * 書式なしの自動則と整合。
387
+ */
388
+ export const resolveAlign = (align: CellAlign | undefined, value: CellValueFormat | undefined, text: string): CellAlign => {
389
+ if (align !== undefined) {
390
+ return align
391
+ }
392
+ if (value !== undefined && value.type === "text") {
393
+ return "left"
394
+ }
395
+ if (value !== undefined && value.type === "date") {
396
+ return parseDate(text) !== null ? "right" : "left"
397
+ }
398
+ // 数値系書式 or 書式なし: 厳密 10 進として解釈できる素文字列だけ右寄せ (fail-soft と対)
399
+ return parseNumber(text) !== null ? "right" : "left"
400
+ }
401
+
402
+ /** Maps an alignment to the flex cell's justify-content. / 揃えを flex セルの justify-content へ写す。 */
403
+ export const alignToJustify = (align: CellAlign): "flex-start" | "center" | "flex-end" => (align === "center" ? "center" : align === "right" ? "flex-end" : "flex-start")
404
+
405
+ /**
406
+ * Every CellFormat key in canonical (sorted, UTF-16 code unit) order, `satisfies`-checked so each
407
+ * entry IS a key; the coverage constant below proves the converse at compile time. The canonical
408
+ * form is built by walking THIS list, which is what makes `JSON.stringify(canonicalizeFormat(f))`
409
+ * a deterministic interning key and drops unknown keys structurally.
410
+ * CellFormat の全キーを正準 (辞書 = UTF-16 コード単位) 順に並べた表。`satisfies` で各要素がキーで
411
+ * あることを、下の網羅定数で逆 (全キーが載っていること) をコンパイル時に証明。正準形はこの表の
412
+ * 走査で組み立てるため `JSON.stringify(canonicalizeFormat(f))` が決定的 intern キーになり、
413
+ * 未知キーは構造的に落ちる。
414
+ */
415
+ const FORMAT_KEYS = ["align", "background", "bold", "borders", "color", "fontFamily", "fontSize", "italic", "strikethrough", "underline", "value", "verticalAlign", "wrap"] as const satisfies readonly (keyof CellFormat)[]
416
+
417
+ /** Compile-time proof that FORMAT_KEYS lists EVERY CellFormat key (a key added to the type but not to the list would be silently dropped by canonicalization). / FORMAT_KEYS が CellFormat の全キーを網羅することの型水準の証明 (型に増えたキーが表に無いと正準化で黙って落ちる)。 */
418
+ const formatKeysCoverEveryKey: [Exclude<keyof CellFormat, (typeof FORMAT_KEYS)[number]>] extends [never] ? true : never = true
419
+ void formatKeysCoverEveryKey
420
+
421
+ /** Border sides in canonical (sorted) order, with the same two-way compile-time coverage proof. / 罫線の辺の正準 (辞書) 順、同じ双方向のコンパイル時網羅証明つき。 */
422
+ const BORDER_SIDES = ["bottom", "left", "right", "top"] as const satisfies readonly (keyof CellBorders)[]
423
+ const borderSidesCoverEverySide: [Exclude<keyof CellBorders, (typeof BORDER_SIDES)[number]>] extends [never] ? true : never = true
424
+ void borderSidesCoverEverySide
425
+
426
+ /** Writes one key of a format under construction (generic so a union-typed key stays assignable without a cast). / 構築中の書式へ 1 キー書込 (総称型で union キーの代入を cast なしに通す)。 */
427
+ const setKey = <K extends keyof CellFormat>(target: CellFormat, key: K, value: CellFormat[K]): void => {
428
+ target[key] = value
429
+ }
430
+
431
+ /**
432
+ * Shallow rebuild of a flat object with undefined keys removed and the remaining keys in sorted
433
+ * order (value formats and border sides are flat — every field is a primitive). The rebuild keeps
434
+ * every defined key of T, which is what makes the return cast structurally honest.
435
+ * 平坦な object を undefined キー除去 + キー辞書順で浅く再構築 (値書式と罫線の辺は平坦 — 全
436
+ * フィールドが原始値)。T の定義済みキーを全て保つため戻り値の cast は構造的に正直。
437
+ */
438
+ const sortedShallow = <T extends object>(source: T): T => {
439
+ const record = source as Record<string, unknown>
440
+ const out: Record<string, unknown> = {}
441
+ for (const key of Object.keys(record).sort()) {
442
+ // undefined のキーは「不在」— 正準形には現れない
443
+ if (record[key] !== undefined) {
444
+ out[key] = record[key]
445
+ }
446
+ }
447
+ return out as T
448
+ }
449
+
450
+ /** Canonical borders: undefined sides dropped, each side rebuilt (color undefined dropped, keys sorted), sides in sorted order; undefined when no side survives. / 正準 borders: undefined の辺を落とし、各辺を再構築 (color undefined 除去・キー辞書順)、辺は辞書順。辺が残らなければ undefined。 */
451
+ const canonicalizeBorders = (borders: CellBorders | undefined): CellBorders | undefined => {
452
+ if (borders === undefined) {
453
+ return undefined
454
+ }
455
+ const out: CellBorders = {}
456
+ for (const side of BORDER_SIDES) {
457
+ const border = borders[side]
458
+ // 辺 undefined は不在。辺 object は color undefined を除きキー辞書順 (color, style) で再構築
459
+ if (border !== undefined) {
460
+ out[side] = sortedShallow(border)
461
+ }
462
+ }
463
+ return Object.keys(out).length === 0 ? undefined : out
464
+ }
465
+
466
+ /**
467
+ * Structural normal form of a format, or undefined when nothing survives: `undefined` keys and
468
+ * `false` booleans are dropped (absent = default), border sides whose value is undefined are
469
+ * dropped and an empty `borders` disappears, unknown keys are dropped, nested objects (value /
470
+ * borders / each side) are rebuilt with undefined keys removed, and every object is emitted with
471
+ * its keys in SORTED order so `JSON.stringify` of the result is a deterministic interning key
472
+ * (copy dictionary, command memo). Values are NOT validated — an unparseable colour or a
473
+ * non-positive font size is kept verbatim and ignored fail-soft by the paint engine:
474
+ * canonicalization is about structure, not meaning. Always returns a fresh object (the input is
475
+ * never mutated) and is idempotent.
476
+ * 書式の構造的正準形 (何も残らなければ undefined)。undefined キーと false 真偽値は落とす (不在 =
477
+ * 既定)、値 undefined の辺は落とし空の borders は消え、未知キーは落ち、入れ子 (value / borders /
478
+ * 各辺) は undefined キーを除いて再構築、全 object をキー辞書順で出力するため `JSON.stringify`
479
+ * がそのまま決定的 intern キー (copy 辞書・コマンド memo)。値の検証はしない — 解釈不能な色や
480
+ * 非正のフォントサイズはそのまま残し塗りエンジンが fail-soft に無視: 正準化は意味でなく構造の話。
481
+ * 常に新規 object を返し (入力は不変)、冪等。
482
+ */
483
+ export const canonicalizeFormat = (format: CellFormat): CellFormat | undefined => {
484
+ const out: CellFormat = {}
485
+ for (const key of FORMAT_KEYS) {
486
+ // 入れ子 2 種は専用の再構築、それ以外は素の値
487
+ const raw = key === "borders" ? canonicalizeBorders(format.borders) : key === "value" ? (format.value === undefined ? undefined : sortedShallow(format.value)) : format[key]
488
+ // undefined と false は「不在 = 既定」— 正準形には現れない (空 borders も undefined へ畳まれて落ちる)
489
+ if (raw !== undefined && raw !== false) {
490
+ setKey(out, key, raw)
491
+ }
492
+ }
493
+ return Object.keys(out).length === 0 ? undefined : out
494
+ }
495
+
496
+ /**
497
+ * Deep structural equality of two CANONICAL values (objects compared key-by-key in emitted order,
498
+ * which canonical forms make comparable; primitives by SameValue so NaN equals NaN).
499
+ * 正準値 2 つの深い構造等価 (object は出力順のキー毎 — 正準形なら比較可能、原始値は SameValue で
500
+ * NaN 同士も等しい)。
501
+ */
502
+ const canonicalEquals = (a: unknown, b: unknown): boolean => {
503
+ if (Object.is(a, b)) {
504
+ return true
505
+ }
506
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
507
+ return false
508
+ }
509
+ const keysA = Object.keys(a)
510
+ const keysB = Object.keys(b)
511
+ if (keysA.length !== keysB.length) {
512
+ return false
513
+ }
514
+ const recordA = a as Record<string, unknown>
515
+ const recordB = b as Record<string, unknown>
516
+ for (let i = 0; i < keysA.length; i += 1) {
517
+ // 正準形はキー順が固定なので位置毎に同じキーでなければ不等
518
+ const key = keysA[i]
519
+ if (key !== keysB[i] || !canonicalEquals(recordA[key], recordB[key])) {
520
+ return false
521
+ }
522
+ }
523
+ return true
524
+ }
525
+
526
+ /**
527
+ * Structural equality of two formats on their canonical forms — key order, undefined keys,
528
+ * false booleans and empty borders never matter; every value (including each border side and
529
+ * the nested value format) must match. `undefined` equals any format whose canonical form is
530
+ * empty.
531
+ * 正準形上の構造等価 — キー順・undefined キー・false 真偽値・空 borders は無関係、全値 (罫線の
532
+ * 各辺・入れ子の値書式を含む) が一致すること。`undefined` は正準形が空の書式と等しい。
533
+ */
534
+ export const formatEquals = (a: CellFormat | undefined, b: CellFormat | undefined): boolean => {
535
+ if (a === b) {
536
+ return true
537
+ }
538
+ return canonicalEquals(a === undefined ? undefined : canonicalizeFormat(a), b === undefined ? undefined : canonicalizeFormat(b))
539
+ }
540
+
541
+ /**
542
+ * Applies a patch to a base format: `null` clears a key, `undefined` (or an omitted key) keeps
543
+ * the base value, anything else replaces it (borders as a whole set). Returns the CANONICAL
544
+ * merged format — or `base` BY IDENTITY when the result is structurally unchanged (so callers
545
+ * skip the edit for an unchanged cell), or `undefined` when everything has been cleared. The
546
+ * base is never mutated.
547
+ * base 書式へパッチを適用: `null` はキー消去、`undefined` (省略) は base の値を保持、それ以外は
548
+ * 置換 (borders は集合ごと)。戻り値は**正準**の合成書式 — 構造的に無変化なら `base` を
549
+ * **identity で**返し (無変化セルの edit を呼び手が省ける)、全て消えれば `undefined`。base は
550
+ * 変更しない。
551
+ */
552
+ export const mergeFormat = (base: CellFormat | undefined, patch: CellFormatPatch): CellFormat | undefined => {
553
+ const merged: CellFormat = {}
554
+ for (const key of FORMAT_KEYS) {
555
+ const next = patch[key]
556
+ // undefined = 不変 (base の値を保つ)、null = 消去、それ以外 = 上書き
557
+ setKey(merged, key, next === undefined ? base?.[key] : next === null ? undefined : next)
558
+ }
559
+ const canonical = canonicalizeFormat(merged)
560
+ // 無変化 (正準形が等しい) なら base を identity で返す — 無変化セルは edit を出さない契約の根拠
561
+ return canonicalEquals(canonical, base === undefined ? undefined : canonicalizeFormat(base)) ? base : canonical
562
+ }