@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.
- package/CHANGELOG.md +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- 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
|
+
}
|