@aiquants/spreadsheet 0.15.0 → 0.16.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.
@@ -1,34 +1,102 @@
1
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).
2
+ * @fileoverview Clipboard TSV serialize/parse (§6.5.1) whose round trip is exact for every
3
+ * rectangular matrix of strings of at least 1 × 1: `parseTsv(serializeTsv(m))` deep-equals `m`.
4
+ * `serializeClipboardTsv` is the clipboard's text/plain writer (the grid's copy, and a host's own
5
+ * copy button): Excel's form, which quotes only fields holding a tab, CR or LF, so a plain-text
6
+ * target receives `"Important" note` as typed (the grid's own paste reads its JSON payload first, so
7
+ * a grid-to-grid paste stays exact). Files and stored bodies use the strict `serializeTsv`. The writer quotes a
8
+ * field (doubling its inner `"`) when it holds a tab, CR or LF — the only cells Excel quotes — OR
9
+ * when it starts with `"`, the one other cell a reader could mistake for a quoted field (a ditto
10
+ * mark `"` beside another one would otherwise read back as a single cell). The reader adopts the
11
+ * quoted reading only when the quoted field closes properly (the closing quote is followed by a
12
+ * tab, a line break or the end) AND its unescaped content holds a tab, CR, LF or `"` — exactly the
13
+ * fields the writer quotes. Everything else is a literal, so Excel's raw output keeps reading as
14
+ * before: a cell Excel emitted untouched such as `"hi"` (content without any of the four) stays
15
+ * `"hi"`. The one raw shape that changed reading is a cell that looks exactly like a quoted field
16
+ * holding a doubled quote (a literal `"a""b"` now reads `a"b`), which no Excel cell produces
17
+ * unquoted. Both readers share ONE grammar walk (`scanTsv`): `parseTsv` materialises the matrix,
18
+ * `measureTsv` only counts rows × columns so the ADR-12 paste gate can refuse an over-cap
19
+ * clipboard BEFORE any row array exists (a newline-only clipboard costs ~hundreds of bytes of
20
+ * matrix per input byte when materialised).
10
21
  *
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 の行列費用になる)。
22
+ * クリップボード TSV の直列化 / 解析 (§6.5.1)。文字列の少なくとも 1 × 1 のどの矩形行列でも往復が厳密
23
+ * (`parseTsv(serializeTsv(m))` が `m` と深く等しい)。`serializeClipboardTsv` はクリップボードの text/plain の
24
+ * 書き手 (グリッドのコピーと、ホスト自身のコピーのボタン) で、Excel の形 (タブ・CR・LF を含む値だけを囲む) なので、平文の
25
+ * 貼り先には `"Important" note` がそのまま届く (グリッド自身の貼り付けは JSON の payload を先に読むので、グリッドからグリッドへの
26
+ * 貼り付けは厳密なまま)。ファイルと保存する本文は厳密な `serializeTsv` を使う。書き手はタブ・CR・LF を含む値 (Excel が引用符で
27
+ * 囲む唯一のセル) に加え、`"` で始まる値 (読み手が引用されたフィールドと取り違え得る唯一の残り。
28
+ * 同じ記号 `"` が 2 つ並ぶと 1 つのセルに読み戻される) も引用符で囲み、中の `"` を二重化する。
29
+ * 読み手が引用の読みを採るのは、引用が正しく閉じ (閉じの引用符の直後がタブ・改行・終端)、かつ
30
+ * 剥がした中身がタブ・CR・LF・`"` のどれかを含むとき — 書き手が囲む値とちょうど同じ — だけ。
31
+ * それ以外は文字どおりの値なので、Excel の素の出力は従来どおりに読める (Excel が囲まずに出した
32
+ * `"hi"` は中身に 4 つのどれも無いため `"hi"` のまま)。読みが変わった素の形は、二重化した引用符を
33
+ * 持つ引用のフィールドとそっくりな値 (文字どおりの `"a""b"` は `a"b` と読む) だけで、Excel が囲まずに
34
+ * 出すセルには無い。読み手 2 つは唯一の文法走査 (`scanTsv`) を共有: `parseTsv` は行列を実体化し、
35
+ * `measureTsv` は行 × 列だけを数える — ADR-12 の paste ゲートが上限超のクリップボードを行配列の
36
+ * 実体化**前**に拒否できる (改行だけのクリップボードは実体化で入力 1 byte あたり数百 byte の行列費用になる)。
18
37
  */
19
38
 
20
39
  /**
21
- * Serializes a matrix to Excel-behavior TSV with a trailing CRLF.
22
- * 行列を終端 CRLF つきの Excel 実挙動 TSV へ直列化。
40
+ * Joins escaped fields with tabs, rows with CRLF, and ends with a CRLF.
41
+ * 囲みを済ませた値をタブで、行を CRLF でつなぎ、CRLF で終える処理。
42
+ *
43
+ * @param matrix - The rows of cell strings. セルの文字列の行。
44
+ * @param escapeField - The field writer. 値の書き手。
45
+ * @returns The TSV text. TSV の文字列。
23
46
  */
24
- export const serializeTsv = (matrix: readonly (readonly string[])[]): string =>
47
+ const joinTsv = (matrix: readonly (readonly string[])[], escapeField: (field: string) => string): string =>
25
48
  // 終端 CRLF は必須 (Excel 実挙動)。parseTsv の「終端の改行 1 つを剥がす」規約と対 —
26
49
  // 終端なしだと [["a"],[""]] が [["a"]] に潰れ (cut-move で clear されるべき先に旧値が残る)、
27
50
  // [[""]] は "" になって §6.5.4-5 の空ガードに飲まれ「空セル貼り付けで clear」が不発になる
28
- `${matrix.map((row) => row.map(escapeTsvField).join("\t")).join("\r\n")}\r\n`
51
+ `${matrix.map((row) => row.map(escapeField).join("\t")).join("\r\n")}\r\n`
29
52
 
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)
53
+ /**
54
+ * Serializes a matrix to TSV with a trailing CRLF; `parseTsv` reads the result back to the same
55
+ * matrix for every rectangular matrix of strings of at least 1 × 1 (the strict writer the package
56
+ * publishes; a host stores tables with it).
57
+ * 行列を終端 CRLF つきの TSV へ直列化する処理。文字列の少なくとも 1 × 1 のどの矩形行列でも `parseTsv` が同じ行列へ
58
+ * 読み戻す (パッケージが公開する厳密な書き手。ホストは表をこれで保存する)。
59
+ *
60
+ * @param matrix - The rows of cell strings. セルの文字列の行。
61
+ * @returns The TSV text. TSV の文字列。
62
+ */
63
+ export const serializeTsv = (matrix: readonly (readonly string[])[]): string => joinTsv(matrix, escapeTsvField)
64
+
65
+ /**
66
+ * Serializes a matrix the way Excel writes its clipboard text, with a trailing CRLF: only a field
67
+ * holding a tab, CR or LF is quoted (inner quotes doubled), so a field that merely starts with `"`
68
+ * reaches a plain-text target as typed. Its round trip is not exact for such fields (a raw
69
+ * `"\t"\tBlue` reads back as two cells); the grid's copy writes text/plain with it (and so should a
70
+ * host's own copy control, to match), and the grid's own paste reads the JSON payload first. Files and
71
+ * stored bodies use `serializeTsv`.
72
+ * Excel がクリップボードの文字を書く形で行列を終端 CRLF つきに直列化する処理。囲むのはタブ・CR・LF を含む値だけ (中の
73
+ * `"` は二重化) なので、`"` で始まるだけの値は平文の貼り先へそのまま届く。そうした値の往復は厳密でない (素の
74
+ * `"\t"\tBlue` は 2 つのセルに読み戻る)。グリッドのコピーが text/plain をこれで書き (ホスト自身のコピーの部品も揃えて
75
+ * これで書く)、グリッド自身の貼り付けは JSON の payload を先に読む。ファイルと保存する本文は `serializeTsv` を使う。
76
+ *
77
+ * @param matrix - The rows of cell strings. セルの文字列の行。
78
+ * @returns The TSV text in Excel's form. Excel の形の TSV の文字列。
79
+ */
80
+ export const serializeClipboardTsv = (matrix: readonly (readonly string[])[]): string => joinTsv(matrix, escapeClipboardTsvField)
81
+
82
+ /**
83
+ * Quotes a field that holds a tab, CR or LF or starts with `"` (inner quotes doubled); any other
84
+ * field is written verbatim.
85
+ * タブ・CR・LF を含むか `"` で始まる値を引用符で囲む処理 (中の `"` は二重化)。ほかの値はそのまま書く。
86
+ *
87
+ * @param field - The cell string. セルの文字列。
88
+ * @returns The field as written to TSV. TSV へ書く形の値。
89
+ */
90
+ const escapeTsvField = (field: string): string => (/[\t\r\n]/.test(field) || field.startsWith('"') ? `"${field.replaceAll('"', '""')}"` : field)
91
+
92
+ /**
93
+ * Quotes a field only when it holds a tab, CR or LF (inner quotes doubled) — the cells Excel quotes.
94
+ * タブ・CR・LF を含む値だけを引用符で囲む処理 (中の `"` は二重化) — Excel が囲むセル。
95
+ *
96
+ * @param field - The cell string. セルの文字列。
97
+ * @returns The field as Excel writes it. Excel が書く形の値。
98
+ */
99
+ const escapeClipboardTsvField = (field: string): string => (/[\t\r\n]/.test(field) ? `"${field.replaceAll('"', '""')}"` : field)
32
100
 
33
101
  /**
34
102
  * Strips the single trailing line terminator (CRLF / LF / lone CR) — the last row's terminator,
@@ -37,38 +105,36 @@ const escapeTsvField = (field: string): string => (/[\t\r\n]/.test(field) ? `"${
37
105
  */
38
106
  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
107
 
108
+ /** The token scanTsv yields after the last field of every row. / scanTsv が各行の最後の値の後に出す印。 */
109
+ const ROW_END: unique symbol = Symbol("row end")
110
+
40
111
  /**
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` は終端剥がし済み・非空であること。
112
+ * The ONE grammar walk every reader shares (parseTsv through iterateTsvRows, and measureTsv): yields each field of
113
+ * `body` in order and ROW_END after the last field of every row (the last row included), adopting the quoted reading only
114
+ * under the two-part condition of tryReadQuotedField. Lazy: nothing past the token a consumer takes is read. `body` must
115
+ * be terminator-stripped and non-empty.
116
+ * 全ての読み手 (iterateTsvRows を通る parseTsv と measureTsv) が共有する唯一の文法走査: `body` の値を順に出し、各行の最後の
117
+ * 値の後に ROW_END を出す (最終行も)。quoted 解釈の採否は tryReadQuotedField の 2 条件だけ。遅延で、受け手が取った印より先は
118
+ * 読まない。`body` は終端剥がし済み・非空であること。
119
+ *
120
+ * @param body - The terminator-stripped TSV text. 終端を剥がした TSV の文字列。
121
+ * @returns The field / row-end tokens. 値と行末の印。
47
122
  */
48
- const scanTsv = (body: string, onField: (field: string) => void, onRowEnd: () => void): void => {
123
+ function* scanTsv(body: string): Generator<string | typeof ROW_END> {
49
124
  let i = 0
50
125
  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
- }
126
+ // quoted 解釈の採否は tryReadQuotedField の 2 条件だけが決める (不成立は literal — Excel が囲まずに出した `"hi"` を壊さない)
127
+ const quoted = body[i] === '"' ? tryReadQuotedField(body, i) : null
128
+ if (quoted !== null) {
129
+ yield quoted.value
130
+ i = quoted.nextIndex
64
131
  } else {
65
132
  const end = nextDelimiter(body, i)
66
- field = body.slice(i, end)
133
+ yield body.slice(i, end)
67
134
  i = end
68
135
  }
69
- onField(field)
70
136
  if (i >= body.length) {
71
- onRowEnd()
137
+ yield ROW_END
72
138
  return
73
139
  }
74
140
  if (body[i] === "\t") {
@@ -76,32 +142,51 @@ const scanTsv = (body: string, onField: (field: string) => void, onRowEnd: () =>
76
142
  } else {
77
143
  // 改行 (CRLF / LF / CR)
78
144
  i += body[i] === "\r" && body[i + 1] === "\n" ? 2 : 1
79
- onRowEnd()
145
+ yield ROW_END
80
146
  }
81
147
  }
82
148
  }
83
149
 
84
150
  /**
85
- * Parses Excel-behavior TSV into a rectangular matrix (ragged rows are right-padded).
86
- * Excel 実挙動 TSV を矩形行列へ解析 (ギザギザ行は右パディング)。
151
+ * Yields the rows of a TSV text one at a time, in exactly parseTsv's grammar (the same quoting rule, line terminators
152
+ * and final-terminator handling; empty text is one row with one empty cell), WITHOUT padding them to a common width. A
153
+ * consumer that stops early materialises nothing past the last row it took, so a large text can be previewed under a
154
+ * cell budget.
155
+ * TSV の文字列の行を 1 つずつ出す処理。parseTsv とちょうど同じ文法 (同じ引用の規則・改行・末尾の改行の扱い。空の文字は空のセル
156
+ * 1 つの 1 行) で、幅を揃える右の埋めはしない。途中で止めた受け手は、最後に取った行より先を何も実体化しないので、大きな文字を
157
+ * セルの予算の下で見られる。
158
+ *
159
+ * @param text - The TSV text. TSV の文字列。
160
+ * @returns The rows, unpadded. 埋めない行。
87
161
  */
88
- export const parseTsv = (text: string): string[][] => {
162
+ export function* iterateTsvRows(text: string): Generator<string[]> {
89
163
  const body = stripTrailingTerminator(text)
90
164
  if (body === "") {
91
- return [[""]]
165
+ yield [""]
166
+ return
92
167
  }
93
- const rows: string[][] = []
94
168
  let row: string[] = []
95
- scanTsv(
96
- body,
97
- (field) => {
98
- row.push(field)
99
- },
100
- () => {
101
- rows.push(row)
169
+ for (const token of scanTsv(body)) {
170
+ if (token === ROW_END) {
171
+ yield row
102
172
  row = []
103
- },
104
- )
173
+ } else {
174
+ row.push(token)
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Parses TSV into a rectangular matrix (ragged rows are right-padded with ""; one trailing line
181
+ * terminator is dropped; empty text is one empty cell): the rows of iterateTsvRows, padded.
182
+ * TSV を矩形行列へ解析する処理 (不揃いの行は "" で右を埋め、末尾の改行 1 つを除き、空の文字は空のセル 1 つ)。iterateTsvRows の
183
+ * 行を埋めたもの。
184
+ *
185
+ * @param text - The TSV text. TSV の文字列。
186
+ * @returns The rectangular matrix, at least 1 × 1. 少なくとも 1 × 1 の矩形行列。
187
+ */
188
+ export const parseTsv = (text: string): string[][] => {
189
+ const rows = Array.from(iterateTsvRows(text))
105
190
  // 矩形化: Excel 由来は常に矩形だが、他アプリ由来のギザギザ行も右パディングで受ける (仕様)。
106
191
  // ❗ Math.max(...rows) は行数ぶん引数を積み、巨大貼り付け (V8 実測 ~15 万行、JSC はさらに低い)
107
192
  // で ADR-12 見積もりに到達する前に RangeError で落ちる — ループで求める
@@ -122,6 +207,9 @@ export const parseTsv = (text: string): string[][] => {
122
207
  * `parseTsv(text)` が実体化する寸法 — 行数と矩形化後 (最大) の列数 — を**同じ**文法走査から、
123
208
  * 行配列を一切作らずに返す: `tsv` 貼り付けソースの ADR-12 実体化前見積もり (素の改行計数は
124
209
  * quoted 内の埋め込み改行を行と数え、正当な貼り付けを誤って上限拒否する)。
210
+ *
211
+ * @param text - The TSV text. TSV の文字列。
212
+ * @returns The row count and the maximum column count. 行数と最大の列数。
125
213
  */
126
214
  export const measureTsv = (text: string): { rows: number; cols: number } => {
127
215
  const body = stripTrailingTerminator(text)
@@ -131,23 +219,31 @@ export const measureTsv = (text: string): { rows: number; cols: number } => {
131
219
  let rows = 0
132
220
  let cols = 0
133
221
  let current = 0
134
- scanTsv(
135
- body,
136
- () => {
137
- current += 1
138
- },
139
- () => {
222
+ for (const token of scanTsv(body)) {
223
+ if (token === ROW_END) {
140
224
  rows += 1
141
225
  if (current > cols) {
142
226
  cols = current
143
227
  }
144
228
  current = 0
145
- },
146
- )
229
+ } else {
230
+ current += 1
231
+ }
232
+ }
147
233
  return { rows, cols }
148
234
  }
149
235
 
150
- /** Reads a quoted field at i, adopted ONLY when its content needs quoting. / 位置 i の quoted field 読取り (中身が quote を要する場合のみ採用、不成立は null)。 */
236
+ /**
237
+ * Reads the quoted field that starts at `start`, adopted ONLY when it closes properly and its
238
+ * content holds a tab, CR, LF or `"` (exactly the fields `serializeTsv` quotes); otherwise null,
239
+ * and the caller reads a literal.
240
+ * 位置 `start` から始まる引用のフィールドを読む処理。正しく閉じ、中身がタブ・CR・LF・`"` のどれかを含む
241
+ * とき (`serializeTsv` が囲む値とちょうど同じ) だけ採用し、そうでなければ null (呼び手は文字どおりに読む)。
242
+ *
243
+ * @param body - The terminator-stripped TSV text. 終端を剥がした TSV の文字列。
244
+ * @param start - The index of the opening quote. 開きの引用符の位置。
245
+ * @returns The unescaped value and the index after the closing quote, or null. 剥がした値と閉じの直後の位置、または null。
246
+ */
151
247
  const tryReadQuotedField = (body: string, start: number): { value: string; nextIndex: number } | null => {
152
248
  let value = ""
153
249
  let i = start + 1
@@ -158,11 +254,10 @@ const tryReadQuotedField = (body: string, start: number): { value: string; nextI
158
254
  i += 2
159
255
  } else {
160
256
  const next = i + 1
161
- // 成立条件は 2 つとも必要: (a) 閉じ引用の直後が区切りか終端、
162
- // (b) 中身に改行かタブを含む (Excel が quote する唯一の理由)。
163
- // (b) が無いと、全体がちょうど引用で包まれた literal `"hi"` を剥がしてしまう
257
+ // 成立条件は 2 つとも必要: (a) 閉じ引用の直後が区切りか終端、(b) 中身がタブ・改行・`"` を含む
258
+ // (書き手が囲む値の集合と同じ)。(b) が無いと、Excel が囲まずに出した literal `"hi"` を剥がしてしまう
164
259
  const isBoundary = next >= body.length || body[next] === "\t" || body[next] === "\n" || body[next] === "\r"
165
- if (isBoundary && /[\t\r\n]/.test(value)) {
260
+ if (isBoundary && /[\t\r\n"]/.test(value)) {
166
261
  return { value, nextIndex: next }
167
262
  }
168
263
  return null
@@ -88,7 +88,7 @@ import {
88
88
  import { parseHtmlTable, serializeHtmlTable } from "./htmlInterop.ts"
89
89
  import { DEFAULT_PASTE_OPTIONS, MODE_ASPECTS, type PasteAspects, type PasteOptions, patchFromClipCell, stripUndeclaredAspects, translateConditionalFormats, transposePayload } from "./pasteSpecial.ts"
90
90
  import { computePasteTargetRect, expandRichPaste } from "./tiling.ts"
91
- import { measureTsv, parseTsv, serializeTsv } from "./tsv.ts"
91
+ import { measureTsv, parseTsv, serializeClipboardTsv } from "./tsv.ts"
92
92
 
93
93
  /** The armed clip surface a consumer sees (`onClipChange` / `getClip`): `foreign` = the record belongs to another adapter (sheet switch kept a cut). / consumer が見る装填クリップ (`onClipChange` / `getClip`): `foreign` = 記録が別アダプタのもの (シート切替が cut を保持)。 */
94
94
  export type ClipInfo = { kind: "copy" | "cut"; range: CellRange; foreign: boolean }
@@ -709,7 +709,8 @@ export const useClipboard = (params: UseClipboardParams): ClipboardController =>
709
709
  if (data !== null) {
710
710
  // TSV は素の文字列 (数式セルも値文字列)、HTML は先頭属性に同じ JSON、カスタム MIME は
711
711
  // paste イベント経路の高速マーカー (read() 経路には届かない)
712
- data.setData("text/plain", serializeTsv(payload.cells.map((line) => line.map((cell) => cell.t))))
712
+ // 平文の貼り先には Excel の形 (囲むのはタブ・改行を含む値だけ)。グリッド自身の貼り付けは JSON の payload を先に読む
713
+ data.setData("text/plain", serializeClipboardTsv(payload.cells.map((line) => line.map((cell) => cell.t))))
713
714
  data.setData("text/html", serializeHtmlTable(payload, json))
714
715
  data.setData(CLIP_MIME, json)
715
716
  }
@@ -166,6 +166,14 @@ export type UseCommandsParams = {
166
166
  announce: (text: string) => void
167
167
  formatCommandAnnouncement?: (ctx: { command: SpreadsheetCommand; state: boolean | null }) => string
168
168
  /** The active cell's keyboard rect (onPasteSpecialRequest origin). / active セルのキーボード矩形 (onPasteSpecialRequest の origin)。 */
169
+ /**
170
+ * The keyboard resize capability (the header resize machine's keyboard twins); absent ⇒ the resize commands are
171
+ * unavailable (`enableResizeUI` false or an empty grid).
172
+ * キーボードの寸法の能力 (帯のリサイズ機械のキーボードの双子)。無ければ寸法のコマンドは使えない (`enableResizeUI` が false か空のグリッド)。
173
+ */
174
+ trackResize?: { readonly stepTrack: (axis: "row" | "col", index: number, direction: 1 | -1) => boolean; readonly autofitColumn: (index: number) => boolean }
175
+ /** Live-region text after a keyboard resize (the active track's new size); absent ⇒ silent. / キーボードの寸法の変更の後の読み上げ (アクティブなトラックの新しい寸法)。無ければ無音。 */
176
+ formatResizeAnnouncement?: (ctx: { command: SpreadsheetCommand; axis: "row" | "column"; index: number; size: number }) => string
169
177
  keyboardCellRect: () => KeyboardCellRect
170
178
  }
171
179
 
@@ -218,6 +226,7 @@ type CommandSpec =
218
226
  | { kind: "paste"; options: PasteOptions }
219
227
  | { kind: "insertNow"; unit: "date" | "time" }
220
228
  | { kind: "hide"; axis: "row" | "col"; hide: boolean }
229
+ | { kind: "resize"; axis: "row" | "col"; step: 1 | -1 | "fit" }
221
230
  | { kind: "highlight"; target: "row" | "column" | "both" | "intersection" }
222
231
 
223
232
  /** Fill presets of the four named fill-colour toggles — pastels (design v3 §7 / the reference extension: legible behind the default text colour). / 名前つき塗りトグル 4 種のプリセット — 淡色 (設計 v3 §7 / 参照拡張機能と同値: 既定文字色の背後で可読)。 */
@@ -294,6 +303,11 @@ const COMMAND_SPECS: { readonly [K in SpreadsheetCommand]: CommandSpec } = {
294
303
  fontColorCustom3: { kind: "color", key: "color", preset: null, custom: 2 },
295
304
  fontColorCustom4: { kind: "color", key: "color", preset: null, custom: 3 },
296
305
  fontColorCustom5: { kind: "color", key: "color", preset: null, custom: 4 },
306
+ increaseColumnWidth: { kind: "resize", axis: "col", step: 1 },
307
+ decreaseColumnWidth: { kind: "resize", axis: "col", step: -1 },
308
+ increaseRowHeight: { kind: "resize", axis: "row", step: 1 },
309
+ decreaseRowHeight: { kind: "resize", axis: "row", step: -1 },
310
+ autoFitColumnWidth: { kind: "resize", axis: "col", step: "fit" },
297
311
  toggleRowHighlight: { kind: "highlight", target: "row" },
298
312
  toggleColumnHighlight: { kind: "highlight", target: "column" },
299
313
  toggleBothHighlight: { kind: "highlight", target: "both" },
@@ -748,6 +762,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
748
762
  const formatDeclared = adapter.getCellFormat !== undefined
749
763
  const popoverEnabled = (kind: PopoverKind): boolean => (p.popovers === undefined ? true : p.popovers[kind] !== false)
750
764
  switch (spec.kind) {
765
+ case "resize":
766
+ return p.trackResize !== undefined
751
767
  case "toggle":
752
768
  case "patch":
753
769
  case "clear":
@@ -842,6 +858,30 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
842
858
  [activeHyperlink],
843
859
  )
844
860
 
861
+ /**
862
+ * Runs one keyboard resize command on the active track (the header resize machine's keyboard twin) and announces the
863
+ * track's size afterwards; returns false when the capability is absent or the machine refused.
864
+ * キーボードの寸法のコマンド 1 つをアクティブなトラックへ当て (帯のリサイズ機械のキーボードの双子)、後でトラックの寸法を
865
+ * 読み上げる処理。能力が無いときと機械が拒んだときは false。
866
+ */
867
+ const runResize = useCallback((command: SpreadsheetCommand, axis: "row" | "col", step: 1 | -1 | "fit"): boolean => {
868
+ const p = paramsRef.current
869
+ const resize = p.trackResize
870
+ // 可否は isCommandAvailable が先に確かめている — 無いのは到達しない枝で、能力の代わりの値は作らない
871
+ if (resize === undefined) {
872
+ return false
873
+ }
874
+ const active = p.getActive()
875
+ const index = axis === "col" ? active.col : active.row
876
+ const ran = step === "fit" ? resize.autofitColumn(index) : resize.stepTrack(axis, index, step)
877
+ const size = (axis === "col" ? p.colAxis : p.rowAxis).sizeOf(index)
878
+ // 隠れたトラック (0 px) は何も変えずに消費する — 寸法の読み上げも出さない
879
+ if (ran && size > 0 && p.formatResizeAnnouncement !== undefined) {
880
+ p.announce(p.formatResizeAnnouncement({ command, axis: axis === "col" ? "column" : "row", index, size }))
881
+ }
882
+ return ran
883
+ }, [])
884
+
845
885
  const executeCommand = useCallback(
846
886
  (command: SpreadsheetCommand): boolean => {
847
887
  const p = paramsRef.current
@@ -980,6 +1020,8 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
980
1020
  }
981
1021
  case "hide":
982
1022
  return runHide(spec.axis, spec.hide)
1023
+ case "resize":
1024
+ return runResize(command, spec.axis, spec.step)
983
1025
  default: {
984
1026
  const h = p.getHighlight()
985
1027
  switch (spec.target) {
@@ -1002,7 +1044,7 @@ export const useCommands = (params: UseCommandsParams): CommandRunner => {
1002
1044
  }
1003
1045
  }
1004
1046
  },
1005
- [isCommandAvailable, gateReadOnlyChord, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runFillSelectionEditing, runHide],
1047
+ [isCommandAvailable, gateReadOnlyChord, activeHyperlink, activeBase, writeConstantPatch, announceFormat, setBorders, runCalc, runFill, runFillSelectionEditing, runHide, runResize],
1006
1048
  )
1007
1049
 
1008
1050
  /** Commit gate shared by the annotation setters. / 注釈 setter 共通の commit ゲート。 */
@@ -28,6 +28,14 @@
28
28
  * helper (ADR-31 — no package CSS; capability absent ⇒ O(1) false ⇒ keys absent ⇒ the DOM
29
29
  * stays byte-identical).
30
30
  *
31
+ * Accessibility structure (v0.16): the body wrapper is the ONE `role="grid"` element (row / column counts, its name
32
+ * from `labelledBy` or the built-in root label, `aria-readonly` from `readOnly`; no `aria-multiselectable`, since no
33
+ * cell carries `aria-selected` — the live region announces the selection), so the frozen leading,
34
+ * scrolling and frozen trailing panes VirtualGrid draws inside it are all owned by it; the scrolling pane's content
35
+ * element is the body `rowgroup`, the cells of the first `headerRows` rows are `columnheader` (`data-aqsp-header-row`),
36
+ * and both scroll bars are declared pointer-only (`enableArrowButtonTabStops: false` — the grid owns keyboard
37
+ * scrolling), so no `scrollbar` / `button` becomes a child of the grid.
38
+ *
31
39
  * UI chrome locale: the resolved `locale` and the value-stable engine subset `scrollLabels` are
32
40
  * forwarded to VirtualGrid, which localizes the embedded vertical bar, the horizontal bar and the
33
41
  * degenerate-band "No items" — no lang attribute, no new data-* hook.
@@ -55,6 +63,13 @@
55
63
  * `aria-readonly="true"` + `data-aqsp-readonly=""` を `isReadOnlyCell` ヘルパ経由で条件スタンプ
56
64
  * する (ADR-31 — パッケージ CSS なし。能力未宣言 = O(1) false = キー不在 = DOM バイト同一)。
57
65
  *
66
+ * アクセシビリティの構造 (v0.16): 本体の包みが唯一の `role="grid"` (行数 / 列数・`labelledBy` か組み込みのルートの名前による
67
+ * 名前・`readOnly` からの `aria-readonly`。セルが `aria-selected` を持たないので `aria-multiselectable` は付けず、選択は
68
+ * live region が読み上げる) で、VirtualGrid がその中に描く先頭の凍結・スクロール・末尾の凍結の
69
+ * ペインを全て持つ。スクロールのペインの中身の要素は本体の `rowgroup`、先頭 `headerRows` 行のセルは `columnheader`
70
+ * (`data-aqsp-header-row`)、両方のスクロールバーはポインタ専用 (`enableArrowButtonTabStops: false` — キーボードのスクロールは
71
+ * グリッドが持つ) と宣言し、`scrollbar` / `button` をグリッドの子にしない。
72
+ *
58
73
  * UI クロームロケール: 解決済み `locale` と値安定なエンジン部分集合 `scrollLabels` を VirtualGrid へ
59
74
  * 素通しし、埋込み縦バー・横バー・退化帯の "No items" を現地化する (lang 属性・新規 data-* フックなし)。
60
75
  */
@@ -83,6 +98,15 @@ const ALIGN_ONLY_STYLES: Readonly<Record<CellAlign, CellStyle>> = Object.freeze(
83
98
  right: Object.freeze({ justifyContent: alignToJustify("right") }),
84
99
  })
85
100
 
101
+ /**
102
+ * The scrolling pane's content element is the row group of the body rows; the `role="grid"` element is the body wrapper,
103
+ * which contains the frozen leading pane, the scrolling pane and the frozen trailing pane, so every row has the grid as
104
+ * an ancestor.
105
+ * スクロールするペインの中身の要素は本体の行の行グループ。`role="grid"` は本体の包みが持ち、先頭の凍結のペイン・スクロールの
106
+ * ペイン・末尾の凍結のペインを全て含むので、どの行もグリッドを祖先に持つ。
107
+ */
108
+ const BODY_ROWGROUP_PROPS = Object.freeze({ role: "rowgroup" }) as { readonly role: "rowgroup" }
109
+
86
110
  /** Whole-Map clearing threshold of the canonical-JSON style intern (the CF memo's bound). / 正準 JSON style intern の全消去しきい値 (CF memo と同じ上限)。 */
87
111
  export const STYLE_INTERN_LIMIT = 4096
88
112
 
@@ -287,6 +311,14 @@ type GridBodyProps = {
287
311
  frozenTrailingColumns: number
288
312
  /** Trailing frozen rows (v0.9 — VirtualGrid frozenTrailingRows engine-validated pass-through; the row twin). / 末尾凍結行数 (v0.9 — frozenTrailingRows のエンジン検証素通し。行双子)。 */
289
313
  frozenTrailingRows: number
314
+ /** Leading rows whose cells are column headers (validated by the root against frozenRows). / セルが列の見出しになる先頭の行数 (root が frozenRows と照らして検証済み)。 */
315
+ headerRows: number
316
+ /** The id naming the grid (`aria-labelledby` of the `role="grid"` element), or undefined. / グリッドに名前を与える id (`role="grid"` の要素の `aria-labelledby`)。無ければ undefined。 */
317
+ labelledBy: string | undefined
318
+ /** The grid's own name (`aria-label`) when no `labelledBy` names it, else undefined. / `labelledBy` が無いときのグリッド自身の名前 (`aria-label`)。あれば undefined。 */
319
+ gridLabel: string | undefined
320
+ /** Whole-grid read-only (`aria-readonly` of the `role="grid"` element). / グリッド全体の読み取り専用 (`role="grid"` の要素の `aria-readonly`)。 */
321
+ readOnly: boolean
290
322
  renderCell?: (ctx: RenderCellContext) => ReactNode
291
323
  /** 2D scroll notification from the grid handle (the truth — §7-4). / グリッドハンドル発の 2 軸スクロール通知 (§7-4 の真実側)。 */
292
324
  onScroll: (position: { x: number; y: number }) => void
@@ -382,6 +414,10 @@ const GridBodyInner = ({
382
414
  frozenRows,
383
415
  frozenTrailingColumns,
384
416
  frozenTrailingRows,
417
+ headerRows,
418
+ labelledBy,
419
+ gridLabel,
420
+ readOnly,
385
421
  renderCell,
386
422
  onScroll,
387
423
  onRangeChange,
@@ -424,14 +460,14 @@ const GridBodyInner = ({
424
460
  const getRowHeight = useCallback((row: number) => rowAxis.sizeOf(row), [rowAxis])
425
461
  const getColWidth = useCallback((col: number) => colAxis.sizeOf(col), [colAxis])
426
462
  const getCell = useCallback((row: number, col: number) => adapter.getCellText(row, col), [adapter])
427
- // ARIA コンテナ属性 (identity は行列数にのみ追随)。型は行側と同型の狭い contentProps
428
- const contentProps = useMemo(() => ({ role: "grid", "aria-rowcount": adapter.rowCount, "aria-colcount": adapter.colCount, "aria-multiselectable": true }) as const, [adapter.rowCount, adapter.colCount])
429
463
  // 挙動オプション: 行木は identity 変化で再構築 (軸再構築追随)、列幅木も対称 + defaultColWidth
430
464
  // (§7-8 — サンプリング省略の Fenwick baseValue)。pointerDragInputs はペン / タッチのみ
431
465
  // (マウスドラッグは選択エンジンの所有)
432
466
  const behaviorOptions = useMemo(() => ({ enableKeyboardNavigation: false, pointerDragInputs: ["pen", "touch"] as const, resetOnGetItemHeightChange: true, resetOnGetColWidthChange: true, defaultColWidth: defaultColumnWidth }), [defaultColumnWidth])
433
467
  // タップサークル素通し (v0.10 §2.1): 未指定は undefined キー同乗 = エンジン宣言の不在信号
434
- const scrollBarOptions = useMemo(() => ({ width: SPREADSHEET_SCROLLBAR_SIZE_PX, tapScrollCircleOptions }), [tapScrollCircleOptions])
468
+ // 矢印の Tab の止まり先は false: キーボードのスクロールはグリッド自身 (シンク) が持つので、バーはネイティブのスクロールバーと同じ
469
+ // ポインタ専用の部品になり (aria-hidden)、role="grid" の中に許されない子 (scrollbar・button) を作らない
470
+ const scrollBarOptions = useMemo(() => ({ width: SPREADSHEET_SCROLLBAR_SIZE_PX, tapScrollCircleOptions, enableArrowButtonTabStops: false }), [tapScrollCircleOptions])
435
471
  // 表示値書式の掛け金 (getCellProps が書き、同じセルの children が直後に消費 — 番兵は届かない座標)
436
472
  const displayLatchRef = useRef<DisplayLatch>({ row: -1, col: -1, value: undefined })
437
473
  /**
@@ -446,9 +482,10 @@ const GridBodyInner = ({
446
482
  */
447
483
  const getCellProps = useCallback(
448
484
  (row: number, col: number) => {
485
+ const isHeader = row < headerRows
449
486
  const base = {
450
487
  // aria-rowindex は行ラッパーと重複出力 (合法な上位集合) — セル単独が AT に読まれた際の行文脈補助 (§10-1)
451
- role: "gridcell",
488
+ role: isHeader ? "columnheader" : "gridcell",
452
489
  "aria-rowindex": row + 1,
453
490
  "aria-colindex": col + 1,
454
491
  "data-aqsp-cell": "",
@@ -460,6 +497,7 @@ const GridBodyInner = ({
460
497
  // 単一居住 — layer-2 census)。未宣言 = O(1) false = キー不在 = v0.9 と DOM バイト同一、
461
498
  // 宣言ホストは塗りセル毎 1 述語読み (ADR-31 の登記コスト)
462
499
  ...(isReadOnlyCell(adapter, row, col) ? { "aria-readonly": "true", "data-aqsp-readonly": "" } : {}),
500
+ ...(isHeader ? { "data-aqsp-header-row": "" } : {}),
463
501
  }
464
502
  // v0.12 ホスト属性の継ぎ目: resolveCellAttributes が予約キー (data-aqsp* / data-row /
465
503
  // data-col) と非 data-* を fail-fast 済みなのでパッケージ属性と衝突しない。未宣言は
@@ -477,7 +515,7 @@ const GridBodyInner = ({
477
515
  }
478
516
  return (rich.style === null ? { ...stamped, ...rich.attrs } : { ...stamped, ...rich.attrs, style: rich.style }) as HTMLAttributes<HTMLDivElement>
479
517
  },
480
- [adapter, rowAxis, colAxis, cf],
518
+ [adapter, rowAxis, colAxis, cf, headerRows],
481
519
  )
482
520
  /** Row attribute surface — carries the engine event gate (data-aqsp-cells §6.4.3-1). / 行属性面 — エンジンのイベントゲート (data-aqsp-cells) を運ぶ。 */
483
521
  const getRowProps = useCallback(
@@ -518,11 +556,18 @@ const GridBodyInner = ({
518
556
  )
519
557
 
520
558
  return (
559
+ // biome-ignore lint/a11y/useSemanticElements: <table> を描けない — 行は VirtualGrid が 3 つのペインに仮想化して描き、表の構造要素の入れ子を作れないため、ARIA の grid で全ての行を包む
521
560
  <div
522
561
  ref={bodyRef}
523
562
  className="aqsp-body"
524
563
  data-aqsp-body=""
525
564
  data-testid={testId ? `${testId}-body` : undefined}
565
+ role="grid"
566
+ aria-rowcount={adapter.rowCount}
567
+ aria-colcount={adapter.colCount}
568
+ aria-label={gridLabel}
569
+ aria-labelledby={labelledBy}
570
+ aria-readonly={readOnly ? "true" : undefined}
526
571
  onPointerDownCapture={(event) => {
527
572
  // バー帯の統合ゲート (§6.8-3 — v0.4 でスクロールバー 2 本ともグリッド内在化):
528
573
  // セル領域 (data-aqsp-cells) 由来は選択エンジンの所有 — 素通し。それ以外 (縦バー /
@@ -574,7 +619,7 @@ const GridBodyInner = ({
574
619
  getCell={getCell}
575
620
  getCellProps={getCellProps}
576
621
  getRowProps={getRowProps}
577
- contentProps={contentProps}
622
+ contentProps={BODY_ROWGROUP_PROPS}
578
623
  behaviorOptions={behaviorOptions}
579
624
  scrollBarOptions={scrollBarOptions}
580
625
  overscanRows={overscanRows}
@@ -55,6 +55,40 @@ export const MAX_TRACK_SIZE_PX = 10_000
55
55
  */
56
56
  export const ROW_FANOUT_DIVISOR = 10
57
57
 
58
+ /**
59
+ * The ratio of one keyboard resize step: the zoom ladder's 2^(1/4), so four presses double a track and four halve it,
60
+ * at every size.
61
+ * キーボードの寸法の 1 段の比。拡大率の段と同じ 2^(1/4) で、どの寸法でも 4 回で 2 倍、4 回で半分になる。
62
+ */
63
+ const KEYBOARD_RESIZE_RATIO = 2 ** (1 / 4)
64
+
65
+ /**
66
+ * The size one keyboard step gives a track (increaseColumnWidth / decreaseColumnWidth / increaseRowHeight /
67
+ * decreaseRowHeight): `size × 2^(1/4)` up and `size / 2^(1/4)` down, rounded to whole px, moving at least 1 px, never
68
+ * below 1 px (a step never hides a track) and never above MAX_TRACK_SIZE_PX. A track already at a bound keeps its size:
69
+ * a hidden track (0 px) stays hidden in both directions (only unhide restores it), and growing never shrinks a track a
70
+ * host made wider than MAX_TRACK_SIZE_PX.
71
+ * キーボードの 1 段がトラックに与える寸法。上は `size × 2^(1/4)`、下は `size / 2^(1/4)` を整数 px に丸め、少なくとも 1 px 動き、
72
+ * 1 px を下回らず (1 段でトラックを隠さない)、MAX_TRACK_SIZE_PX を超えない。端にあるトラックは寸法を保つ: 隠れたトラック (0 px)
73
+ * はどちらの向きでも隠れたまま (戻すのは再表示だけ)、ホストが MAX_TRACK_SIZE_PX より広くしたトラックを大きくする向きで縮めない。
74
+ *
75
+ * @param size - The track's current size in px. トラックの今の寸法 (px)。
76
+ * @param direction - 1 to grow, -1 to shrink. 大きくするなら 1、小さくするなら -1。
77
+ * @returns The new size in px. 新しい寸法 (px)。
78
+ */
79
+ export const keyboardStepSize = (size: number, direction: 1 | -1): number => {
80
+ if (size <= 0) {
81
+ return size
82
+ }
83
+ if (direction > 0) {
84
+ if (size >= MAX_TRACK_SIZE_PX) {
85
+ return size
86
+ }
87
+ return Math.min(MAX_TRACK_SIZE_PX, Math.max(size + 1, Math.round(size * KEYBOARD_RESIZE_RATIO)))
88
+ }
89
+ return Math.max(1, Math.min(size - 1, Math.round(size / KEYBOARD_RESIZE_RATIO)))
90
+ }
91
+
58
92
  /** One track-size entry of a resize batch (size in logical px, 0 = hidden). / リサイズバッチの 1 entry (論理 px、0 = 非表示)。 */
59
93
  export type ResizeEntry = { index: number; size: number }
60
94