@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,1242 @@
1
+ /**
2
+ * @fileoverview Lossless clip wire format v1 (fixed leading keys), header scan, TOTAL validation,
3
+ * source detection, the single-quoted `data-aqsp-clip` attribute conventions, the copy-side
4
+ * dictionaries (format / validation interning) and the HTML table size estimator (v0.8, ADR-14).
5
+ * The package's own copy writes ONE JSON string — `serializeClipPayload` — into two carriers:
6
+ * the paste-event-only custom MIME `application/x-aqsp-clip` and the FIRST attribute of the
7
+ * interop `<table>` (`data-aqsp-clip='…'`, single-quoted so the JSON's double quotes stay raw
8
+ * and the header can be anchored without decoding). React-free and pure (spec 03 §3.1).
9
+ *
10
+ * Engine contract (each clause is load-bearing and mutation-gated):
11
+ * - WIRE FORMAT: `{"v":1,"rows":R,"cols":C,"clipId":"…",…}` — the four leading keys in this
12
+ * FIXED order, then `formats`, `validations`, `cells`, `colWidths?`, `rowHeights?`,
13
+ * `wholeRows`, `wholeCols`, `conditionalFormats`. Every nested object is emitted with a
14
+ * DETERMINISTIC key order: cells `t, f, fmt, val, note, comments, link` (absent keys omitted),
15
+ * comments `text, author, createdAt`, format dictionary entries through `canonicalizeFormat`
16
+ * (sorted keys at every level, `{}` for an empty canonical form), validation rules and
17
+ * conditional-format inputs with sorted keys — two payloads that are structurally equal
18
+ * serialise to the identical string whatever the caller's insertion order.
19
+ * - HEADER SCAN: `readClipHeader` decodes at most the first 512 code units (raw or
20
+ * entity-encoded) and anchors on the fixed keys — rows / cols are positive integers without
21
+ * leading zeros, clipId a non-empty plain string — so the ADR-12 estimate (`rows × cols`) and
22
+ * the clipId identity check never touch the body.
23
+ * - TOTAL VALIDATION: `parseClipPayload` is hand-rolled (no schema library) and returns null on
24
+ * ANY malformed input — invalid JSON, a wrong root, a wrong `v`, non-positive dimensions,
25
+ * `cells` not exactly rows × cols, a dictionary index outside its dictionary, a formula not
26
+ * starting with `=`, an unknown key anywhere, a value outside the vocabularies of
27
+ * `CellFormat` / `DataValidationRule` / `ConditionalFormatRuleInput`, a non-finite number, a
28
+ * size list of the wrong length or with a negative entry, a rule range outside the clip
29
+ * rectangle (ranges are origin-relative and must lie within `[0, rows) × [0, cols)`). Nothing
30
+ * is ever substituted or partially accepted: a parsed payload is safe for every downstream
31
+ * engine (the validation engine throws RangeError on an unknown rule type — hence the strict
32
+ * vocabulary check here at the trust boundary). `serializeClipPayload` runs the SAME
33
+ * validator and throws RangeError (an API surface: consumer input) instead of emitting a
34
+ * string the parser would reject.
35
+ * - CLIP ID: `newClipId` = 128 random bits from `crypto.getRandomValues`, lower-case hex (32
36
+ * chars). No capability branch, no counter, no `randomUUID` substitute: an environment
37
+ * without `getRandomValues` is a RangeError. The id is the cut-move identity on BOTH channels.
38
+ * - ATTRIBUTE: the raw carrier is `data-aqsp-clip='<json with & → &amp; and ' → &#39;>'`; a
39
+ * sanitising engine re-serialises it as `data-aqsp-clip="{&quot;v&quot;:1,…}"`.
40
+ * `decodeClipAttribute` decodes exactly the closed entity set `&amp; &lt; &gt; &quot; &#39;
41
+ * &nbsp;` in ONE pass (so `&amp;lt;` yields the literal `&lt;` the cell contained) and leaves
42
+ * every other sequence verbatim; the carrier scan accepts either quote character.
43
+ * - DETECTION (ADR-14, design v5): event channel — `types` contains the custom MIME ⇒ `aqsp`
44
+ * without reading any other flavour; otherwise (both channels) the first 16 KiB of
45
+ * `text/html` is scanned for markers in the order `data-aqsp-clip=` ⇒ `aqsp`,
46
+ * `<google-sheets-html-origin>` + `data-sheets-root="1"` ⇒ `sheets`, Excel (`<meta
47
+ * name=Generator content="Microsoft Excel`, an `mso-` property or a `.xlNN {` rule) ⇒
48
+ * `excel`; a marker kind and the plain `html` kind both require `<table` somewhere in the
49
+ * FULL text (the full string is at hand on both channels — an `mso-` document without a table,
50
+ * e.g. Word paragraphs, is not readable by any table reader and continues to `tsv`); then a
51
+ * non-empty `text/plain` ⇒ `tsv`; else `none`. `getData` is called only for the flavours the
52
+ * detection needs and never for a type absent from `types`. Detection never tries a reader.
53
+ * - CARRIER READS: `readClipSourceHeader` / `readClipPayload` read ONE carrier — the custom
54
+ * MIME on the event channel when present, else the attribute — and never slide from a broken
55
+ * carrier to the other one (a malformed carrier is null ⇒ `onClipboardUnavailable("unreadable")`).
56
+ * - ESTIMATOR: `estimateHtmlTableCells` is one byte-linear pass over the ASCII-case-folded text
57
+ * (the DOM reads tag / attribute names case-insensitively, so a lowercase `<table` document
58
+ * with `<TR>` / `<TD>` rows must never estimate to 0): `<tr` / `<td` / `<th` count only when
59
+ * followed by whitespace, `>` or `/` (`<track`, `<thead`, `<textarea`, `<title` never count), a
60
+ * `<td` before any `<tr` opens one implicit row, `</tr` (and `</table` / `</tbody` / `</thead` /
61
+ * `</tfoot`) closes the row so a following `<td` opens another (the DOM inserts a row there),
62
+ * `colspan` / `rowspan` are read as attributes when preceded by whitespace, `/` or a closing
63
+ * quote (`mso-ignore:colspan` is not one; 0 / non-numeric = 1; clamped to the HTML caps the
64
+ * reader applies), Σcolspan is summed per row, a `rowspan` carries its colspan into the next
65
+ * `rowspan − 1` rows, and the result is rows × max(carried + Σcolspan) — an UPPER bound of the
66
+ * reader's `rows × cols` (the HTML table model places a row's cells after the carried blanks;
67
+ * a wide header colspan may reject an under-cap table; conservative like `countRangeCells`).
68
+ * Every cursor (`<`, `>`, `colspan`, `rowspan`) only advances, so an unclosed tag or thousands
69
+ * of non-attribute occurrences cost O(bytes), never O(tags × occurrences). What the estimator
70
+ * cannot see (entity references inside attribute values, tokenizer corner cases) is fenced by
71
+ * the reader's own cap (`parseHtmlTable(…, maxCells)`), the second wall of ADR-12.
72
+ * - INTERNING: `createFormatInterner` interns by identity (WeakMap) THEN by canonical JSON
73
+ * (`JSON.stringify(canonicalizeFormat(f))`), storing the canonical object; an empty canonical
74
+ * form yields no index (undefined). `createValidationInterner` interns by identity then by
75
+ * sorted-key JSON, storing the first rule object seen.
76
+ *
77
+ * 自前 lossless wire format v1 (先頭キー固定)・ヘッダ走査・全域検証・ソース検出・単一引用
78
+ * `data-aqsp-clip` 属性規約・copy 側辞書 (書式 / 検証の intern)・HTML 表の規模見積もり (v0.8、
79
+ * ADR-14)。自前 copy は `serializeClipPayload` の JSON 1 本を 2 つの運搬体 — paste イベント専用の
80
+ * カスタム MIME `application/x-aqsp-clip` と相互運用 `<table>` の先頭属性 (`data-aqsp-clip='…'`、
81
+ * JSON の二重引用符を素のまま残しヘッダを復号なしで anchor できる単一引用) — へ書く。React 非依存
82
+ * の純関数 (spec 03 §3.1)。
83
+ *
84
+ * エンジン契約 (全て変異検証で封印): 先頭 4 キー固定順 + 全入れ子の決定的キー順 (構造等価な
85
+ * payload は挿入順に依らず同一文字列)、先頭 512 code unit の復号 + 固定キー anchor によるヘッダ走査
86
+ * (本文に触れず rows × cols と clipId を得る)、手書き全域検証 (不正 JSON・根の型・v・非正寸法・
87
+ * rows × cols 不一致・辞書範囲外の索引・`=` 非始まりの数式・未知キー・語彙外の値・非有限数・寸法配列
88
+ * の長さ / 負値・クリップ矩形外の規則レンジ — いずれも null、部分受理も代替もしない)、
89
+ * `serializeClipPayload` は同じ検証器で RangeError、`newClipId` は `crypto.getRandomValues` の
90
+ * 128 bit を hex 化 (能力分岐・カウンタ・randomUUID 代替なし、不在は RangeError)、閉じた実体集合の
91
+ * 1 パス復号と両クオート形の運搬体走査、ADR-14 の検出順 (custom MIME → 先頭 16 KiB のマーカー
92
+ * `aqsp` / `sheets` / `excel` → 全文 `<table` → 非空 text/plain → none。マーカー種も `<table` を
93
+ * 要し、無ければ tsv へ続く)、運搬体は 1 つだけ読み壊れていても他方へ滑らない、ASCII 大小を
94
+ * 折り畳んだ文字列に対するバイト線形 1 パス見積もり (トークン規則・暗黙行と `</tr` の閉じ・colspan
95
+ * 合計・rowspan の持越し・HTML 上限クランプ・reader の rows × cols の上限値 — 見積もりが見ない
96
+ * ものは reader 自身の上限 `parseHtmlTable(…, maxCells)` が第 2 の壁)、identity → 正準 JSON の
97
+ * 2 段 intern。
98
+ */
99
+
100
+ import { MAX_TRACK_SIZE_PX } from "../header/resizeGates.ts"
101
+ import type { CellRange } from "../model/cellCoord.ts"
102
+ import { type CellAlign, type CellBorder, type CellBorderStyle, type CellBorders, type CellFormat, type CellValueFormat, type CellVerticalAlign, canonicalizeFormat } from "../model/cellFormat.ts"
103
+ import type { CellValueCondition, CfTextOp, ColorScaleCondition, CompareOp, ConditionalFormatRuleInput, ConditionalFormatStyle, CustomFormulaCondition, EmptyCondition, NotEmptyCondition, ScalePoint, TextCondition } from "../model/conditionalFormat.ts"
104
+ import type {
105
+ CheckboxValidationRule,
106
+ CustomValidationRule,
107
+ DataValidationRule,
108
+ DateValidationRule,
109
+ ListValidationRule,
110
+ NumberValidationRule,
111
+ TextLengthValidationRule,
112
+ TextValidationRule,
113
+ ValidationCompareOp,
114
+ ValidationRuleBase,
115
+ ValidationTextOp,
116
+ } from "../model/dataValidation.ts"
117
+
118
+ /** A comment on the wire — the consumer's `id` is stripped (every pasted comment is new). / wire 上のコメント — consumer の `id` は剥がされる (貼り付けられたコメントは全件新規)。 */
119
+ export type ClipComment = { text: string; author: string; createdAt: string }
120
+
121
+ /**
122
+ * One cell on the wire: `t` raw text, `f` the formula in origin-relative R1C1 (`=` leading),
123
+ * `fmt` / `val` indexes into the payload dictionaries, `note`, the id-less `comments` thread and
124
+ * the hyperlink `link`. Absent keys are absent aspects.
125
+ * wire 上のセル 1 つ: `t` 素の文字列、`f` 原点相対 R1C1 の数式 (`=` 始まり)、`fmt` / `val` は
126
+ * payload 辞書への索引、`note`、id を剥がした `comments` スレッド、リンク `link`。不在キー =
127
+ * 不在側面。
128
+ */
129
+ export type ClipCell = { t: string; f?: string; fmt?: number; val?: number; note?: string; comments?: readonly ClipComment[]; link?: string }
130
+
131
+ /**
132
+ * The lossless clip payload (wire format v1). The four leading keys are fixed in this order;
133
+ * `cells` is exactly `rows × cols`; `colWidths` / `rowHeights` travel only when the copy source
134
+ * was whole columns / whole rows; conditional-format inputs are origin-relative and clipped to
135
+ * the copied rectangle.
136
+ * lossless クリップ payload (wire format v1)。先頭 4 キーは固定順、`cells` は rows × cols ちょうど、
137
+ * `colWidths` / `rowHeights` は全列 / 全行 copy のときだけ、条件付き書式入力は原点相対でコピー
138
+ * 矩形に切り詰め済み。
139
+ */
140
+ export type ClipPayload = {
141
+ v: 1
142
+ rows: number
143
+ cols: number
144
+ clipId: string
145
+ formats: readonly CellFormat[]
146
+ validations: readonly DataValidationRule[]
147
+ cells: readonly (readonly ClipCell[])[]
148
+ colWidths?: readonly number[]
149
+ rowHeights?: readonly number[]
150
+ wholeRows: boolean
151
+ wholeCols: boolean
152
+ conditionalFormats: readonly ConditionalFormatRuleInput[]
153
+ }
154
+
155
+ /** The fixed leading keys as a value — what `readClipHeader` returns without parsing the body. / 固定先頭キーの値 — `readClipHeader` が本文 parse なしで返すもの。 */
156
+ export type ClipHeader = { v: 1; rows: number; cols: number; clipId: string }
157
+
158
+ /** The detected clipboard source kind (ADR-14). / 検出されたクリップボードソース種別 (ADR-14)。 */
159
+ export type ClipSource = "aqsp" | "sheets" | "excel" | "html" | "tsv" | "none"
160
+
161
+ /** The channel a paste arrives on: the paste event (custom MIME available) or the async clipboard menu path. / 貼り付けの経路: paste イベント (カスタム MIME あり) か非同期クリップボードのメニュー経路。 */
162
+ export type ClipChannel = "event" | "menu"
163
+
164
+ /**
165
+ * The common input of both channels: the available flavour types and a reader for one type.
166
+ * `getData` is called only for a type present in `types` and only when detection needs it —
167
+ * callers should memoise it per gesture when the read is expensive (a paste event's `getData`
168
+ * re-materialises the string on every call).
169
+ * 両経路の共通入力: 利用可能なフレーバー種別と 1 種別の読み手。`getData` は `types` に在る種別に
170
+ * だけ、検出が要するときにだけ呼ばれる — 読みが高価なとき (paste イベントの `getData` は呼ぶ度に
171
+ * 文字列を再実体化する) は呼び手がジェスチャ毎に memo する。
172
+ */
173
+ export type ClipFlavours = { types: readonly string[]; getData: (type: string) => string }
174
+
175
+ /** The attribute carrying the JSON on the interop `<table>` (its FIRST attribute). / 相互運用 `<table>` で JSON を運ぶ属性 (先頭属性)。 */
176
+ export const CLIP_ATTRIBUTE = "data-aqsp-clip"
177
+
178
+ /** The paste-event-only custom flavour carrying the same JSON string. / 同じ JSON 文字列を運ぶ paste イベント専用のカスタムフレーバー。 */
179
+ export const CLIP_MIME = "application/x-aqsp-clip"
180
+
181
+ /** How much of a carrier `readClipHeader` decodes before anchoring (code units). / `readClipHeader` が anchor 前に復号する運搬体の長さ (code unit)。 */
182
+ export const CLIP_HEADER_WINDOW_BYTES = 512
183
+
184
+ /** How much of `text/html` the marker detection scans (code units). / マーカー検出が走査する `text/html` の長さ (code unit)。 */
185
+ export const CLIP_DETECT_PREFIX_BYTES = 16 * 1024
186
+
187
+ /** The marker literal the detection and the carrier scan look for. / 検出と運搬体走査が探すマーカー文字列。 */
188
+ const CLIP_ATTRIBUTE_MARKER = `${CLIP_ATTRIBUTE}=`
189
+
190
+ /** Random bytes per clip id (128 bits). / クリップ id 1 つの乱数バイト数 (128 bit)。 */
191
+ const CLIP_ID_BYTES = 16
192
+
193
+ /**
194
+ * Mints a clip id: 128 random bits from `crypto.getRandomValues`, lower-case hex (32 chars).
195
+ * There is no capability branch and no counter / `randomUUID` substitute by design — an
196
+ * environment without `getRandomValues` is a RangeError.
197
+ * クリップ id の採番: `crypto.getRandomValues` の 128 bit を小文字 hex (32 文字) へ。能力分岐も
198
+ * カウンタ / `randomUUID` 代替も設計上置かない — `getRandomValues` の無い環境は RangeError。
199
+ */
200
+ export const newClipId = (): string => {
201
+ const api = globalThis.crypto
202
+ // 不在は fail-fast — カウンタや randomUUID へ黙って滑らない
203
+ if (api === undefined || typeof api.getRandomValues !== "function") {
204
+ throw new RangeError("[spreadsheet] newClipId requires crypto.getRandomValues — there is no counter or randomUUID substitute by design")
205
+ }
206
+ const bytes = api.getRandomValues(new Uint8Array(CLIP_ID_BYTES))
207
+ let hex = ""
208
+ // 1 バイト = 2 桁の小文字 hex
209
+ for (const byte of bytes) {
210
+ hex += byte.toString(16).padStart(2, "0")
211
+ }
212
+ return hex
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------------------------
216
+ // Structural validation (the ONE validator behind parse and serialize)
217
+ // 構造検証 (parse と serialize が共有する唯一の検証器)
218
+ // ---------------------------------------------------------------------------------------------
219
+
220
+ /** A structural predicate over an unknown value. / 未知の値に対する構造述語。 */
221
+ type Check = (value: unknown) => boolean
222
+
223
+ /** An object shape: the closed key vocabulary with a predicate per key, and the keys that must be present. / object の形: 閉じたキー語彙 (キー毎の述語) と必須キー。 */
224
+ type Shape = { readonly fields: ReadonlyMap<string, Check>; readonly required: readonly string[] }
225
+
226
+ /** Plain-object guard (arrays and null are not records). / 素の object 判定 (配列と null は除外)。 */
227
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
228
+
229
+ /** String predicate. / 文字列述語。 */
230
+ const isString: Check = (value) => typeof value === "string"
231
+
232
+ /** Boolean predicate. / 真偽値述語。 */
233
+ const isBoolean: Check = (value) => typeof value === "boolean"
234
+
235
+ /** Finite number predicate (JSON cannot carry NaN / Infinity, but a serialiser input can). / 有限数述語 (JSON は NaN / Infinity を運べないが直列化入力は持ち得る)。 */
236
+ const isFiniteNumber: Check = (value) => typeof value === "number" && Number.isFinite(value)
237
+
238
+ /** Non-negative integer guard (indexes, coordinates). / 非負整数判定 (索引・座標)。 */
239
+ const isNonNegativeInteger = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0
240
+
241
+ /** Positive integer guard (dimensions — a clip is at least one cell). / 正整数判定 (寸法 — クリップは最低 1 セル)。 */
242
+ const isPositiveInteger = (value: unknown): value is number => isNonNegativeInteger(value) && value >= 1
243
+
244
+ /** Non-negative finite size (px) predicate, bounded by MAX_TRACK_SIZE_PX (a crafted payload must not carry a geometry-wedging width — same bound as the resize gate and the HTML reader). / 非負有限かつ MAX_TRACK_SIZE_PX 以下の寸法 (px) 述語 (細工 payload に幾何破壊幅を運ばせない — リサイズゲート / HTML reader と同一上限)。 */
245
+ const isSize: Check = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_TRACK_SIZE_PX
246
+
247
+ /** Builds a predicate accepting exactly the listed string literals. / 列挙した文字列リテラルだけを受ける述語。 */
248
+ const oneOf = (values: readonly string[]): Check => {
249
+ const set = new Set<string>(values)
250
+ return (value) => typeof value === "string" && set.has(value)
251
+ }
252
+
253
+ /** Builds a predicate for an array whose every element satisfies `check`. / 全要素が `check` を満たす配列の述語。 */
254
+ const arrayOf =
255
+ (check: Check): Check =>
256
+ (value) =>
257
+ Array.isArray(value) && value.every(check)
258
+
259
+ /** Builds a Shape from a key → predicate table and the required keys. / キー → 述語表と必須キーから Shape を作る。 */
260
+ const shape = (fields: Readonly<Record<string, Check>>, required: readonly string[]): Shape => ({ fields: new Map(Object.entries(fields)), required })
261
+
262
+ /**
263
+ * Checks a value against a Shape: it must be a plain object, every present key (a key whose
264
+ * value is `undefined` counts as absent — a serialiser input may spell an omitted key that way)
265
+ * must be in the vocabulary and satisfy its predicate, and every required key must be present.
266
+ * Shape に対する検査: 素の object で、存在する全キー (値 `undefined` は不在扱い — 直列化入力は
267
+ * 省略キーをそう綴り得る) が語彙内かつ述語を満たし、必須キーが揃うこと。
268
+ */
269
+ const conforms = (value: unknown, expected: Shape): value is Record<string, unknown> => {
270
+ if (!isRecord(value)) {
271
+ return false
272
+ }
273
+ for (const key of Object.keys(value)) {
274
+ const field = value[key]
275
+ // undefined は「省略」と同値 — JSON からは来ないが直列化入力は持ち得る
276
+ if (field === undefined) {
277
+ continue
278
+ }
279
+ const check = expected.fields.get(key)
280
+ // 語彙外キーも述語違反も丸ごと拒否
281
+ if (check === undefined || !check(field)) {
282
+ return false
283
+ }
284
+ }
285
+ for (const key of expected.required) {
286
+ if (value[key] === undefined) {
287
+ return false
288
+ }
289
+ }
290
+ return true
291
+ }
292
+
293
+ /** Builds a predicate for a `type`-tagged union: the tag selects the Shape. / `type` タグ付き union の述語: タグが Shape を選ぶ。 */
294
+ const tagged =
295
+ (shapes: ReadonlyMap<string, Shape>): Check =>
296
+ (value) => {
297
+ if (!isRecord(value) || typeof value.type !== "string") {
298
+ return false
299
+ }
300
+ const expected = shapes.get(value.type)
301
+ return expected !== undefined && conforms(value, expected)
302
+ }
303
+
304
+ /** Compile-time proof that a literal list covers a literal union exactly (a literal added to the type but not the list would be rejected at parse time). / リテラル一覧がリテラル union を過不足なく覆うことの型水準の証明 (型に増えたリテラルが一覧に無いと parse で拒否される)。 */
305
+ type Covers<List extends readonly string[], Union extends string> = [Exclude<Union, List[number]>] extends [never] ? ([Exclude<List[number], Union>] extends [never] ? true : never) : never
306
+
307
+ const CELL_ALIGNS = ["left", "center", "right"] as const
308
+ const CELL_VERTICAL_ALIGNS = ["top", "middle", "bottom"] as const
309
+ const BORDER_STYLES = ["thin", "medium", "thick", "dashed", "dotted", "double", "hair"] as const
310
+ const VALIDATION_COMPARE_OPS = ["gt", "gte", "lt", "lte", "eq", "neq", "between", "notBetween"] as const
311
+ const VALIDATION_TEXT_OPS = ["contains", "notContains", "startsWith", "endsWith", "isEmail", "isUrl"] as const
312
+ const CF_COMPARE_OPS = ["eq", "neq", "gt", "gte", "lt", "lte", "between", "notBetween"] as const
313
+ const CF_TEXT_OPS = ["contains", "notContains", "startsWith", "endsWith"] as const
314
+
315
+ const vocabulariesCoverTheirTypes: [
316
+ Covers<typeof CELL_ALIGNS, CellAlign>,
317
+ Covers<typeof CELL_VERTICAL_ALIGNS, CellVerticalAlign>,
318
+ Covers<typeof BORDER_STYLES, CellBorderStyle>,
319
+ Covers<typeof VALIDATION_COMPARE_OPS, ValidationCompareOp>,
320
+ Covers<typeof VALIDATION_TEXT_OPS, ValidationTextOp>,
321
+ Covers<typeof CF_COMPARE_OPS, CompareOp>,
322
+ Covers<typeof CF_TEXT_OPS, CfTextOp>,
323
+ ] = [true, true, true, true, true, true, true]
324
+ void vocabulariesCoverTheirTypes
325
+
326
+ /** Local aliases of the value-format members (Extract stays private — api-extractor never sees it). / 値書式メンバーのローカル別名 (Extract は非公開に留める — api-extractor には見えない)。 */
327
+ type NumberValueFormat = Extract<CellValueFormat, { type: "number" }>
328
+ type PercentValueFormat = Extract<CellValueFormat, { type: "percent" }>
329
+ type CurrencyValueFormat = Extract<CellValueFormat, { type: "currency" }>
330
+ type ScientificValueFormat = Extract<CellValueFormat, { type: "scientific" }>
331
+ type DateValueFormat = Extract<CellValueFormat, { type: "date" }>
332
+ type TextValueFormat = Extract<CellValueFormat, { type: "text" }>
333
+
334
+ /** The `CellValueFormat` vocabulary per type (every key of every member is listed — `satisfies` proves it). / 型毎の `CellValueFormat` 語彙 (全メンバーの全キーを列挙 — `satisfies` が証明)。 */
335
+ const VALUE_SHAPES: ReadonlyMap<string, Shape> = new Map([
336
+ ["number", shape({ type: isString, decimals: isFiniteNumber, grouping: isBoolean, locale: isString } satisfies Record<keyof NumberValueFormat, Check>, ["type"])],
337
+ ["percent", shape({ type: isString, decimals: isFiniteNumber, scale: isBoolean, locale: isString } satisfies Record<keyof PercentValueFormat, Check>, ["type"])],
338
+ ["currency", shape({ type: isString, currency: isString, decimals: isFiniteNumber, locale: isString } satisfies Record<keyof CurrencyValueFormat, Check>, ["type", "currency"])],
339
+ ["scientific", shape({ type: isString, decimals: isFiniteNumber, locale: isString } satisfies Record<keyof ScientificValueFormat, Check>, ["type"])],
340
+ ["date", shape({ type: isString, pattern: isString } satisfies Record<keyof DateValueFormat, Check>, ["type"])],
341
+ ["text", shape({ type: isString } satisfies Record<keyof TextValueFormat, Check>, ["type"])],
342
+ ])
343
+
344
+ /** One border side. / 罫線 1 辺。 */
345
+ const BORDER_SHAPE = shape({ style: oneOf(BORDER_STYLES), color: isString } satisfies Record<keyof CellBorder, Check>, ["style"])
346
+
347
+ /** Border side predicate. / 罫線辺の述語。 */
348
+ const isBorder: Check = (value) => conforms(value, BORDER_SHAPE)
349
+
350
+ /** The four sides. / 4 辺。 */
351
+ const BORDERS_SHAPE = shape({ top: isBorder, right: isBorder, bottom: isBorder, left: isBorder } satisfies Record<keyof CellBorders, Check>, [])
352
+
353
+ /** The full `CellFormat` vocabulary (values are checked for TYPE only — meaning stays with the fail-soft engines). / `CellFormat` の全語彙 (値は型だけ検査 — 意味は fail-soft エンジンの領分)。 */
354
+ const FORMAT_SHAPE = shape(
355
+ {
356
+ value: tagged(VALUE_SHAPES),
357
+ align: oneOf(CELL_ALIGNS),
358
+ verticalAlign: oneOf(CELL_VERTICAL_ALIGNS),
359
+ wrap: isBoolean,
360
+ background: isString,
361
+ color: isString,
362
+ bold: isBoolean,
363
+ italic: isBoolean,
364
+ underline: isBoolean,
365
+ strikethrough: isBoolean,
366
+ fontFamily: isString,
367
+ fontSize: isFiniteNumber,
368
+ borders: (value) => conforms(value, BORDERS_SHAPE),
369
+ } satisfies Record<keyof CellFormat, Check>,
370
+ [],
371
+ )
372
+
373
+ /** Format dictionary entry predicate. / 書式辞書エントリの述語。 */
374
+ const isCellFormat: Check = (value) => conforms(value, FORMAT_SHAPE)
375
+
376
+ /** The three optional base keys every validation rule may carry. / 全検証規則が持ち得る任意の基底キー 3 つ。 */
377
+ const VALIDATION_BASE = { strict: isBoolean, inputMessage: isString, errorMessage: isString } satisfies Record<keyof ValidationRuleBase, Check>
378
+
379
+ /** The `DataValidationRule` vocabulary per type (base keys merged into each member). / 型毎の `DataValidationRule` 語彙 (基底キーを各メンバーへ併合)。 */
380
+ const VALIDATION_SHAPES: ReadonlyMap<string, Shape> = new Map([
381
+ ["list", shape({ ...VALIDATION_BASE, type: isString, values: arrayOf(isString), showDropdown: isBoolean } satisfies Record<keyof (ValidationRuleBase & ListValidationRule), Check>, ["type", "values"])],
382
+ ["number", shape({ ...VALIDATION_BASE, type: isString, op: oneOf(VALIDATION_COMPARE_OPS), a: isString, b: isString } satisfies Record<keyof (ValidationRuleBase & NumberValidationRule), Check>, ["type", "op", "a"])],
383
+ ["textLength", shape({ ...VALIDATION_BASE, type: isString, op: oneOf(VALIDATION_COMPARE_OPS), a: isFiniteNumber, b: isFiniteNumber } satisfies Record<keyof (ValidationRuleBase & TextLengthValidationRule), Check>, ["type", "op", "a"])],
384
+ ["date", shape({ ...VALIDATION_BASE, type: isString, op: oneOf(VALIDATION_COMPARE_OPS), a: isString, b: isString } satisfies Record<keyof (ValidationRuleBase & DateValidationRule), Check>, ["type", "op", "a"])],
385
+ ["text", shape({ ...VALIDATION_BASE, type: isString, op: oneOf(VALIDATION_TEXT_OPS), value: isString } satisfies Record<keyof (ValidationRuleBase & TextValidationRule), Check>, ["type", "op"])],
386
+ ["checkbox", shape({ ...VALIDATION_BASE, type: isString, checked: isString, unchecked: isString } satisfies Record<keyof (ValidationRuleBase & CheckboxValidationRule), Check>, ["type"])],
387
+ ["custom", shape({ ...VALIDATION_BASE, type: isString, formula: isString } satisfies Record<keyof (ValidationRuleBase & CustomValidationRule), Check>, ["type", "formula"])],
388
+ ])
389
+
390
+ /** Validation dictionary entry predicate. / 検証辞書エントリの述語。 */
391
+ const isValidationRule: Check = tagged(VALIDATION_SHAPES)
392
+
393
+ /** A cell range: four non-negative integers with start ≤ end on both axes (bounds are checked against the clip later). / セルレンジ: 非負整数 4 つ、両軸で start ≤ end (クリップ境界は後で検査)。 */
394
+ const RANGE_SHAPE = shape({ startRow: isNonNegativeInteger, startCol: isNonNegativeInteger, endRow: isNonNegativeInteger, endCol: isNonNegativeInteger } satisfies Record<keyof CellRange, Check>, ["startRow", "startCol", "endRow", "endCol"])
395
+
396
+ /** Range predicate (structure + ordering — the narrowing repeats the integer checks so no cast is needed). / レンジ述語 (構造 + 順序 — 絞り込みのため整数検査を繰り返し、キャストを置かない)。 */
397
+ const isRange = (value: unknown): value is CellRange => {
398
+ if (!conforms(value, RANGE_SHAPE)) {
399
+ return false
400
+ }
401
+ const { startRow, startCol, endRow, endCol } = value
402
+ return isNonNegativeInteger(startRow) && isNonNegativeInteger(endRow) && isNonNegativeInteger(startCol) && isNonNegativeInteger(endCol) && startRow <= endRow && startCol <= endCol
403
+ }
404
+
405
+ /** A colour-scale point. / カラースケールの点。 */
406
+ const SCALE_POINT_SHAPE = shape({ color: isString, value: isFiniteNumber } satisfies Record<keyof ScalePoint, Check>, ["color"])
407
+
408
+ /** Scale point predicate. / スケール点の述語。 */
409
+ const isScalePoint: Check = (value) => conforms(value, SCALE_POINT_SHAPE)
410
+
411
+ /** The `CfCondition` vocabulary per type. / 型毎の `CfCondition` 語彙。 */
412
+ const CONDITION_SHAPES: ReadonlyMap<string, Shape> = new Map([
413
+ ["cellValue", shape({ type: isString, op: oneOf(CF_COMPARE_OPS), a: isString, b: isString } satisfies Record<keyof CellValueCondition, Check>, ["type", "op", "a"])],
414
+ ["text", shape({ type: isString, op: oneOf(CF_TEXT_OPS), value: isString } satisfies Record<keyof TextCondition, Check>, ["type", "op", "value"])],
415
+ ["empty", shape({ type: isString } satisfies Record<keyof EmptyCondition, Check>, ["type"])],
416
+ ["notEmpty", shape({ type: isString } satisfies Record<keyof NotEmptyCondition, Check>, ["type"])],
417
+ ["colorScale", shape({ type: isString, min: isScalePoint, mid: isScalePoint, max: isScalePoint } satisfies Record<keyof ColorScaleCondition, Check>, ["type", "min", "max"])],
418
+ ["customFormula", shape({ type: isString, formula: isString } satisfies Record<keyof CustomFormulaCondition, Check>, ["type", "formula"])],
419
+ ])
420
+
421
+ /** The `ConditionalFormatStyle` vocabulary. / `ConditionalFormatStyle` の語彙。 */
422
+ const STYLE_SHAPE = shape({ background: isString, color: isString, bold: isBoolean, italic: isBoolean, underline: isBoolean, strikethrough: isBoolean } satisfies Record<keyof ConditionalFormatStyle, Check>, [])
423
+
424
+ /** The `ConditionalFormatRuleInput` vocabulary. / `ConditionalFormatRuleInput` の語彙。 */
425
+ const CF_INPUT_SHAPE = shape({ sourceRuleId: isString, ranges: Array.isArray, condition: tagged(CONDITION_SHAPES), style: (value) => conforms(value, STYLE_SHAPE), stopIfTrue: isBoolean } satisfies Record<keyof ConditionalFormatRuleInput, Check>, [
426
+ "sourceRuleId",
427
+ "ranges",
428
+ "condition",
429
+ "style",
430
+ ])
431
+
432
+ /**
433
+ * Conditional-format input predicate: structure plus the origin-relative bound — every range
434
+ * must lie inside `[0, rows) × [0, cols)` (the copy side clips rules to the copied rectangle).
435
+ * 条件付き書式入力の述語: 構造 + 原点相対の境界 — 全レンジが `[0, rows) × [0, cols)` に収まる
436
+ * こと (copy 側は規則をコピー矩形へ切り詰める)。
437
+ */
438
+ const isConditionalFormatInput = (value: unknown, rows: number, cols: number): value is ConditionalFormatRuleInput => {
439
+ if (!conforms(value, CF_INPUT_SHAPE)) {
440
+ return false
441
+ }
442
+ const ranges = value.ranges
443
+ if (!Array.isArray(ranges)) {
444
+ return false
445
+ }
446
+ // 各レンジは構造 + 順序 (isRange) + クリップ矩形内の境界
447
+ for (const range of ranges) {
448
+ if (!isRange(range) || range.endRow >= rows || range.endCol >= cols) {
449
+ return false
450
+ }
451
+ }
452
+ return true
453
+ }
454
+
455
+ /** A comment on the wire: exactly the three strings (an `id` is an unknown key — rejected). / wire 上のコメント: 3 つの文字列ちょうど (`id` は未知キー — 拒否)。 */
456
+ const COMMENT_SHAPE = shape({ text: isString, author: isString, createdAt: isString } satisfies Record<keyof ClipComment, Check>, ["text", "author", "createdAt"])
457
+
458
+ /** Comment predicate. / コメント述語。 */
459
+ const isClipComment: Check = (value) => conforms(value, COMMENT_SHAPE)
460
+
461
+ /**
462
+ * The per-cell check — the hot path (100k cells): hand-rolled switch over the seven keys, every
463
+ * other key rejected, dictionary indexes bounded by the dictionaries, formulas `=`-leading.
464
+ * セル毎の検査 — ホットパス (10 万セル): 7 キーの手書き switch、他キーは拒否、辞書索引は辞書
465
+ * 長で有界、数式は `=` 始まり。
466
+ */
467
+ const isClipCell = (value: unknown, formatCount: number, validationCount: number): value is ClipCell => {
468
+ if (!isRecord(value) || typeof value.t !== "string") {
469
+ return false
470
+ }
471
+ for (const key of Object.keys(value)) {
472
+ const field = value[key]
473
+ // undefined は省略と同値
474
+ if (field === undefined) {
475
+ continue
476
+ }
477
+ switch (key) {
478
+ case "t":
479
+ break
480
+ case "f":
481
+ // 原点相対 R1C1 の数式 — getCellFormula と同じく `=` 始まりだけ
482
+ if (typeof field !== "string" || !field.startsWith("=")) {
483
+ return false
484
+ }
485
+ break
486
+ case "fmt":
487
+ if (!isNonNegativeInteger(field) || field >= formatCount) {
488
+ return false
489
+ }
490
+ break
491
+ case "val":
492
+ if (!isNonNegativeInteger(field) || field >= validationCount) {
493
+ return false
494
+ }
495
+ break
496
+ case "note":
497
+ case "link":
498
+ if (typeof field !== "string") {
499
+ return false
500
+ }
501
+ break
502
+ case "comments":
503
+ if (!(Array.isArray(field) && field.every(isClipComment))) {
504
+ return false
505
+ }
506
+ break
507
+ default:
508
+ return false
509
+ }
510
+ }
511
+ return true
512
+ }
513
+
514
+ /** A size list (px) of exactly the expected length. / 期待長ちょうどの寸法配列 (px)。 */
515
+ const isSizeList = (value: unknown, length: number): value is readonly number[] => Array.isArray(value) && value.length === length && value.every(isSize)
516
+
517
+ /** The closed top-level key set. / 閉じた最上位キー集合。 */
518
+ const PAYLOAD_KEYS: ReadonlySet<string> = new Set<keyof ClipPayload>(["v", "rows", "cols", "clipId", "formats", "validations", "cells", "colWidths", "rowHeights", "wholeRows", "wholeCols", "conditionalFormats"])
519
+
520
+ /**
521
+ * The whole-payload structural check (TOTAL — every clause of the fileoverview's validation
522
+ * contract). Shared by `parseClipPayload` (null) and `serializeClipPayload` (RangeError).
523
+ * payload 全体の構造検査 (全域 — fileoverview の検証契約の全項)。`parseClipPayload` (null) と
524
+ * `serializeClipPayload` (RangeError) が共有。
525
+ */
526
+ const isClipPayload = (value: unknown): value is ClipPayload => {
527
+ if (!isRecord(value)) {
528
+ return false
529
+ }
530
+ // 最上位キーは閉じた集合 (undefined 値は省略と同値)
531
+ for (const key of Object.keys(value)) {
532
+ if (value[key] !== undefined && !PAYLOAD_KEYS.has(key)) {
533
+ return false
534
+ }
535
+ }
536
+ if (value.v !== 1) {
537
+ return false
538
+ }
539
+ const rows = value.rows
540
+ const cols = value.cols
541
+ if (!(isPositiveInteger(rows) && isPositiveInteger(cols))) {
542
+ return false
543
+ }
544
+ if (typeof value.clipId !== "string" || value.clipId === "") {
545
+ return false
546
+ }
547
+ const formats = value.formats
548
+ if (!(Array.isArray(formats) && formats.every(isCellFormat))) {
549
+ return false
550
+ }
551
+ const validations = value.validations
552
+ if (!(Array.isArray(validations) && validations.every(isValidationRule))) {
553
+ return false
554
+ }
555
+ const cells = value.cells
556
+ if (!Array.isArray(cells) || cells.length !== rows) {
557
+ return false
558
+ }
559
+ // rows × cols ちょうど、各セルは辞書長で有界
560
+ for (const row of cells) {
561
+ if (!Array.isArray(row) || row.length !== cols) {
562
+ return false
563
+ }
564
+ for (const cell of row) {
565
+ if (!isClipCell(cell, formats.length, validations.length)) {
566
+ return false
567
+ }
568
+ }
569
+ }
570
+ if (value.colWidths !== undefined && !isSizeList(value.colWidths, cols)) {
571
+ return false
572
+ }
573
+ if (value.rowHeights !== undefined && !isSizeList(value.rowHeights, rows)) {
574
+ return false
575
+ }
576
+ if (typeof value.wholeRows !== "boolean" || typeof value.wholeCols !== "boolean") {
577
+ return false
578
+ }
579
+ const conditionalFormats = value.conditionalFormats
580
+ return Array.isArray(conditionalFormats) && conditionalFormats.every((rule) => isConditionalFormatInput(rule, rows, cols))
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------------------------
584
+ // Serialisation (fixed leading keys, deterministic key order)
585
+ // 直列化 (先頭キー固定、決定的キー順)
586
+ // ---------------------------------------------------------------------------------------------
587
+
588
+ /**
589
+ * JSON with SORTED object keys at every level and `undefined`-valued keys dropped — the
590
+ * deterministic spelling of validation rules and conditional-format inputs (which have no
591
+ * canonicaliser of their own).
592
+ * 全階層でキーをソートし `undefined` 値キーを落とした JSON — 独自の正準化器を持たない検証規則と
593
+ * 条件付き書式入力の決定的な綴り。
594
+ */
595
+ const stableJson = (value: unknown): string => {
596
+ if (Array.isArray(value)) {
597
+ return `[${value.map(stableJson).join(",")}]`
598
+ }
599
+ if (isRecord(value)) {
600
+ // キーは UTF-16 code unit 順 (canonicalizeFormat と同じ順序規則)
601
+ const keys = Object.keys(value)
602
+ .filter((key) => value[key] !== undefined)
603
+ .sort()
604
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`
605
+ }
606
+ return JSON.stringify(value)
607
+ }
608
+
609
+ /** A format dictionary entry: the canonical form (sorted keys), `{}` when nothing survives. / 書式辞書エントリ: 正準形 (ソート済みキー)、何も残らなければ `{}`。 */
610
+ const serializeFormat = (format: CellFormat): string => {
611
+ const canonical = canonicalizeFormat(format)
612
+ return canonical === undefined ? "{}" : JSON.stringify(canonical)
613
+ }
614
+
615
+ /** One comment in the fixed key order `text, author, createdAt`. / 固定キー順 `text, author, createdAt` のコメント 1 件。 */
616
+ const serializeComment = (comment: ClipComment): string => `{"text":${JSON.stringify(comment.text)},"author":${JSON.stringify(comment.author)},"createdAt":${JSON.stringify(comment.createdAt)}}`
617
+
618
+ /** One cell in the fixed key order `t, f, fmt, val, note, comments, link` (absent keys omitted). / 固定キー順 `t, f, fmt, val, note, comments, link` のセル 1 つ (不在キーは省略)。 */
619
+ const serializeCell = (cell: ClipCell): string => {
620
+ let out = `{"t":${JSON.stringify(cell.t)}`
621
+ if (cell.f !== undefined) {
622
+ out += `,"f":${JSON.stringify(cell.f)}`
623
+ }
624
+ if (cell.fmt !== undefined) {
625
+ out += `,"fmt":${cell.fmt}`
626
+ }
627
+ if (cell.val !== undefined) {
628
+ out += `,"val":${cell.val}`
629
+ }
630
+ if (cell.note !== undefined) {
631
+ out += `,"note":${JSON.stringify(cell.note)}`
632
+ }
633
+ if (cell.comments !== undefined) {
634
+ out += `,"comments":[${cell.comments.map(serializeComment).join(",")}]`
635
+ }
636
+ if (cell.link !== undefined) {
637
+ out += `,"link":${JSON.stringify(cell.link)}`
638
+ }
639
+ return `${out}}`
640
+ }
641
+
642
+ /**
643
+ * Serialises a payload with the FIXED leading key order `{"v":1,"rows":R,"cols":C,"clipId":"…",…}`
644
+ * and a deterministic key order everywhere below (fileoverview). A structurally malformed
645
+ * payload (the parser's validation contract) is a RangeError — the serialiser never emits a
646
+ * string `parseClipPayload` would reject.
647
+ * 固定先頭キー順 `{"v":1,"rows":R,"cols":C,"clipId":"…",…}` と以下全階層の決定的キー順で直列化
648
+ * (fileoverview)。構造的に不正な payload (parser の検証契約) は RangeError — `parseClipPayload`
649
+ * が拒む文字列を直列化器は決して出さない。
650
+ */
651
+ export const serializeClipPayload = (payload: ClipPayload): string => {
652
+ if (!isClipPayload(payload)) {
653
+ throw new RangeError("[spreadsheet] serializeClipPayload received a payload outside the wire format v1 vocabulary")
654
+ }
655
+ const head = `{"v":1,"rows":${payload.rows},"cols":${payload.cols},"clipId":${JSON.stringify(payload.clipId)}`
656
+ const formats = payload.formats.map(serializeFormat).join(",")
657
+ const validations = payload.validations.map(stableJson).join(",")
658
+ const cells = payload.cells.map((row) => `[${row.map(serializeCell).join(",")}]`).join(",")
659
+ // 寸法配列は在るときだけ、cells と wholeRows の間
660
+ const colWidths = payload.colWidths === undefined ? "" : `,"colWidths":[${payload.colWidths.join(",")}]`
661
+ const rowHeights = payload.rowHeights === undefined ? "" : `,"rowHeights":[${payload.rowHeights.join(",")}]`
662
+ const conditionalFormats = payload.conditionalFormats.map(stableJson).join(",")
663
+ return `${head},"formats":[${formats}],"validations":[${validations}],"cells":[${cells}]${colWidths}${rowHeights},"wholeRows":${payload.wholeRows},"wholeCols":${payload.wholeCols},"conditionalFormats":[${conditionalFormats}]}`
664
+ }
665
+
666
+ /**
667
+ * Parses a wire-format string with TOTAL validation (fileoverview): null on any malformed input,
668
+ * never a partial payload, never a substituted value.
669
+ * wire format 文字列を全域検証つきで parse (fileoverview): 不正入力は全て null、部分 payload も
670
+ * 値の代替も無し。
671
+ */
672
+ export const parseClipPayload = (json: string): ClipPayload | null => {
673
+ let value: unknown
674
+ try {
675
+ value = JSON.parse(json)
676
+ } catch {
677
+ // 不正 JSON は「読めない」— fail-soft の null (parse エンジンの全域性)
678
+ return null
679
+ }
680
+ return isClipPayload(value) ? value : null
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------------------------
684
+ // Attribute carrier + header scan
685
+ // 属性運搬体 + ヘッダ走査
686
+ // ---------------------------------------------------------------------------------------------
687
+
688
+ /** The closed entity set of the attribute carrier (raw writer form + sanitised re-serialisation). / 属性運搬体の閉じた実体集合 (writer の生形 + サニタイズ再直列化形)。 */
689
+ const ENTITY_RE = /&(?:amp|lt|gt|quot|#39|nbsp);/g
690
+
691
+ /** Entity → character, exactly the six the regex matches. / 実体 → 文字、正規表現が当てる 6 つちょうど。 */
692
+ const ENTITIES: Readonly<Record<string, string>> = Object.freeze({ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'", "&nbsp;": "\u00a0" })
693
+
694
+ /**
695
+ * Decodes an attribute value in ONE pass over the closed entity set `&amp; &lt; &gt; &quot;
696
+ * &#39; &nbsp;` (so `&amp;lt;` yields the literal `&lt;` the cell contained); every other
697
+ * sequence stays verbatim. Both quote styles decode with the same table — the raw single-quoted
698
+ * writer form only ever holds `&amp;` / `&#39;` (and optionally `&lt;` / `&gt;`), a sanitising
699
+ * engine's double-quoted re-serialisation adds `&quot;` / `&nbsp;`. A value without `&` is
700
+ * returned as the same string instance.
701
+ * 閉じた実体集合 `&amp; &lt; &gt; &quot; &#39; &nbsp;` を 1 パスで復号 (`&amp;lt;` はセルが含んで
702
+ * いた文字通りの `&lt;` になる)。他の並びは全て原文のまま。両クオート形が同じ表で復号される —
703
+ * writer の生 (単一引用) 形は `&amp;` / `&#39;` (任意で `&lt;` / `&gt;`) しか持たず、サニタイズ
704
+ * エンジンの二重引用再直列化は `&quot;` / `&nbsp;` を足す。`&` の無い値は同一インスタンスを返す。
705
+ */
706
+ export const decodeClipAttribute = (attributeValue: string): string => (attributeValue.includes("&") ? attributeValue.replace(ENTITY_RE, (entity) => ENTITIES[entity]) : attributeValue)
707
+
708
+ /** The anchored header grammar: fixed keys, positive integers without leading zeros, a plain non-empty clipId. / anchor されたヘッダ文法: 固定キー、先頭 0 の無い正整数、素の非空 clipId。 */
709
+ const HEADER_RE = /^\{"v":1,"rows":([1-9]\d{0,14}),"cols":([1-9]\d{0,14}),"clipId":"([^"\\]+)"/
710
+
711
+ /**
712
+ * Reads the fixed leading keys from the start of a carrier (the JSON string or the attribute
713
+ * value, raw or entity-encoded): decodes at most the first CLIP_HEADER_WINDOW_BYTES code units
714
+ * and anchors on `{"v":1,"rows":R,"cols":C,"clipId":"…"`. Null when the carrier does not begin
715
+ * with a well-formed header (including a clipId whose closing quote lies beyond the window).
716
+ * 運搬体 (JSON 文字列か属性値、生 / 実体形) の先頭から固定先頭キーを読む: 先頭
717
+ * CLIP_HEADER_WINDOW_BYTES code unit までを復号し `{"v":1,"rows":R,"cols":C,"clipId":"…"` へ
718
+ * anchor。整形式のヘッダで始まらなければ null (閉じ引用が窓の外にある clipId を含む)。
719
+ */
720
+ export const readClipHeader = (source: string): ClipHeader | null => {
721
+ // 窓の外は復号も走査もしない — 11 MB の運搬体でもヘッダのコストは定数
722
+ const window = source.length > CLIP_HEADER_WINDOW_BYTES ? source.slice(0, CLIP_HEADER_WINDOW_BYTES) : source
723
+ const match = HEADER_RE.exec(decodeClipAttribute(window))
724
+ if (match === null) {
725
+ return null
726
+ }
727
+ return { v: 1, rows: Number(match[1]), cols: Number(match[2]), clipId: match[3] }
728
+ }
729
+
730
+ /** Where the attribute value starts and which quote closes it. / 属性値の開始位置と閉じ引用符。 */
731
+ type AttributeStart = { readonly start: number; readonly quote: string }
732
+
733
+ /**
734
+ * Locates the `data-aqsp-clip=` carrier in an HTML string: the value must be quoted with `'`
735
+ * (raw writer form) or `"` (re-serialised form); an unquoted or absent value is null.
736
+ * HTML 文字列内の `data-aqsp-clip=` 運搬体の位置: 値は `'` (writer の生形) か `"` (再直列化形) で
737
+ * 引用されていること。非引用 / 不在は null。
738
+ */
739
+ const findClipAttribute = (html: string): AttributeStart | null => {
740
+ const at = html.indexOf(CLIP_ATTRIBUTE_MARKER)
741
+ if (at === -1) {
742
+ return null
743
+ }
744
+ const quoteAt = at + CLIP_ATTRIBUTE_MARKER.length
745
+ const quote = html.charAt(quoteAt)
746
+ // JSON は空白 / 引用符を含み得るので非引用の属性値は運搬体になり得ない
747
+ if (quote !== "'" && quote !== '"') {
748
+ return null
749
+ }
750
+ return { start: quoteAt + 1, quote }
751
+ }
752
+
753
+ /** The one carrier a channel reads: the custom MIME's JSON, or the attribute inside the HTML. / 経路が読む唯一の運搬体: カスタム MIME の JSON か、HTML 内の属性。 */
754
+ type ClipCarrier = { readonly kind: "mime"; readonly json: string } | { readonly kind: "attribute"; readonly html: string; readonly at: AttributeStart }
755
+
756
+ /**
757
+ * Chooses the carrier: the custom MIME on the event channel when present (no other flavour is
758
+ * read), else the attribute in `text/html` (only when that type is present). Null when neither
759
+ * exists — a broken carrier never slides to the other one.
760
+ * 運搬体の選択: event 経路でカスタム MIME が在ればそれ (他フレーバーは読まない)、無ければ
761
+ * `text/html` の属性 (その種別が在るときだけ)。どちらも無ければ null — 壊れた運搬体から他方へは
762
+ * 滑らない。
763
+ */
764
+ const locateClipCarrier = (flavours: ClipFlavours, channel: ClipChannel): ClipCarrier | null => {
765
+ if (channel === "event" && flavours.types.includes(CLIP_MIME)) {
766
+ return { kind: "mime", json: flavours.getData(CLIP_MIME) }
767
+ }
768
+ if (!flavours.types.includes("text/html")) {
769
+ return null
770
+ }
771
+ const html = flavours.getData("text/html")
772
+ const at = findClipAttribute(html)
773
+ return at === null ? null : { kind: "attribute", html, at }
774
+ }
775
+
776
+ /**
777
+ * The header (clipId, rows × cols) of an `aqsp` source WITHOUT parsing the body — the custom
778
+ * MIME's JSON head on the event channel, the attribute head otherwise. The identity check on
779
+ * both channels reads this; null when the carrier is absent or its head is malformed (callers
780
+ * treat null as "unreadable" — never as a foreign clip).
781
+ * `aqsp` ソースのヘッダ (clipId、rows × cols) を本文 parse なしで — event 経路はカスタム MIME の
782
+ * JSON 先頭、他は属性の先頭。両経路の同一性判定がこれを読む。運搬体不在 / 先頭不正は null (呼び手
783
+ * は null を「読めない」と扱う — 外来クリップ扱いにはしない)。
784
+ */
785
+ export const readClipSourceHeader = (flavours: ClipFlavours, channel: ClipChannel): ClipHeader | null => {
786
+ const carrier = locateClipCarrier(flavours, channel)
787
+ if (carrier === null) {
788
+ return null
789
+ }
790
+ if (carrier.kind === "mime") {
791
+ return readClipHeader(carrier.json)
792
+ }
793
+ // 属性値の先頭 512 code unit だけを切り出す (閉じ引用の探索も本文の復号もしない)
794
+ return readClipHeader(carrier.html.slice(carrier.at.start, carrier.at.start + CLIP_HEADER_WINDOW_BYTES))
795
+ }
796
+
797
+ /**
798
+ * Extracts and parses the JSON of an `aqsp` source: the custom MIME on the event channel when
799
+ * present, else the attribute value up to its closing quote, entity-decoded. Not called on an
800
+ * identity hit. Null on a missing carrier, a missing closing quote or any validation failure.
801
+ * `aqsp` ソースの JSON 抽出 + parse: event 経路でカスタム MIME が在ればそれ、無ければ閉じ引用まで
802
+ * の属性値を実体復号。同一性ヒット時は呼ばれない。運搬体不在 / 閉じ引用不在 / 検証失敗は null。
803
+ */
804
+ export const readClipPayload = (flavours: ClipFlavours, channel: ClipChannel): ClipPayload | null => {
805
+ const carrier = locateClipCarrier(flavours, channel)
806
+ if (carrier === null) {
807
+ return null
808
+ }
809
+ if (carrier.kind === "mime") {
810
+ return parseClipPayload(carrier.json)
811
+ }
812
+ const end = carrier.html.indexOf(carrier.at.quote, carrier.at.start)
813
+ // 閉じ引用が無い属性は切り詰められた運搬体 — 読めない
814
+ if (end === -1) {
815
+ return null
816
+ }
817
+ return parseClipPayload(decodeClipAttribute(carrier.html.slice(carrier.at.start, end)))
818
+ }
819
+
820
+ // ---------------------------------------------------------------------------------------------
821
+ // Source detection (ADR-14)
822
+ // ソース検出 (ADR-14)
823
+ // ---------------------------------------------------------------------------------------------
824
+
825
+ /** Google Sheets wrapper element (attributes tolerated). / Google Sheets のラッパー要素 (属性は許容)。 */
826
+ const SHEETS_ORIGIN_RE = /<google-sheets-html-origin[\s>]/
827
+
828
+ /** Google Sheets table root attribute. / Google Sheets の表ルート属性。 */
829
+ const SHEETS_ROOT_MARKER = 'data-sheets-root="1"'
830
+
831
+ /** Excel generator meta (raw `name=Generator` or re-serialised `name="Generator"`). / Excel の generator meta (生 `name=Generator` / 再直列化 `name="Generator"`)。 */
832
+ const EXCEL_GENERATOR_RE = /<meta\s+name=["']?Generator["']?\s+content=["']?Microsoft Excel/
833
+
834
+ /** Any Office `mso-` CSS property. / Office の `mso-` CSS プロパティ。 */
835
+ const EXCEL_MSO_MARKER = "mso-"
836
+
837
+ /** An Excel `.xlNN {` style rule. / Excel の `.xlNN {` スタイル規則。 */
838
+ const EXCEL_XL_RULE_RE = /\.xl\d+\s*\{/
839
+
840
+ /** The table test marker (lowercase — the serialisation Excel / Sheets / Chromium emit). / 表判定マーカー (小文字 — Excel / Sheets / Chromium が出す直列化)。 */
841
+ const TABLE_MARKER = "<table"
842
+
843
+ /** The marker-refined kind of an HTML flavour, or null when no marker is present. / HTML フレーバーのマーカー種別、マーカー無しは null。 */
844
+ const detectHtmlMarker = (prefix: string): "aqsp" | "sheets" | "excel" | null => {
845
+ if (prefix.includes(CLIP_ATTRIBUTE_MARKER)) {
846
+ return "aqsp"
847
+ }
848
+ // Sheets は mso- も含むので excel より先に判定する
849
+ if (SHEETS_ORIGIN_RE.test(prefix) && prefix.includes(SHEETS_ROOT_MARKER)) {
850
+ return "sheets"
851
+ }
852
+ if (EXCEL_GENERATOR_RE.test(prefix) || prefix.includes(EXCEL_MSO_MARKER) || EXCEL_XL_RULE_RE.test(prefix)) {
853
+ return "excel"
854
+ }
855
+ return null
856
+ }
857
+
858
+ /**
859
+ * Detects the ONE source a paste reads (ADR-14, design v5): event channel — `types` contains
860
+ * the custom MIME ⇒ `aqsp` (nothing else is read); then, on both channels, the first
861
+ * CLIP_DETECT_PREFIX_BYTES of `text/html` are scanned for markers (`aqsp` → `sheets` → `excel`)
862
+ * and the FULL text is tested for `<table` — a marker refines the kind, `<table` alone is
863
+ * `html`, and an HTML flavour without any table is not a table source at all; then a non-empty
864
+ * `text/plain` ⇒ `tsv`; else `none`. Never tries a reader; `getData` is called only for the
865
+ * flavours the decision needs and never for a type absent from `types`.
866
+ * 貼り付けが読む唯一のソースを検出 (ADR-14、設計 v5): event 経路 — `types` にカスタム MIME が
867
+ * 在れば `aqsp` (他は読まない)。次に両経路で `text/html` の先頭 CLIP_DETECT_PREFIX_BYTES を
868
+ * マーカー走査 (`aqsp` → `sheets` → `excel`) し全文を `<table` で判定 — マーカーは種別を精密化、
869
+ * `<table` だけなら `html`、表の無い HTML フレーバーは表ソースにならない。次に非空 `text/plain` ⇒
870
+ * `tsv`、他は `none`。reader は試行しない。`getData` は判定が要するフレーバーにだけ、`types` に
871
+ * 無い種別には決して呼ばない。
872
+ */
873
+ export const detectClipSource = (flavours: ClipFlavours, channel: ClipChannel): ClipSource => {
874
+ if (channel === "event" && flavours.types.includes(CLIP_MIME)) {
875
+ return "aqsp"
876
+ }
877
+ if (flavours.types.includes("text/html")) {
878
+ const html = flavours.getData("text/html")
879
+ const prefix = html.length > CLIP_DETECT_PREFIX_BYTES ? html.slice(0, CLIP_DETECT_PREFIX_BYTES) : html
880
+ const marker = detectHtmlMarker(prefix)
881
+ // 自前運搬体は属性そのものが reader の入力 — 表判定を要しない
882
+ if (marker === "aqsp") {
883
+ return "aqsp"
884
+ }
885
+ // 表判定は全文 (両経路とも全文が手元にあり無償) — マーカー種も表が無ければ読めない
886
+ if (html.includes(TABLE_MARKER)) {
887
+ return marker === null ? "html" : marker
888
+ }
889
+ }
890
+ if (flavours.types.includes("text/plain") && flavours.getData("text/plain") !== "") {
891
+ return "tsv"
892
+ }
893
+ return "none"
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------------------------
897
+ // HTML table size estimate (ADR-12, before any DOMParser)
898
+ // HTML 表の規模見積もり (ADR-12、DOMParser 構築前)
899
+ // ---------------------------------------------------------------------------------------------
900
+
901
+ /** HTML clamp of `colspan` — shared by the estimator and the reader (`htmlInterop.ts`). / `colspan` の HTML 上限 — 見積もりと reader (`htmlInterop.ts`) が共有。 */
902
+ export const HTML_COLSPAN_MAX = 1000
903
+
904
+ /** HTML clamp of `rowspan` — shared by the estimator and the reader (`htmlInterop.ts`). / `rowspan` の HTML 上限 — 見積もりと reader (`htmlInterop.ts`) が共有。 */
905
+ export const HTML_ROWSPAN_MAX = 65534
906
+
907
+ const CODE_TAB = 0x09
908
+ const CODE_LF = 0x0a
909
+ const CODE_FF = 0x0c
910
+ const CODE_CR = 0x0d
911
+ const CODE_SPACE = 0x20
912
+ const CODE_QUOTE = 0x22
913
+ const CODE_APOS = 0x27
914
+ const CODE_PLUS = 0x2b
915
+ const CODE_SLASH = 0x2f
916
+ const CODE_ZERO = 0x30
917
+ const CODE_NINE = 0x39
918
+ const CODE_EQUALS = 0x3d
919
+ const CODE_GT = 0x3e
920
+ const CODE_A = 0x61
921
+ const CODE_B = 0x62
922
+ const CODE_D = 0x64
923
+ const CODE_F = 0x66
924
+ const CODE_H = 0x68
925
+ const CODE_R = 0x72
926
+ const CODE_T = 0x74
927
+
928
+ /** The end tags after `</t` that end the current row in the HTML parser (`</tr` is tested by code). / HTML パーサで現在行を終える `</t` 始まりの終了タグ (`</tr` はコード比較)。 */
929
+ const ROW_CLOSER_TAILS: readonly string[] = ["able", "body", "head", "foot"]
930
+
931
+ /** HTML whitespace. / HTML の空白。 */
932
+ const isHtmlSpace = (code: number): boolean => code === CODE_SPACE || code === CODE_TAB || code === CODE_LF || code === CODE_CR || code === CODE_FF
933
+
934
+ /** What may follow `<tr` / `<td` / `<th` for the token to count (NaN at end of input never does). / `<tr` / `<td` / `<th` が数えられるために続き得る文字 (入力末尾の NaN は該当しない)。 */
935
+ const isTokenEnd = (code: number): boolean => code === CODE_GT || code === CODE_SLASH || isHtmlSpace(code)
936
+
937
+ /**
938
+ * What may precede an attribute NAME inside a tag: whitespace, `/` (after a stray slash the
939
+ * tokenizer starts a new attribute — `<td/colspan=3>` is `colspan=3`) or the closing quote of the
940
+ * previous value (`x="1"colspan=3`). Anything else (`:` in `mso-ignore:colspan`) is not an attribute.
941
+ * タグ内で属性名の直前に来得る文字: 空白、`/` (孤立した斜線の後は新しい属性 — `<td/colspan=3>` は
942
+ * `colspan=3`)、前の値の閉じ引用 (`x="1"colspan=3`)。他 (`mso-ignore:colspan` の `:`) は属性でない。
943
+ */
944
+ const isAttributeStart = (code: number): boolean => isHtmlSpace(code) || code === CODE_SLASH || code === CODE_QUOTE || code === CODE_APOS
945
+
946
+ /**
947
+ * Reads a span attribute occurrence (`colspan` / `rowspan`, name length `length`) at `at`: null
948
+ * when it is not an attribute (see `isAttributeStart`); otherwise the HTML non-negative-integer
949
+ * reading of its value (optional quotes / whitespace / `+`, leading digits) with 0, a missing value
950
+ * or a non-numeric value reading as 1, clamped to `max` (the HTML cap the reader applies).
951
+ * 位置 `at` の span 属性 (`colspan` / `rowspan`、名前長 `length`) を読む: 属性でなければ
952
+ * (`isAttributeStart` 参照) null、属性なら値の HTML 非負整数読み (任意の引用符 / 空白 / `+`、先頭の
953
+ * 数字列)。0・値なし・非数は 1、`max` (reader が適用する HTML 上限) へクランプ。
954
+ */
955
+ const readSpan = (text: string, at: number, length: number, max: number): number | null => {
956
+ if (at === 0 || !isAttributeStart(text.charCodeAt(at - 1))) {
957
+ return null
958
+ }
959
+ let k = at + length
960
+ while (isHtmlSpace(text.charCodeAt(k))) {
961
+ k += 1
962
+ }
963
+ // 裸の属性 (`=` 無し) は空値 = 1
964
+ if (text.charCodeAt(k) !== CODE_EQUALS) {
965
+ return 1
966
+ }
967
+ k += 1
968
+ while (isHtmlSpace(text.charCodeAt(k))) {
969
+ k += 1
970
+ }
971
+ const quote = text.charCodeAt(k)
972
+ if (quote === CODE_QUOTE || quote === CODE_APOS) {
973
+ k += 1
974
+ }
975
+ while (isHtmlSpace(text.charCodeAt(k))) {
976
+ k += 1
977
+ }
978
+ if (text.charCodeAt(k) === CODE_PLUS) {
979
+ k += 1
980
+ }
981
+ let span = 0
982
+ let digits = 0
983
+ // 先頭の数字列だけ (HTML の非負整数解析)
984
+ for (let code = text.charCodeAt(k); code >= CODE_ZERO && code <= CODE_NINE; code = text.charCodeAt(k)) {
985
+ span = span * 10 + (code - CODE_ZERO)
986
+ digits += 1
987
+ k += 1
988
+ }
989
+ if (digits === 0 || span === 0) {
990
+ return 1
991
+ }
992
+ return span > max ? max : span
993
+ }
994
+
995
+ /**
996
+ * A monotonic reader of ONE span attribute name over the folded text: `(from, limit)` yields the
997
+ * attribute's value for the tag whose attribute region is `[from, limit)`, or 1 when the region
998
+ * holds none. The shared occurrence cursor only advances: an occurrence that is not an attribute
999
+ * (`mso-ignore:colspan`) is examined once for the whole document, so the total cost is
1000
+ * O(occurrences) however many tags share one region (an unclosed tag reaching the end of the
1001
+ * text costs nothing extra).
1002
+ * 折り畳み済み文字列上の span 属性名 1 つの単調 reader: `(from, limit)` は属性領域 `[from, limit)`
1003
+ * を持つタグの属性値、無ければ 1。共有の出現カーソルは前進しかせず、属性でない出現
1004
+ * (`mso-ignore:colspan`) は文書全体で 1 回しか調べない — 同じ領域を共有するタグが幾つあっても
1005
+ * O(出現数) (末尾まで届く未閉鎖タグも追加費用なし)。
1006
+ */
1007
+ const spanReader = (text: string, name: string, max: number): ((from: number, limit: number) => number) => {
1008
+ let at = text.indexOf(name)
1009
+ return (from, limit) => {
1010
+ // 領域より前の出現は読み飛ばす (単調前進)
1011
+ while (at !== -1 && at < from) {
1012
+ at = text.indexOf(name, at + name.length)
1013
+ }
1014
+ // 領域内の候補を順に読み、属性でない出現は共有カーソルごと越える (再訪しない)
1015
+ for (; at !== -1 && at < limit; at = text.indexOf(name, at + name.length)) {
1016
+ const span = readSpan(text, at, name.length, max)
1017
+ if (span !== null) {
1018
+ return span
1019
+ }
1020
+ }
1021
+ return 1
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Whether the end tag at `i` (`</…`) ends the current row in the HTML parser: `</tr`, `</table`,
1027
+ * `</tbody`, `</thead` or `</tfoot` followed by a token end (`</track` does not).
1028
+ * 位置 `i` の終了タグ (`</…`) が HTML パーサで現在行を終えるか: `</tr` / `</table` / `</tbody` /
1029
+ * `</thead` / `</tfoot` の直後がトークン末尾のとき (`</track` は該当しない)。
1030
+ */
1031
+ const closesRow = (text: string, i: number): boolean => {
1032
+ if (text.charCodeAt(i + 2) !== CODE_T) {
1033
+ return false
1034
+ }
1035
+ const third = text.charCodeAt(i + 3)
1036
+ if (third === CODE_R) {
1037
+ return isTokenEnd(text.charCodeAt(i + 4))
1038
+ }
1039
+ // 3 文字目で絞ってから綴りを比べる (`</td` / `</th` の熱経路は比較なしで抜ける)
1040
+ if (third !== CODE_A && third !== CODE_B && third !== CODE_H && third !== CODE_F) {
1041
+ return false
1042
+ }
1043
+ for (const tail of ROW_CLOSER_TAILS) {
1044
+ if (text.startsWith(tail, i + 3) && isTokenEnd(text.charCodeAt(i + 3 + tail.length))) {
1045
+ return true
1046
+ }
1047
+ }
1048
+ return false
1049
+ }
1050
+
1051
+ /**
1052
+ * One byte-linear pass upper-bound size of the table(s) in an HTML string (ADR-12 — computed
1053
+ * BEFORE any DOMParser is built), over the ASCII-case-folded text because the DOM reads tag and
1054
+ * attribute names case-insensitively: `<tr` / `<td` / `<th` count only when followed by
1055
+ * whitespace, `>` or `/` (`<track` / `<thead` / `<textarea` / `<title` never count), a `<td` before
1056
+ * any `<tr` opens one implicit row, `</tr` (`</table` / `</tbody` / `</thead` / `</tfoot`) closes
1057
+ * the row so a following `<td` opens another, `colspan` is summed per row, a `rowspan` carries its
1058
+ * colspan into the next `rowspan − 1` rows, and the result is rows × the maximum per-row
1059
+ * (carried + Σcolspan) — an UPPER bound of the reader's `rows × cols` (a wide header colspan may
1060
+ * reject an under-cap table). Spans are clamped to the HTML caps the reader applies. Every cursor
1061
+ * only advances, so the cost is O(bytes) whatever the shape. `{ rows: 0, cols: 0 }` without a
1062
+ * table.
1063
+ * HTML 文字列内の表の規模のバイト線形 1 パス上限見積もり (ADR-12 — DOMParser 構築前に計算)。DOM は
1064
+ * tag / 属性名を ASCII 大小無視で読むので折り畳んだ文字列を走査する: `<tr` / `<td` / `<th` は直後が
1065
+ * 空白 / `>` / `/` のときだけ数え (`<track` / `<thead` / `<textarea` / `<title` は非該当)、`<tr` より
1066
+ * 前の `<td` は暗黙行 1 つを開き、`</tr` (`</table` / `</tbody` / `</thead` / `</tfoot`) は行を閉じて
1067
+ * 続く `<td` が次の行を開き、`colspan` を行毎に合計、`rowspan` はその colspan を次の `rowspan − 1`
1068
+ * 行へ持ち越し、結果は rows × 行毎 (持越し + Σcolspan) の最大 — reader の `rows × cols` の上限値
1069
+ * (幅広いヘッダ colspan は上限内の表を拒否し得る)。span は reader と同じ HTML 上限へクランプ。
1070
+ * 全カーソルが前進しかしないので形に依らず O(bytes)。表が無ければ `{ rows: 0, cols: 0 }`。
1071
+ */
1072
+ export const estimateHtmlTableCells = (html: string): { rows: number; cols: number } => {
1073
+ // DOM は tag / 属性名を ASCII 大小無視で読む — 折り畳んだ写しを走査する (原文の索引へ戻らないので長さ変化は無害)
1074
+ const text = html.toLowerCase()
1075
+ const colspanOf = spanReader(text, "colspan", HTML_COLSPAN_MAX)
1076
+ const rowspanOf = spanReader(text, "rowspan", HTML_ROWSPAN_MAX)
1077
+ // タグ末尾 `>` の memo (単調前進、-1 は以後のタグが全て未閉鎖 = 末尾まで)
1078
+ let tagEnd = 0
1079
+ const limitOf = (from: number): number => {
1080
+ if (tagEnd !== -1 && tagEnd < from) {
1081
+ tagEnd = text.indexOf(">", from)
1082
+ }
1083
+ return tagEnd === -1 ? text.length : tagEnd
1084
+ }
1085
+ let rows = 0
1086
+ let cols = 0
1087
+ let current = 0
1088
+ let carried = 0
1089
+ let inRow = false
1090
+ // rowspan の持越し幅の差分 (行索引 r で +幅、r + rowspan で −幅)
1091
+ const carryDelta = new Map<number, number>()
1092
+ const addCarry = (rowIndex: number, width: number): void => {
1093
+ const previous = carryDelta.get(rowIndex)
1094
+ carryDelta.set(rowIndex, previous === undefined ? width : previous + width)
1095
+ }
1096
+ const closeRow = (): void => {
1097
+ // 行の幅 = 持ち越された列 + この行の Σcolspan
1098
+ if (inRow && current + carried > cols) {
1099
+ cols = current + carried
1100
+ }
1101
+ inRow = false
1102
+ }
1103
+ const openRow = (): void => {
1104
+ closeRow()
1105
+ rows += 1
1106
+ inRow = true
1107
+ current = 0
1108
+ // この行索引へ入る持越し幅を加える
1109
+ const delta = carryDelta.get(rows - 1)
1110
+ if (delta !== undefined) {
1111
+ carried += delta
1112
+ }
1113
+ }
1114
+ for (let i = text.indexOf("<"); i !== -1; i = text.indexOf("<", i + 1)) {
1115
+ const first = text.charCodeAt(i + 1)
1116
+ if (first === CODE_SLASH) {
1117
+ if (closesRow(text, i)) {
1118
+ closeRow()
1119
+ }
1120
+ continue
1121
+ }
1122
+ if (first !== CODE_T || !isTokenEnd(text.charCodeAt(i + 3))) {
1123
+ continue
1124
+ }
1125
+ const second = text.charCodeAt(i + 2)
1126
+ if (second === CODE_R) {
1127
+ openRow()
1128
+ } else if (second === CODE_D || second === CODE_H) {
1129
+ // `<tr` より前 / 閉じた行の後のセルは暗黙行を開く
1130
+ if (!inRow) {
1131
+ openRow()
1132
+ }
1133
+ const from = i + 3
1134
+ const limit = limitOf(from)
1135
+ const colspan = colspanOf(from, limit)
1136
+ current += colspan
1137
+ const rowspan = rowspanOf(from, limit)
1138
+ // 次の rowspan − 1 行へこの幅を持ち越す
1139
+ if (rowspan > 1) {
1140
+ addCarry(rows, colspan)
1141
+ addCarry(rows - 1 + rowspan, -colspan)
1142
+ }
1143
+ }
1144
+ }
1145
+ closeRow()
1146
+ return { rows, cols }
1147
+ }
1148
+
1149
+ // ---------------------------------------------------------------------------------------------
1150
+ // Copy-side dictionaries (identity → canonical JSON interning)
1151
+ // copy 側辞書 (identity → 正準 JSON の intern)
1152
+ // ---------------------------------------------------------------------------------------------
1153
+
1154
+ /**
1155
+ * The format dictionary builder of one copy: `intern` returns the dictionary index of a format
1156
+ * (undefined when its canonical form is empty — the cell carries no `fmt`), `entries` is the live
1157
+ * dictionary (canonical objects, sorted keys) to place in `ClipPayload.formats`.
1158
+ * copy 1 回の書式辞書ビルダー: `intern` は書式の辞書索引 (正準形が空なら undefined — セルは `fmt`
1159
+ * を持たない)、`entries` は `ClipPayload.formats` に置く生きた辞書 (正準 object、ソート済みキー)。
1160
+ */
1161
+ export type ClipFormatInterner = { readonly intern: (format: CellFormat) => number | undefined; readonly entries: readonly CellFormat[] }
1162
+
1163
+ /**
1164
+ * The validation dictionary builder of one copy: `intern` returns the dictionary index of a rule
1165
+ * (always defined — a rule is never empty), `entries` the live dictionary for
1166
+ * `ClipPayload.validations`.
1167
+ * copy 1 回の検証辞書ビルダー: `intern` は規則の辞書索引 (常に定義 — 規則が空になることはない)、
1168
+ * `entries` は `ClipPayload.validations` 用の生きた辞書。
1169
+ */
1170
+ export type ClipValidationInterner = { readonly intern: (rule: DataValidationRule) => number; readonly entries: readonly DataValidationRule[] }
1171
+
1172
+ /**
1173
+ * The key-stage dictionary shared by both interners: a string key per normalised value, one
1174
+ * entry per distinct key (structurally equal fresh objects share it), entries in first-seen order.
1175
+ * 両 interner が共有するキー段の辞書: 正規化値毎の文字列キー、キー毎に 1 エントリ (構造等価な別
1176
+ * object は共有)、エントリは初出順。
1177
+ */
1178
+ const createDictionary = <T>(keyOf: (value: T) => string): { intern: (value: T) => number; entries: T[] } => {
1179
+ const entries: T[] = []
1180
+ const byKey = new Map<string, number>()
1181
+ const intern = (value: T): number => {
1182
+ const key = keyOf(value)
1183
+ const known = byKey.get(key)
1184
+ if (known !== undefined) {
1185
+ return known
1186
+ }
1187
+ // 新規キー — 末尾へ追加
1188
+ const index = entries.length
1189
+ entries.push(value)
1190
+ byKey.set(key, index)
1191
+ return index
1192
+ }
1193
+ return { intern, entries }
1194
+ }
1195
+
1196
+ /**
1197
+ * Creates the format interner of one copy: identity THEN `JSON.stringify(canonicalizeFormat(f))`
1198
+ * (sorted keys at every level = the deterministic key); the dictionary stores the canonical
1199
+ * object; an empty canonical form (e.g. `{ bold: false }`) yields undefined and no entry.
1200
+ * copy 1 回の書式 interner: identity → `JSON.stringify(canonicalizeFormat(f))` (全階層ソート済み
1201
+ * キー = 決定的キー)。辞書は正準 object を保持、空の正準形 (例 `{ bold: false }`) は undefined で
1202
+ * エントリ無し。
1203
+ */
1204
+ export const createFormatInterner = (): ClipFormatInterner => {
1205
+ const byIdentity = new WeakMap<CellFormat, number | undefined>()
1206
+ const dictionary = createDictionary<CellFormat>((canonical) => JSON.stringify(canonical))
1207
+ const intern = (format: CellFormat): number | undefined => {
1208
+ // 第 1 段: identity (空の答え undefined も登録済みなら再正準化しない)
1209
+ if (byIdentity.has(format)) {
1210
+ return byIdentity.get(format)
1211
+ }
1212
+ const canonical = canonicalizeFormat(format)
1213
+ // 第 2 段: 正準 JSON (空の正準形はエントリ無し)
1214
+ const index = canonical === undefined ? undefined : dictionary.intern(canonical)
1215
+ byIdentity.set(format, index)
1216
+ return index
1217
+ }
1218
+ return { intern, entries: dictionary.entries }
1219
+ }
1220
+
1221
+ /**
1222
+ * Creates the validation interner of one copy: identity THEN sorted-key JSON of the rule (key
1223
+ * order independent); the dictionary stores the first rule object seen for each key.
1224
+ * copy 1 回の検証 interner: identity → 規則のソート済みキー JSON (キー順非依存)。辞書はキー毎に
1225
+ * 最初に見た規則 object を保持。
1226
+ */
1227
+ export const createValidationInterner = (): ClipValidationInterner => {
1228
+ const byIdentity = new WeakMap<DataValidationRule, number>()
1229
+ const dictionary = createDictionary<DataValidationRule>(stableJson)
1230
+ const intern = (rule: DataValidationRule): number => {
1231
+ // 第 1 段: identity
1232
+ const known = byIdentity.get(rule)
1233
+ if (known !== undefined) {
1234
+ return known
1235
+ }
1236
+ // 第 2 段: ソート済みキー JSON
1237
+ const index = dictionary.intern(rule)
1238
+ byIdentity.set(rule, index)
1239
+ return index
1240
+ }
1241
+ return { intern, entries: dictionary.entries }
1242
+ }