@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,370 @@
1
+ /**
2
+ * @fileoverview Paste / fill expansion rules. Paste (§6.5.3 contract table): single-cell
3
+ * target expands the source once from active; an exact-multiple range target tiles the
4
+ * pattern; a non-multiple range target expands once from the range's top-left (Sheets-style
5
+ * deviation — Excel raises a dialog). The table is implemented ONCE by the generic tiler
6
+ * `tileCells<T>` (spec 03 §4, v0.8): the string entry point `expandPaste` and the rich paste
7
+ * entry point `expandRichPaste` both delegate to it, so a cell of any type is tiled by the
8
+ * same loop, visited in row-major order over the WHOLE output rectangle (never per tile), and
9
+ * every visit receives the origin of the tile the cell belongs to (one shared object per tile —
10
+ * the conditional-format translation moves rule ranges by these origins). `tileCells` returns
11
+ * the tile origins it entered (a tile whose origin lies inside the grid, even when the grid edge
12
+ * clips part of it). Grid-edge clipping is silent (the §6.5.3 deviation). estimatePasteCellCount
13
+ * pre-checks WITHOUT materializing (ADR-12). The private expandCutPaste is the cut-move path:
14
+ * never tiles, and the CALLER rejects grid-edge clipping. Fill (§8.3, barrel-PRIVATE):
15
+ * expandFill fills every box cell OUTSIDE the source with 2D source-start modular anchoring
16
+ * (single-axis bands and diagonal L-shapes share one formula — v0.3), generic over the source
17
+ * cell type with a per-target emitter (v0.8); estimateFillCellCount pre-checks region + needed
18
+ * source reads before any readCell call. readFillSource / fillSourceEdit are the ONE per-source
19
+ * rule of every fill path (the handle drag through expandFill, fillDown / fillRight /
20
+ * fillSelection through their own target lists): the editor seed (formula source or canonical
21
+ * text) plus every DECLARED aspect, comments never, a formula shifted per target.
22
+ *
23
+ * 貼り付け / fill 展開規則。貼り付け (§6.5.3 の契約表): 単一セル = active 起点で 1 回展開、
24
+ * 整数倍レンジ = タイル、非整数倍レンジ = レンジ左上起点で 1 回展開 (Sheets 準拠の逸脱)。
25
+ * 表の実装は generic タイラー `tileCells<T>` の 1 箇所 (spec 03 §4、v0.8): 文字列入口
26
+ * `expandPaste` とリッチ入口 `expandRichPaste` が委譲するため、任意型のセルが同じループで
27
+ * 展開され、訪問順は出力矩形全体の行優先 (タイル毎ではない)、各訪問はそのセルが属する
28
+ * タイルの原点 (タイル毎に 1 つの共有 object — 条件付き書式のレンジ平行移動が使う) を
29
+ * 受け取る。`tileCells` は入ったタイルの原点 (原点がグリッド内のタイル — 端で一部が切れても
30
+ * 残る) を返す。グリッド外は静かに切り捨て (§6.5.3 の逸脱)。estimatePasteCellCount は
31
+ * 展開せず事前見積もる (ADR-12)。expandCutPaste は cut-move 専用 (タイルしない — グリッド外
32
+ * クリップの拒否は呼び出し側 §6.5.4-4)。fill (§8.3 — barrel 非公開): expandFill はソース
33
+ * 開始端 anchoring の 2D 剰余式で「バウンディングボックス − ソース」へ展開 (単軸帯も斜め
34
+ * L 字も同一式 — v0.3 の斜めフィル) し、ソースセル型に generic で対象毎の emitter を受ける
35
+ * (v0.8)、estimateFillCellCount は readCell 前に領域 + 必要ソース読みを見積もる。
36
+ * readFillSource / fillSourceEdit は全 fill 経路 (ハンドルドラッグは expandFill 経由、fillDown /
37
+ * fillRight / fillSelection は自前の対象列) が共有する**唯一**のソース毎規則: エディタの種
38
+ * (数式ソースか正準文字列) + 宣言済み側面の全部、comments は決して運ばず、数式は対象毎にシフト。
39
+ */
40
+
41
+ import { normalizeAdapterHyperlink } from "../model/cellAnnotations.ts"
42
+ import { type CellCoord, type CellRange, rangeSize } from "../model/cellCoord.ts"
43
+ import { type CellFormat, canonicalizeFormat } from "../model/cellFormat.ts"
44
+ import { type CellEdit, resolveEditText, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
45
+ import type { DataValidationRule } from "../model/dataValidation.ts"
46
+ import { shiftFormula } from "../model/formulaRefs.ts"
47
+
48
+ /** Paste cell count WITHOUT materializing (ADR-12 pre-check). / 展開せず貼り付け対象セル数を見積もる (ADR-12 の事前検査)。 */
49
+ export const estimatePasteCellCount = (source: readonly (readonly string[])[], target: CellRange): number => {
50
+ const srcRows = source.length
51
+ const srcCols = source[0]?.length ?? 0
52
+ if (srcRows === 0 || srcCols === 0) {
53
+ return 0
54
+ }
55
+ const size = rangeSize(target)
56
+ const isTileTarget = !(size.rows === 1 && size.cols === 1) && size.rows % srcRows === 0 && size.cols % srcCols === 0
57
+ return isTileTarget ? size.rows * size.cols : srcRows * srcCols
58
+ }
59
+
60
+ /**
61
+ * Generic tiler — the §6.5.3 table over any cell type (spec 03 §4, v0.8): the one construction
62
+ * point the string paste and the rich paste both delegate to. Visits every output cell in
63
+ * row-major order over the WHOLE output rectangle (the v0.7 flat-loop order — not tile by tile)
64
+ * with `(row, col, cell, tile)`: `cell` is the source element itself (same reference, never
65
+ * copied) and `tile` is the origin of the tile the cell belongs to — ONE object per tile shared
66
+ * by all of its cells and returned in the result. The grid edge clips silently (the copy-path
67
+ * deviation): the output rectangle is clamped to the grid before visiting, so a tile whose
68
+ * origin is inside the grid is entered (and returned) even when only part of it fits, and a
69
+ * tile entirely outside is never entered. Empty source (no rows or no columns) visits nothing
70
+ * and returns no tiles. Returns the tile origins in the same row-major order.
71
+ * generic タイラー — §6.5.3 の表を任意セル型で (spec 03 §4、v0.8)。文字列貼り付けとリッチ
72
+ * 貼り付けが委譲する唯一の構築点。出力矩形全体の行優先 (v0.7 のフラットループ順 — タイル毎
73
+ * ではない) で全セルを `(row, col, cell, tile)` で訪問: `cell` はソース要素そのもの (同一参照、
74
+ * 複製なし)、`tile` はそのセルが属するタイルの原点 — タイル毎に 1 object を全セルで共有し
75
+ * 戻り値にも載せる。グリッド端は静かに切り捨て (copy 経路の逸脱): 出力矩形を訪問前に
76
+ * グリッドへクランプするため、原点がグリッド内のタイルは一部しか収まらなくても入り (戻り値
77
+ * にも載り)、全体が外のタイルには入らない。空ソース (行なし / 列なし) は訪問なし・タイル
78
+ * なし。戻り値は入ったタイルの原点 (同じ行優先順)。
79
+ */
80
+ export const tileCells = <T>(source: readonly (readonly T[])[], target: CellRange, active: CellCoord, rowCount: number, colCount: number, visit: (row: number, col: number, cell: T, tile: CellCoord) => void): CellCoord[] => {
81
+ const srcRows = source.length
82
+ const srcCols = source[0]?.length ?? 0
83
+ if (srcRows === 0 || srcCols === 0) {
84
+ return []
85
+ }
86
+ const size = rangeSize(target)
87
+ const isSingleTarget = size.rows === 1 && size.cols === 1
88
+ const isTileTarget = !isSingleTarget && size.rows % srcRows === 0 && size.cols % srcCols === 0
89
+ const outRows = isTileTarget ? size.rows : srcRows
90
+ const outCols = isTileTarget ? size.cols : srcCols
91
+ // 基点: 単一セル選択なら active、それ以外は現在レンジの左上 (表の規則そのもの)
92
+ const origin = isSingleTarget ? active : { row: target.startRow, col: target.startCol }
93
+ // グリッド外は切り捨て (逸脱 — Excel は拒否。§6.5.3 の表参照): 出力矩形をグリッド境界でクランプ
94
+ const rowEnd = Math.min(outRows, rowCount - origin.row)
95
+ const colEnd = Math.min(outCols, colCount - origin.col)
96
+ const tiles: CellCoord[] = []
97
+ for (let r0 = 0; r0 < rowEnd; r0 += srcRows) {
98
+ // このタイル行の原点をタイル毎に 1 object だけ作る (同一タイルの全セルへ同じ参照を渡す)
99
+ const rowTiles: CellCoord[] = []
100
+ for (let c0 = 0; c0 < colEnd; c0 += srcCols) {
101
+ const tile = { row: origin.row + r0, col: origin.col + c0 }
102
+ rowTiles.push(tile)
103
+ tiles.push(tile)
104
+ }
105
+ // 訪問順は矩形全体の行優先 (タイル毎の塊ではない — v0.7 のフラットループと同一順序)
106
+ const rowSpan = Math.min(srcRows, rowEnd - r0)
107
+ for (let rr = 0; rr < rowSpan; rr += 1) {
108
+ const row = origin.row + r0 + rr
109
+ const sourceRow = source[rr]
110
+ for (let j = 0; j < rowTiles.length; j += 1) {
111
+ const c0 = j * srcCols
112
+ const colSpan = Math.min(srcCols, colEnd - c0)
113
+ for (let cc = 0; cc < colSpan; cc += 1) {
114
+ visit(row, origin.col + c0 + cc, sourceRow[cc], rowTiles[j])
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return tiles
120
+ }
121
+
122
+ /**
123
+ * Expands a paste into cell edits per the §6.5.3 table (copy path — silent grid-edge clip).
124
+ * Delegates to `tileCells` (v0.8) — output identical to the v0.7 flat loop.
125
+ * §6.5.3 の表どおり貼り付けを CellEdit 列へ展開 (copy 経路 — グリッド外は静かに切り捨て)。
126
+ * `tileCells` へ委譲 (v0.8) — 出力は v0.7 のフラットループと同一。
127
+ */
128
+ export const expandPaste = (source: readonly (readonly string[])[], target: CellRange, active: CellCoord, rowCount: number, colCount: number): CellEdit[] => {
129
+ const edits: CellEdit[] = []
130
+ tileCells(source, target, active, rowCount, colCount, (row, col, text) => {
131
+ edits.push({ row, col, text })
132
+ })
133
+ return edits
134
+ }
135
+
136
+ /**
137
+ * Rich paste expansion (spec 03 §4, v0.8): tiles a cell matrix of any type with `tileCells`,
138
+ * asks `toPatch(cell, row, col, tile)` for the per-cell patch and returns the edits
139
+ * `{ ...patch, row, col }` (the coordinates are authoritative — a patch key can never override
140
+ * them) together with the tile origins for the conditional-format translation. A `null` patch
141
+ * skips that cell (the only per-cell skip — `skipBlanks`); the tile origin stays in `tiles`
142
+ * regardless, because rule ranges are a tile-level aspect, not a cell-level one. Edits keep
143
+ * the row-major visiting order. Independent of the wire format by design: the caller passes
144
+ * the payload's cell matrix.
145
+ * リッチ貼り付け展開 (spec 03 §4、v0.8)。任意型のセル行列を `tileCells` でタイルし、
146
+ * `toPatch(cell, row, col, tile)` にセル毎のパッチを求め、edits `{ ...patch, row, col }`
147
+ * (座標が正 — パッチのキーでは上書き不能) と条件付き書式平行移動用のタイル原点を返す。
148
+ * `null` パッチはそのセルを書かない (唯一のセル単位スキップ — `skipBlanks`) が、規則レンジは
149
+ * セルでなくタイルの側面なのでタイル原点は `tiles` に残る。edits は行優先の訪問順のまま。
150
+ * wire format から独立 (呼び出し側が payload のセル行列を渡す)。
151
+ */
152
+ export const expandRichPaste = <C, P extends object>(
153
+ cells: readonly (readonly C[])[],
154
+ target: CellRange,
155
+ active: CellCoord,
156
+ rowCount: number,
157
+ colCount: number,
158
+ toPatch: (cell: C, row: number, col: number, tile: CellCoord) => P | null,
159
+ ): { edits: (CellCoord & P)[]; tiles: CellCoord[] } => {
160
+ const edits: (CellCoord & P)[] = []
161
+ const tiles = tileCells(cells, target, active, rowCount, colCount, (row, col, cell, tile) => {
162
+ const patch = toPatch(cell, row, col, tile)
163
+ // null = このセルは書かない (skipBlanks 等の唯一のセル単位スキップ) — タイル原点は tiles に残る
164
+ if (patch === null) {
165
+ return
166
+ }
167
+ // 座標が正: パッチに row / col が紛れても上書きされない
168
+ edits.push({ ...patch, row, col })
169
+ })
170
+ return { edits, tiles }
171
+ }
172
+
173
+ /**
174
+ * Validates the fill box (§8.3 SSOT): the box must strictly CONTAIN the source and differ
175
+ * from it — anything else is a caller bug and fail-fasts (no direction argument, no partial
176
+ * overlap; the box is derived from source ∪ hover so containment is structural).
177
+ * fill ボックスの検証 (§8.3 の SSOT)。ボックスはソースを包含しかつ相違すること — それ以外は
178
+ * 呼び出し側バグとして fail-fast (direction 引数なし・部分重なりなし。ボックスは
179
+ * ソース ∪ hover 由来のため包含は構造的)。
180
+ */
181
+ const assertFillBox = (sourceRange: CellRange, box: CellRange): void => {
182
+ const contains = box.startRow <= sourceRange.startRow && box.endRow >= sourceRange.endRow && box.startCol <= sourceRange.startCol && box.endCol >= sourceRange.endCol
183
+ const differs = box.startRow !== sourceRange.startRow || box.endRow !== sourceRange.endRow || box.startCol !== sourceRange.startCol || box.endCol !== sourceRange.endCol
184
+ if (!(contains && differs)) {
185
+ throw new RangeError(`[spreadsheet] expandFill requires a box strictly containing the source (source ${JSON.stringify(sourceRange)}, box ${JSON.stringify(box)})`)
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Fill cell count BEFORE any source read (ADR-12 §8.3): region cells (box minus source) plus
191
+ * the distinct source cells the expansion will read — rows side: all source rows when the box
192
+ * extends horizontally, else min(source rows, extra rows); columns side symmetric. For pure
193
+ * single-axis bands this reduces EXACTLY to the v0.2 formula (band + orthogonal span x
194
+ * min(source length, band length)); the diagonal case charges the full source block. The
195
+ * charge is an UPPER BOUND on distinct reads (a diagonal whose extension is smaller than the
196
+ * source on both axes reads fewer distinct cells) — it never under-counts. The rejection
197
+ * count the machine reports is THIS SUM (documented asymmetry vs the other ops).
198
+ * readCell 前の fill セル数見積もり (ADR-12 §8.3): 領域セル (ボックス − ソース) + ソース読み
199
+ * 課金 — 行側はボックスが横に伸びるとき全ソース行、さもなくば min(ソース行数, 追加行数)。
200
+ * 列側は対称。単軸帯では v0.2 の式と厳密一致し、斜めはソース全ブロック課金。課金は実読み
201
+ * 異種セル数の**上界** (両軸とも拡張がソースより小さい斜めでは実読みがこれを下回る) で
202
+ * 過小計上はしない。拒否 count はこの和 (他 op との非対称は文書化)。
203
+ */
204
+ export const estimateFillCellCount = (sourceRange: CellRange, box: CellRange): number => {
205
+ assertFillBox(sourceRange, box)
206
+ const boxSize = rangeSize(box)
207
+ const srcSize = rangeSize(sourceRange)
208
+ const extraRows = boxSize.rows - srcSize.rows
209
+ const extraCols = boxSize.cols - srcSize.cols
210
+ const rowsRead = extraCols > 0 ? srcSize.rows : Math.min(srcSize.rows, extraRows)
211
+ const colsRead = extraRows > 0 ? srcSize.cols : Math.min(srcSize.cols, extraCols)
212
+ return boxSize.rows * boxSize.cols - srcSize.rows * srcSize.cols + rowsRead * colsRead
213
+ }
214
+
215
+ /**
216
+ * Expands a fill box into cell edits for every cell of the box OUTSIDE the source (§8.3 —
217
+ * copy fill only), generic over the source cell type (v0.8): `readCell` resolves one source
218
+ * cell (a string for a text fill, a {@link FillSource} for the rich fill) and
219
+ * `toEdit(value, origin, target)` turns it into the edit of one target cell — `origin` is the
220
+ * source cell the target maps to, so a formula can shift by `target − origin`. ONE 2D
221
+ * source-start modular anchoring formula for all directions incl. diagonals:
222
+ * src[((r - srcStart) % m + m) % m][((c - colStart) % n + n) % n] — an upward / leftward fill
223
+ * continues the cycle in reverse (Excel's reversed pattern continuation) and a diagonal fill
224
+ * tiles the source block over the L-shaped region. Reads each needed source cell at most ONCE
225
+ * (memoized reader hand-off — nothing materializes beyond the estimate; one shared origin
226
+ * object per source cell).
227
+ * fill ボックスの CellEdit 展開 (§8.3 — copy fill のみ)。ソースセル型に generic (v0.8): `readCell` が
228
+ * ソースセル 1 個を解決 (text fill は文字列、リッチ fill は {@link FillSource}) し、
229
+ * `toEdit(value, origin, target)` が対象 1 セルの edit を作る — `origin` は対象が写像されるソース
230
+ * セルで、数式は `target − origin` だけシフトできる。ソース外の全ボックスセルへ、単軸も斜めも
231
+ * 共通の 2D ソース開始端 anchoring 剰余式で展開。上 / 左方向は循環の逆向き継続 (Excel 一致)、
232
+ * 斜めは L 字領域へソースブロックをタイル。ソースセルは高々 1 回だけ読む (メモ化 reader
233
+ * 受け渡し — 見積もり超の実体化なし。原点 object はソースセル毎に 1 つを共有)。
234
+ */
235
+ export const expandFill = <T>(readCell: (row: number, col: number) => T, sourceRange: CellRange, box: CellRange, toEdit: (value: T, origin: CellCoord, target: CellCoord) => CellEdit): CellEdit[] => {
236
+ assertFillBox(sourceRange, box)
237
+ const m = rangeSize(sourceRange).rows
238
+ const n = rangeSize(sourceRange).cols
239
+ const edits: CellEdit[] = []
240
+ // 必要ソースは高々 1 回だけ読む (剰余で再訪するセルを再読みしない — 見積もりの読み数と一致)
241
+ const cache = new Map<number, { value: T; origin: CellCoord }>()
242
+ for (let row = box.startRow; row <= box.endRow; row += 1) {
243
+ const srcRow = sourceRange.startRow + ((((row - sourceRange.startRow) % m) + m) % m)
244
+ const inRowSpan = row >= sourceRange.startRow && row <= sourceRange.endRow
245
+ for (let col = box.startCol; col <= box.endCol; col += 1) {
246
+ // ソース内セルは書かない (自己書込なし — 領域 = ボックス − ソース)
247
+ if (inRowSpan && col >= sourceRange.startCol && col <= sourceRange.endCol) {
248
+ continue
249
+ }
250
+ const srcCol = sourceRange.startCol + ((((col - sourceRange.startCol) % n) + n) % n)
251
+ // 2^21 = 2,097,152 列が合成キーの衝突限界 (実証封筒 16,384 列の 128 倍 — §9 の
252
+ // 上限を桁で超える列数は想定外。超えると別ソースセルが同一キーへ黙って畳まれる)
253
+ const key = srcRow * 2 ** 21 + srcCol
254
+ let entry = cache.get(key)
255
+ if (entry === undefined) {
256
+ entry = { value: readCell(srcRow, srcCol), origin: { row: srcRow, col: srcCol } }
257
+ cache.set(key, entry)
258
+ }
259
+ edits.push(toEdit(entry.value, entry.origin, { row, col }))
260
+ }
261
+ }
262
+ return edits
263
+ }
264
+
265
+ /**
266
+ * One fill source cell resolved ONCE (spec 08 §4 fill row, v0.8): `text` / `isFormula` is the
267
+ * editor seed (`resolveEditText` — the formula source when `getCellFormula` is declared and the
268
+ * cell holds one, else the canonical text; a formula not starting with "=" is that function's
269
+ * RangeError), and each DECLARED aspect rides as a key in the `CellPatch` vocabulary — an absent
270
+ * key means the capability is undeclared (the target's aspect stays untouched), `null` means
271
+ * declared but empty on the source (the target's aspect is cleared), `format` is the canonical
272
+ * form. Comments never travel (README deviation table).
273
+ * fill のソースセル 1 個の 1 回解決 (分冊 08 §4 の fill 行、v0.8): `text` / `isFormula` はエディタの種
274
+ * (`resolveEditText` — `getCellFormula` 宣言 + 数式ありなら数式ソース、さもなくば正準文字列。"=" 非
275
+ * 始まりの数式は同関数の RangeError)、宣言済み側面だけが `CellPatch` 語彙のキーで乗る — キー不在 =
276
+ * 能力未宣言 (対象の側面は不変)、`null` = 宣言済みだがソースに無い (対象の側面を消去)、`format` は
277
+ * 正準形。comments は決して運ばない (README 逸脱表)。
278
+ */
279
+ export type FillSource = { text: string; isFormula: boolean; format?: CellFormat | null; validation?: DataValidationRule | null; hyperlink?: string | null; note?: string | null }
280
+
281
+ /**
282
+ * Reads one fill source cell through the adapter's declared capabilities: the editor seed first
283
+ * (its RangeError fires before any aspect read), then format / validation / hyperlink / note —
284
+ * only the declared ones, never comments. The hyperlink read is the trust boundary (ADR-10):
285
+ * `normalizeAdapterHyperlink` maps an allowlist-failing URL to a link-less source (`null`).
286
+ * アダプタの宣言済み能力で fill ソースセル 1 個を読む: まずエディタの種 (その RangeError は側面読みより
287
+ * 先に立つ)、次に format / validation / hyperlink / note — 宣言済みだけ、comments は決して読まない。
288
+ * hyperlink の読みは信頼境界 (ADR-10) — `normalizeAdapterHyperlink` が許可リスト外の URL をリンク
289
+ * 無しソース (`null`) へ写す。
290
+ */
291
+ export const readFillSource = (adapter: SpreadsheetDataAdapter, row: number, col: number): FillSource => {
292
+ // 種はエディタと同じ解決 (数式は "=" 始まり必須 — 非 "=" は側面を読む前に RangeError で fail-fast)
293
+ const seed = resolveEditText(adapter, row, col)
294
+ const source: FillSource = { text: seed.text, isFormula: seed.isFormula }
295
+ // 宣言済み側面だけキーを持つ (未宣言側面のキーは出さない)。ソースに無ければ消去 (null)
296
+ if (adapter.getCellFormat !== undefined) {
297
+ const format = adapter.getCellFormat(row, col)
298
+ // CellPatch.format は正準形の全体 (無書式は null で消去)
299
+ source.format = format === undefined ? null : canonicalizeFormat(format)
300
+ }
301
+ if (adapter.getCellValidation !== undefined) {
302
+ const validation = adapter.getCellValidation(row, col)
303
+ source.validation = validation === undefined ? null : validation
304
+ }
305
+ if (adapter.getCellHyperlink !== undefined) {
306
+ // 信頼境界 (ADR-10): 生 URL を許可リストへ正規化 — 正規化不能はソースにリンク無し (null = 対象の側面を消去)
307
+ const hyperlink = normalizeAdapterHyperlink(adapter.getCellHyperlink(row, col))
308
+ source.hyperlink = hyperlink === undefined ? null : hyperlink
309
+ }
310
+ if (adapter.getCellNote !== undefined) {
311
+ const note = adapter.getCellNote(row, col)
312
+ source.note = note === undefined ? null : note
313
+ }
314
+ return source
315
+ }
316
+
317
+ /**
318
+ * The edit of one fill target from a source resolved at `origin`: a formula shifts its relative
319
+ * references by `target − origin` (`shiftFormula`), plain text is copied verbatim, and every
320
+ * aspect key the source carries is copied as is (absent keys stay absent — a v0.7 adapter yields
321
+ * exactly `{ row, col, text }`).
322
+ * 原点 `origin` で解決したソースから対象 1 セルの edit: 数式は `target − origin` だけ相対参照を
323
+ * シフト (`shiftFormula`)、素の text はそのまま、ソースが持つ側面キーはそのまま複製 (不在キーは不在の
324
+ * まま — v0.7 アダプタは `{ row, col, text }` ちょうど)。
325
+ */
326
+ export const fillSourceEdit = (source: FillSource, origin: CellCoord, target: CellCoord): CellEdit => {
327
+ // 数式は相対シフト、それ以外は text をそのまま
328
+ const edit: CellEdit = { row: target.row, col: target.col, text: source.isFormula ? shiftFormula(source.text, target.row - origin.row, target.col - origin.col) : source.text }
329
+ // 側面キーはソースの有無をそのまま写す (宣言済み側面だけがキーを持つ)
330
+ if (source.format !== undefined) {
331
+ edit.format = source.format
332
+ }
333
+ if (source.validation !== undefined) {
334
+ edit.validation = source.validation
335
+ }
336
+ if (source.hyperlink !== undefined) {
337
+ edit.hyperlink = source.hyperlink
338
+ }
339
+ if (source.note !== undefined) {
340
+ edit.note = source.note
341
+ }
342
+ return edit
343
+ }
344
+
345
+ /**
346
+ * Cut-move expansion (§6.5.4-4): the source is written ONCE at origin with its own
347
+ * dimensions — never tiled. Returns null when any cell would fall outside the grid; the
348
+ * caller rejects with reason "bounds" (allowing the clip would clear the source and then
349
+ * silently drop the clipped cells = quiet data loss, unlike the copy path's clip deviation).
350
+ * cut-move 展開 (§6.5.4-4)。origin へソース寸法で 1 回だけ展開 — タイルしない。グリッド外へ
351
+ * はみ出す場合は null (呼び出し側が reason "bounds" で拒否 — クリップを許すと clear だけ
352
+ * されて書かれない静かなデータ喪失になる)。
353
+ */
354
+ export const expandCutPaste = (source: readonly (readonly string[])[], origin: CellCoord, rowCount: number, colCount: number): CellEdit[] | null => {
355
+ const srcRows = source.length
356
+ const srcCols = source[0]?.length ?? 0
357
+ if (srcRows === 0 || srcCols === 0) {
358
+ return []
359
+ }
360
+ if (origin.row + srcRows > rowCount || origin.col + srcCols > colCount) {
361
+ return null
362
+ }
363
+ const edits: CellEdit[] = []
364
+ for (let r = 0; r < srcRows; r += 1) {
365
+ for (let c = 0; c < srcCols; c += 1) {
366
+ edits.push({ row: origin.row + r, col: origin.col + c, text: source[r][c] })
367
+ }
368
+ }
369
+ return edits
370
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @fileoverview Clipboard TSV serialize/parse compatible with Excel's REAL behavior (§6.5.1):
3
+ * only cells containing a tab or newline are quoted (with internal `"` doubled) — everything
4
+ * else passes through verbatim even when it contains `"`. The parser therefore adopts the
5
+ * quoted reading ONLY when the unquoted content itself needs quoting; a field that is merely
6
+ * wrapped in quotes (`"hi"`) is a literal Excel emitted untouched. Both readers share ONE
7
+ * grammar walk (`scanTsv`): `parseTsv` materialises the matrix, `measureTsv` only counts rows ×
8
+ * columns so the ADR-12 paste gate can refuse an over-cap clipboard BEFORE any row array exists
9
+ * (a newline-only clipboard costs ~hundreds of bytes of matrix per input byte when materialised).
10
+ *
11
+ * Excel 実挙動互換のクリップボード TSV (§6.5.1)。quote されるのはタブ / 改行を含むセルだけ
12
+ * (内部の `"` は二重化)、それ以外は `"` を含んでいても素通し。parse 側の quoted 解釈は
13
+ * 「剥がした中身が改行かタブを含む」場合のみ採用 — 全体が引用で包まれただけの `"hi"` は
14
+ * Excel が素通しした literal であり剥がさない。読み手 2 つは唯一の文法走査 (`scanTsv`) を共有:
15
+ * `parseTsv` は行列を実体化し、`measureTsv` は行 × 列だけを数える — ADR-12 の paste ゲートが
16
+ * 上限超のクリップボードを行配列の実体化**前**に拒否できる (改行だけのクリップボードは実体化で
17
+ * 入力 1 byte あたり数百 byte の行列費用になる)。
18
+ */
19
+
20
+ /**
21
+ * Serializes a matrix to Excel-behavior TSV with a trailing CRLF.
22
+ * 行列を終端 CRLF つきの Excel 実挙動 TSV へ直列化。
23
+ */
24
+ export const serializeTsv = (matrix: readonly (readonly string[])[]): string =>
25
+ // 終端 CRLF は必須 (Excel 実挙動)。parseTsv の「終端の改行 1 つを剥がす」規約と対 —
26
+ // 終端なしだと [["a"],[""]] が [["a"]] に潰れ (cut-move で clear されるべき先に旧値が残る)、
27
+ // [[""]] は "" になって §6.5.4-5 の空ガードに飲まれ「空セル貼り付けで clear」が不発になる
28
+ `${matrix.map((row) => row.map(escapeTsvField).join("\t")).join("\r\n")}\r\n`
29
+
30
+ /** Quotes only fields containing a tab or newline (Excel behavior). / タブか改行を含むフィールドのみ quote (Excel 実挙動)。 */
31
+ const escapeTsvField = (field: string): string => (/[\t\r\n]/.test(field) ? `"${field.replaceAll('"', '""')}"` : field)
32
+
33
+ /**
34
+ * Strips the single trailing line terminator (CRLF / LF / lone CR) — the last row's terminator,
35
+ * not an empty row.
36
+ * 終端の改行 1 つ (CRLF / LF / 単独 CR) を剥がす — 「最終行の終端」であり空行ではない。
37
+ */
38
+ const stripTrailingTerminator = (text: string): string => (text.endsWith("\r\n") ? text.slice(0, -2) : text.endsWith("\n") || text.endsWith("\r") ? text.slice(0, -1) : text)
39
+
40
+ /**
41
+ * The ONE grammar walk both readers share: reads `body` field by field (the quoted reading
42
+ * adopted only under the two-part condition below), reporting each field to `onField` and each
43
+ * row end to `onRowEnd` (the last row included). `body` must be terminator-stripped, non-empty.
44
+ * 両読み手が共有する唯一の文法走査: `body` を 1 フィールドずつ読み (quoted 解釈の採用は下の
45
+ * 二重条件のときだけ)、フィールド毎に `onField`、行の終わり毎に `onRowEnd` (最終行も) を呼ぶ。
46
+ * `body` は終端剥がし済み・非空であること。
47
+ */
48
+ const scanTsv = (body: string, onField: (field: string) => void, onRowEnd: () => void): void => {
49
+ let i = 0
50
+ while (true) {
51
+ // 1 フィールドずつ読む。quoted 解釈は「" で始まり、対応する閉じの直後がタブ / 改行 / 終端」の
52
+ // ときだけ採用し、そうでなければ literal (Excel が quote せず出力した `"hi"` を壊さない)
53
+ let field: string
54
+ if (body[i] === '"') {
55
+ const quoted = tryReadQuotedField(body, i)
56
+ if (quoted !== null) {
57
+ field = quoted.value
58
+ i = quoted.nextIndex
59
+ } else {
60
+ const end = nextDelimiter(body, i)
61
+ field = body.slice(i, end)
62
+ i = end
63
+ }
64
+ } else {
65
+ const end = nextDelimiter(body, i)
66
+ field = body.slice(i, end)
67
+ i = end
68
+ }
69
+ onField(field)
70
+ if (i >= body.length) {
71
+ onRowEnd()
72
+ return
73
+ }
74
+ if (body[i] === "\t") {
75
+ i += 1
76
+ } else {
77
+ // 改行 (CRLF / LF / CR)
78
+ i += body[i] === "\r" && body[i + 1] === "\n" ? 2 : 1
79
+ onRowEnd()
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Parses Excel-behavior TSV into a rectangular matrix (ragged rows are right-padded).
86
+ * Excel 実挙動 TSV を矩形行列へ解析 (ギザギザ行は右パディング)。
87
+ */
88
+ export const parseTsv = (text: string): string[][] => {
89
+ const body = stripTrailingTerminator(text)
90
+ if (body === "") {
91
+ return [[""]]
92
+ }
93
+ const rows: string[][] = []
94
+ let row: string[] = []
95
+ scanTsv(
96
+ body,
97
+ (field) => {
98
+ row.push(field)
99
+ },
100
+ () => {
101
+ rows.push(row)
102
+ row = []
103
+ },
104
+ )
105
+ // 矩形化: Excel 由来は常に矩形だが、他アプリ由来のギザギザ行も右パディングで受ける (仕様)。
106
+ // ❗ Math.max(...rows) は行数ぶん引数を積み、巨大貼り付け (V8 実測 ~15 万行、JSC はさらに低い)
107
+ // で ADR-12 見積もりに到達する前に RangeError で落ちる — ループで求める
108
+ let width = 0
109
+ for (const r of rows) {
110
+ if (r.length > width) {
111
+ width = r.length
112
+ }
113
+ }
114
+ return rows.map((r) => (r.length === width ? r : [...r, ...Array<string>(width - r.length).fill("")]))
115
+ }
116
+
117
+ /**
118
+ * The dimensions `parseTsv(text)` would materialise — rows and the rectangularised (maximum)
119
+ * column count — from the SAME grammar walk, without building any row array: the ADR-12
120
+ * pre-materialisation estimate of the `tsv` paste source (a raw line-terminator count would
121
+ * over-count rows for quoted embedded newlines and falsely reject a legitimate paste).
122
+ * `parseTsv(text)` が実体化する寸法 — 行数と矩形化後 (最大) の列数 — を**同じ**文法走査から、
123
+ * 行配列を一切作らずに返す: `tsv` 貼り付けソースの ADR-12 実体化前見積もり (素の改行計数は
124
+ * quoted 内の埋め込み改行を行と数え、正当な貼り付けを誤って上限拒否する)。
125
+ */
126
+ export const measureTsv = (text: string): { rows: number; cols: number } => {
127
+ const body = stripTrailingTerminator(text)
128
+ if (body === "") {
129
+ return { rows: 1, cols: 1 }
130
+ }
131
+ let rows = 0
132
+ let cols = 0
133
+ let current = 0
134
+ scanTsv(
135
+ body,
136
+ () => {
137
+ current += 1
138
+ },
139
+ () => {
140
+ rows += 1
141
+ if (current > cols) {
142
+ cols = current
143
+ }
144
+ current = 0
145
+ },
146
+ )
147
+ return { rows, cols }
148
+ }
149
+
150
+ /** Reads a quoted field at i, adopted ONLY when its content needs quoting. / 位置 i の quoted field 読取り (中身が quote を要する場合のみ採用、不成立は null)。 */
151
+ const tryReadQuotedField = (body: string, start: number): { value: string; nextIndex: number } | null => {
152
+ let value = ""
153
+ let i = start + 1
154
+ while (i < body.length) {
155
+ if (body[i] === '"') {
156
+ if (body[i + 1] === '"') {
157
+ value += '"'
158
+ i += 2
159
+ } else {
160
+ const next = i + 1
161
+ // 成立条件は 2 つとも必要: (a) 閉じ引用の直後が区切りか終端、
162
+ // (b) 中身に改行かタブを含む (Excel が quote する唯一の理由)。
163
+ // (b) が無いと、全体がちょうど引用で包まれた literal `"hi"` を剥がしてしまう
164
+ const isBoundary = next >= body.length || body[next] === "\t" || body[next] === "\n" || body[next] === "\r"
165
+ if (isBoundary && /[\t\r\n]/.test(value)) {
166
+ return { value, nextIndex: next }
167
+ }
168
+ return null
169
+ }
170
+ } else {
171
+ value += body[i]
172
+ i += 1
173
+ }
174
+ }
175
+ return null // 閉じ引用が無い → literal 扱い
176
+ }
177
+
178
+ /** Next tab/newline position or end. / 次の区切り位置 (無ければ終端)。 */
179
+ const nextDelimiter = (body: string, from: number): number => {
180
+ for (let i = from; i < body.length; i += 1) {
181
+ if (body[i] === "\t" || body[i] === "\n" || body[i] === "\r") {
182
+ return i
183
+ }
184
+ }
185
+ return body.length
186
+ }