@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,1694 @@
1
+ /**
2
+ * @fileoverview Excel / Google Sheets interoperable HTML table WRITER + READER for the clipboard
3
+ * `text/html` flavour (v0.8 §4.1.7, ADR-14; replaces htmlTable.ts). Pure, React-free, and the
4
+ * reader is TOTAL fail-soft: any string yields a `ClipPayload` or `null`, never a throw.
5
+ *
6
+ * Writer contract (`serializeHtmlTable`, each clause mutation-gated in htmlInterop.spec.ts):
7
+ * - `<table data-aqsp-clip='…'>` carries the lossless JSON as the FIRST attribute, single-quoted
8
+ * with exactly `& < > '` entity-escaped (the closed set `decodeClipAttribute` undoes), so both
9
+ * channels of ADR-14 detect `aqsp` on the 16 KiB prefix and `readClipHeader` anchors without
10
+ * decoding the body.
11
+ * - The envelope is the Sheets-origin form (`<google-sheets-html-origin>` + `data-sheets-root="1"`)
12
+ * so Google Sheets pastes the typed attributes: `data-sheets-value` (a string for `=` literals and
13
+ * text-formatted cells, a number for numeric-formatted numeric text, a date serial for
14
+ * date-formatted ISO text), `data-sheets-formula` (the cell-relative R1C1 the payload already
15
+ * holds), `data-sheets-numberformat`, `data-sheets-hyperlink`, `data-sheets-note`. Excel reads
16
+ * the same cells through `x:str` / `x:num` / `x:fmla` (A1 resolved with the clip at A1;
17
+ * off-clip references are omitted rather than shipped as `#REF!`), inline `mso-number-format`
18
+ * (Excel's CSS-string escaping, `encodeMsoNumberFormat`), borders in pt with `windowtext` for
19
+ * the default colour, `<col width>` / `<tr height>`, and every visible style in the shared CSS
20
+ * vocabulary (font-weight / font-style / text-decoration / color / background-color /
21
+ * text-align / vertical-align / font-family / font-size pt / border-*). Plain cells stay
22
+ * `<td style='white-space:pre'>text</td>` — attributes appear only where an aspect exists.
23
+ * - The visible text is the DISPLAY string (`formatCellValue`); the typed attributes carry the raw
24
+ * value. In-cell newlines are `<br style="mso-data-placement:same-cell">` (plus the `br` rule in
25
+ * the `<style>` head), tabs `&#9;`, `& < >` escaped, `white-space:pre` (or `pre-wrap` for wrap
26
+ * cells) on every td so HTML whitespace collapsing cannot flatten the text.
27
+ *
28
+ * Reader contract (`parseHtmlTable(html, kind, options, maxCells)` ⇒ `HtmlTableResult`):
29
+ * - Structure comes from `DOMParser` (built by the caller's ADR-12 gate only under the cap); the
30
+ * FIRST table in document order is read with the HTML table model: `thead` rows, then `tr` /
31
+ * `tbody` rows in tree order, then `tfoot`; `rowspan` / `colspan` expand to the anchor plus
32
+ * blank unformatted cells; ragged rows are right-padded. No table, or a table without any cell
33
+ * column ⇒ `{ outcome: "none" }`.
34
+ * - The reader is the SECOND wall of ADR-12: `maxCells` (a positive integer — anything else is a
35
+ * RangeError before the DOMParser is built) bounds what it materialises. Before every
36
+ * allocation it checks the lower bound `(rows so far) × max(cols so far, width)` of the final
37
+ * `rows × cols`; once that exceeds `maxCells` it stops with `{ outcome: "limit", cells }`
38
+ * (`cells` = that bound), so at most `maxCells` cell objects ever exist (plus the rectangular
39
+ * copy). This fences what `estimateHtmlTableCells` cannot see — entity references inside span
40
+ * attribute values, tokenizer corner cases — and the `<col span>` list is read only up to the
41
+ * cell column count. Rows are pulled lazily through the sibling links (`firstElementChild` /
42
+ * `nextElementSibling` — O(1) a step in every DOM, whereas a live `HTMLCollection` index costs
43
+ * O(index) in jsdom and made a 65,534-row walk quadratic), so the bound also ends the row walk:
44
+ * the rowspan bomb below visits 101 rows, never 65,534.
45
+ * - CSS is read from RAW text, never the CSSOM (which drops `mso-*`): `<style>` class rules
46
+ * (`.xl65`, `td.xl65`, `.font6`) and inline `style` attributes through `tokenizeInlineStyle`
47
+ * (quote- and paren-aware, so `\;` inside an `mso-number-format` string does not split).
48
+ * Element rules (`td { … }`, `tr`, `col`, `br`) are an application's defaults and never become
49
+ * cell formats; the `table` rule supplies `mso-displayed-*-separator` (else
50
+ * `DEFAULT_HTML_SEPARATORS`). Precedence: `bgcolor` attribute < class rules in class order <
51
+ * inline style < whole-cell run elements (a `b` / `i` / `u` / `s` / `a` / `font` / `span`
52
+ * whose text is the entire cell text; partial runs never format the cell; the whole-cell test
53
+ * is the O(1)-per-run window check "no non-whitespace outside the run" — equivalent to
54
+ * whitespace-normalised text equality — so a hostile cell nesting thousands of run tags around
55
+ * a long string never costs O(depth × text length)).
56
+ * - Vocabulary → `CellFormat`: bold (`bold` / `bolder` / ≥ 600), italic / oblique, underline and
57
+ * line-through tokens of `text-decoration(-line)` (tag `u` / `s` add), `color` and
58
+ * `background(-color)` through `normalizeColor` (transparent / unknown ⇒ skipped), `text-align`
59
+ * left / center / right, `vertical-align` top / middle / bottom, the first non-generic
60
+ * `font-family`, `font-size` in pt or px (× 0.75), `white-space` normal / pre-wrap / pre-line ⇒
61
+ * wrap, borders from `border` and `border-<side>` (unit-aware width: px 1 / 2 / ≥ 3 and pt
62
+ * < 0.75 / < 1.25 / ≥ 1.25 ⇒ thin / medium / thick, the keywords by name, a missing width =
63
+ * CSS `medium`; dashed / dotted / double / hairline ⇒ their styles; `none` / `hidden` /
64
+ * transparent ⇒ no side; `windowtext` / `currentcolor` ⇒ default colour; an unknown colour ⇒
65
+ * side skipped). The `sheets` kind ignores Sheets' own grid-line
66
+ * declaration (`1px solid #cccccc`, inlined by a sanitising engine). `align` / `valign`
67
+ * attributes are rendering hints (Excel writes `align=right` for numbers) and are ignored.
68
+ * - Values: `sheets` reads `data-sheets-value` (types 2 string / 3 number / 4 boolean / 5 error,
69
+ * object or the 2015 positional array; a number under a date format is a serial converted to
70
+ * ISO by pure calendar math — only inside the grammar's serial domain 0000-01-01 .. 9999-12-31,
71
+ * the visible text otherwise), `data-sheets-formula` verbatim when `=`-leading,
72
+ * `data-sheets-numberformat`, `data-sheets-hyperlink` (the attribute is the carrier when
73
+ * present, the first `<a href>` otherwise), `data-sheets-note`; `data-sheets-userformat` /
74
+ * `-textstyleruns` are not read (the CSS carries the cell format; the package has no runs).
75
+ * `excel` and `html` are the same reader: `x:str` / `x:bool` / `x:err` / `x:num` (a serial under
76
+ * a date format becomes ISO inside the same serial domain, the visible text otherwise) /
77
+ * `x:fmla` (A1 → cell-relative R1C1 via `toR1C1`), inline or
78
+ * class `mso-number-format` (unquoted, `decodeMsoNumberFormat`, then `fromExcelPattern`), the
79
+ * first `<a href>`. Without a typed carrier the visible text is the value; under a NUMERIC value
80
+ * format it is canonicalised by `numberDisplayToCanonical` with the table's separators
81
+ * (`2,728,548` ⇒ `2728548`, `20.5%` ⇒ `0.205`); under General it is kept verbatim. Hyperlinks
82
+ * pass `normalizeHyperlink` or are dropped.
83
+ * - Text: `<br>` is a newline; text nodes follow the cell's `white-space` — collapse (whitespace
84
+ * runs ⇒ one space, U+00A0 ⇒ space, each line trimmed, leading / trailing empty lines dropped,
85
+ * block elements and nested rows are line boundaries), `pre` / `pre-wrap` verbatim, `pre-line`
86
+ * (spaces collapsed, newlines kept); `mso-spacerun:yes` spans, `<rt>` / `<rp>`, scripts,
87
+ * styles, images, form controls and comments contribute nothing. Formats are interned through
88
+ * `createFormatInterner` (canonical, deduplicated); comments are never produced; column widths
89
+ * (`<col width>` px, `width:Npt` × 4 / 3, `display:none` ⇒ 0, `span`) and row heights
90
+ * (`height:Npt|px` then the `height` attribute) are emitted only when every column / row is
91
+ * covered; the payload gets a fresh `clipId`, empty `validations` / `conditionalFormats` and
92
+ * `wholeRows` / `wholeCols` false.
93
+ *
94
+ * Excel / Google Sheets 相互運用の HTML 表 writer + reader (v0.8 §4.1.7、ADR-14。htmlTable.ts を
95
+ * 置換)。純関数・React 非依存、reader は全域 fail-soft (任意の文字列に対し `ClipPayload` か `null`、
96
+ * throw しない)。writer: `<table data-aqsp-clip='…'>` を先頭属性に単一引用で置き (`& < > '` のみ
97
+ * 実体化)、Sheets 起源形 (`<google-sheets-html-origin>` + `data-sheets-root="1"`) で
98
+ * `data-sheets-value` / `-formula` / `-numberformat` / `-hyperlink` / `-note` を、Excel 向けに
99
+ * `x:str` / `x:num` / `x:fmla` (クリップを A1 に置いた A1 形、クリップ外参照は省略)・インライン
100
+ * `mso-number-format` (Excel の CSS 文字列エスケープ)・pt 罫線 (既定色は `windowtext`)・
101
+ * `<col width>` / `<tr height>`・共通 CSS 語彙を出力。素のセルは `<td style='white-space:pre'>` の
102
+ * まま。可視文字列は表示文字列 (`formatCellValue`)、型付き属性が素の値を運ぶ。改行は
103
+ * `<br style="mso-data-placement:same-cell">`、タブは `&#9;`。reader: 構造は `DOMParser` (呼び手の
104
+ * ADR-12 ゲート下でのみ構築) の HTML 表モデル (thead → tr / tbody → tfoot、rowspan / colspan は
105
+ * anchor + 空白セル、ラグ行は右詰め)。reader 自身が ADR-12 の第 2 の壁: `maxCells` (正整数、他は
106
+ * DOMParser 構築前に RangeError) が実体化を上限で止め、rows × cols の下界が超えた時点で
107
+ * `{ outcome: "limit", cells }` を返す (見積もりが見ない属性値の実体参照等を塞ぐ)。行は兄弟リンク
108
+ * (`firstElementChild` / `nextElementSibling` — live な `HTMLCollection` の添字は jsdom で O(添字)) で遅延列挙し、
109
+ * 下界超えは行の走査も止める (65,534 行の爆弾でも訪れる行は 101)。CSS は生文字列から (`<style>` のクラス規則 + インライン、要素規則は既定として無視、
110
+ * `table` 規則の区切り宣言のみ使用)、優先順は bgcolor < クラス < インライン < 全体 run 要素。
111
+ * 語彙 → `CellFormat` の写像 (罫線幅は単位別、キーワードは同名、幅なしは CSS `medium`)、Sheets の罫線
112
+ * 既定 (`1px solid #cccccc`) は無視、`align` / `valign`
113
+ * 属性は描画ヒントとして無視。値: sheets は `data-sheets-*`、excel / html は `x:*` +
114
+ * `mso-number-format`、型付き運搬体が無ければ可視文字列 (数値書式下は区切り宣言で正準化、General
115
+ * は原文)。文字列は `white-space` に従う (collapse / pre / pre-line)、`mso-spacerun` / `<rt>` /
116
+ * script / img 等は寄与しない。書式は `createFormatInterner` で正準 intern、寸法は全列 / 全行が
117
+ * 揃うときだけ出力、`clipId` は新規採番。
118
+ */
119
+
120
+ import { MAX_TRACK_SIZE_PX } from "../header/resizeGates.ts"
121
+ import { normalizeHyperlink } from "../model/cellAnnotations.ts"
122
+ import { type CellAlign, type CellBorder, type CellBorderStyle, type CellBorders, type CellFormat, type CellValueFormat, type CellVerticalAlign, formatCellValue, parseNumber } from "../model/cellFormat.ts"
123
+ import { normalizeColor } from "../model/cellPaint.ts"
124
+ import { fromR1C1, toR1C1 } from "../model/formulaRefs.ts"
125
+ import { CLIP_ATTRIBUTE, type ClipCell, type ClipPayload, createFormatInterner, HTML_COLSPAN_MAX, HTML_ROWSPAN_MAX, newClipId } from "./clipPayload.ts"
126
+ import { decodeMsoNumberFormat, fromExcelPattern, fromSheetsNumberFormat, type NumberFormatDecodeOptions, type NumberSeparators, numberDisplayToCanonical, toExcelPattern } from "./numberFormatCodec.ts"
127
+
128
+ // ---------------------------------------------------------------------------------------------
129
+ // Vocabulary
130
+ // 語彙
131
+ // ---------------------------------------------------------------------------------------------
132
+
133
+ /** The table kinds the reader accepts — the marker-detected `ClipSource` values that are HTML tables. / reader が受ける表の種別 — HTML 表であるマーカー検出済み `ClipSource` 値。 */
134
+ export type HtmlTableKind = "sheets" | "excel" | "html"
135
+
136
+ /** Google Sheets' wrapper element name (the writer emits it, detection matches it). / Google Sheets のラッパー要素名 (writer が出力、検出が照合)。 */
137
+ export const SHEETS_ORIGIN_TAG = "google-sheets-html-origin"
138
+
139
+ /**
140
+ * The displayed-number separators assumed when a document declares none (Excel always writes
141
+ * `mso-displayed-*-separator` on its `table` rule; Sheets and generic HTML never do). A stated
142
+ * default of the `text/html` channel, not a substitute for a declared value.
143
+ * 文書が区切りを宣言しないときに仮定する表示数値の区切り (Excel は常に `table` 規則へ書く。Sheets /
144
+ * 汎用 HTML は書かない)。`text/html` 経路の宣言済み既定であり、宣言値の代替ではない。
145
+ */
146
+ export const DEFAULT_HTML_SEPARATORS: NumberSeparators = Object.freeze({ group: ",", decimal: "." })
147
+
148
+ /** Border style → the pt width + line style Excel and Sheets read back. / 罫線種 → Excel / Sheets が読み戻す pt 幅 + 線種。 */
149
+ const BORDER_CSS: Readonly<Record<CellBorderStyle, string>> = Object.freeze({
150
+ thin: ".5pt solid",
151
+ medium: "1.0pt solid",
152
+ thick: "1.5pt solid",
153
+ dashed: ".5pt dashed",
154
+ dotted: ".5pt dotted",
155
+ double: "1.5pt double",
156
+ hair: ".5pt hairline",
157
+ })
158
+
159
+ /** Border sides in the emission order. / 罫線の辺 (出力順)。 */
160
+ const BORDER_SIDES: readonly (keyof CellBorders)[] = ["top", "right", "bottom", "left"]
161
+
162
+ /** CSS border-style keywords the reader understands. / reader が解する CSS の border-style キーワード。 */
163
+ const BORDER_STYLE_KEYWORDS: ReadonlySet<string> = new Set(["solid", "dashed", "dotted", "double", "hairline", "none", "hidden", "groove", "ridge", "inset", "outset"])
164
+
165
+ /** Colour words meaning "the automatic / default border colour". / 「自動 (既定) の罫線色」を意味する色語。 */
166
+ const DEFAULT_COLOR_WORDS: ReadonlySet<string> = new Set(["windowtext", "currentcolor", "inherit", "initial", "unset"])
167
+
168
+ /** Generic family keywords that never name a font. / フォント名にならない総称族キーワード。 */
169
+ const GENERIC_FAMILIES: ReadonlySet<string> = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "math", "emoji", "fangsong", "inherit", "initial", "unset"])
170
+
171
+ /** Sheets error sub-codes (research map §3.1) → Excel error text. / Sheets のエラー副コード → Excel のエラー文字列。 */
172
+ const SHEETS_ERROR_TEXT: readonly string[] = ["", "#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A", "#ERROR!"]
173
+
174
+ /** Sheets `NumberFormatType` codes the writer stamps per package value type. / writer がパッケージの値書式型ごとに刻む Sheets の `NumberFormatType` コード。 */
175
+ const SHEETS_NUMBER_FORMAT_CODE: Readonly<Record<"number" | "percent" | "currency" | "scientific", number>> = Object.freeze({ number: 2, percent: 3, currency: 4, scientific: 8 })
176
+
177
+ /** Elements whose subtree never contributes cell text. / セル文字列に一切寄与しない要素。 */
178
+ const SKIP_TAGS: ReadonlySet<string> = new Set(["script", "style", "template", "noscript", "rt", "rp", "img", "svg", "video", "audio", "picture", "canvas", "iframe", "object", "textarea", "select", "button", "input"])
179
+
180
+ /** Elements that end a line in collapse mode (block boundaries, nested table rows). / collapse モードで行を区切る要素 (ブロック境界・入れ子表の行)。 */
181
+ const BLOCK_TAGS: ReadonlySet<string> = new Set(["div", "p", "li", "ul", "ol", "dl", "dt", "dd", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "section", "article", "header", "footer", "table", "tr", "hr"])
182
+
183
+ /** Run elements that may format the WHOLE cell when their text is the entire cell text. / 文字列がセル全体と一致するとき cell を書式付けし得る run 要素。 */
184
+ const RUN_TAGS: ReadonlySet<string> = new Set(["b", "strong", "i", "em", "u", "s", "strike", "del", "a", "font", "span"])
185
+
186
+ /** `Node.TEXT_NODE`. / テキストノード。 */
187
+ const TEXT_NODE = 3
188
+
189
+ /** `Node.ELEMENT_NODE`. / 要素ノード。 */
190
+ const ELEMENT_NODE = 1
191
+
192
+ /** Sheets / Excel serial epoch (1899-12-30) as days since 1970-01-01. / Sheets / Excel シリアルの元期 (1899-12-30) を 1970-01-01 起点の日数で。 */
193
+ const SERIAL_EPOCH_DAYS = -25569
194
+
195
+ /** Seconds per day. / 1 日の秒数。 */
196
+ const SECONDS_PER_DAY = 86400
197
+
198
+ /** Lowest serial of the package date grammar: 0000-01-01 (grammar years are 0000-9999). / パッケージ日付文法の最小シリアル (0000-01-01)。 */
199
+ const SERIAL_GRAMMAR_MIN = -693959
200
+
201
+ /** Highest serial of the package date grammar: 9999-12-31 (Excel's own ceiling). / パッケージ日付文法の最大シリアル (9999-12-31 — Excel 自身の上限)。 */
202
+ const SERIAL_GRAMMAR_MAX = 2958465
203
+
204
+ /** The non-breaking space (Excel / Chromium layout space, read as a plain space). / ノーブレークスペース (Excel / Chromium のレイアウト空白、素の空白として読む)。 */
205
+ const NBSP = "\u00a0"
206
+
207
+ // ---------------------------------------------------------------------------------------------
208
+ // Raw CSS tokenizers (CSSOM never consulted)
209
+ // 生 CSS トークナイザ (CSSOM は参照しない)
210
+ // ---------------------------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Splits a `style` attribute value (or a rule body) into `name → value` declarations: `;` splits
214
+ * only outside quotes and parentheses (so `mso-number-format:"…\;…"` and `rgb(0, 0, 0)` stay
215
+ * whole), a backslash inside quotes escapes the next character, names are trimmed + lower-cased,
216
+ * values trimmed with their quotes kept, empty names / values dropped, a repeated name keeps the
217
+ * LAST value (CSS order). Total: any string yields a map.
218
+ * `style` 属性値 (または規則本体) を `name → value` 宣言へ分割: `;` は引用 / 括弧の外だけで区切り
219
+ * (`mso-number-format:"…\;…"` や `rgb(0, 0, 0)` は保たれる)、引用内の逆斜線は次の文字をエスケープ、
220
+ * 名前は trim + 小文字化、値は引用符を保って trim、空の名前 / 値は落とし、重複名は後勝ち (CSS 順)。
221
+ */
222
+ export const tokenizeInlineStyle = (style: string): ReadonlyMap<string, string> => {
223
+ const out = new Map<string, string>()
224
+ let start = 0
225
+ let quote: string | null = null
226
+ let depth = 0
227
+ // 1 宣言を name / value へ分け、空のものは捨てる
228
+ const flush = (end: number): void => {
229
+ const declaration = style.slice(start, end)
230
+ const colon = declaration.indexOf(":")
231
+ if (colon > 0) {
232
+ const name = declaration.slice(0, colon).trim().toLowerCase()
233
+ const value = declaration.slice(colon + 1).trim()
234
+ if (name !== "" && value !== "") {
235
+ out.set(name, value)
236
+ }
237
+ }
238
+ start = end + 1
239
+ }
240
+ for (let i = 0; i < style.length; i += 1) {
241
+ const c = style[i]
242
+ if (quote !== null) {
243
+ // 引用内: エスケープは次の 1 文字を読み飛ばし、同じ引用符で閉じる
244
+ if (c === "\\") {
245
+ i += 1
246
+ } else if (c === quote) {
247
+ quote = null
248
+ }
249
+ } else if (c === '"' || c === "'") {
250
+ quote = c
251
+ } else if (c === "(") {
252
+ depth += 1
253
+ } else if (c === ")") {
254
+ depth = depth > 0 ? depth - 1 : 0
255
+ } else if (c === ";" && depth === 0) {
256
+ flush(i)
257
+ }
258
+ }
259
+ flush(style.length)
260
+ return out
261
+ }
262
+
263
+ /** Removes ONE layer of matching CSS string quotes (`"…"` / `'…'`). / 対応する CSS 文字列引用符を 1 層だけ外す。 */
264
+ const stripCssQuotes = (value: string): string => {
265
+ const first = value[0]
266
+ return value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first) ? value.slice(1, -1) : value
267
+ }
268
+
269
+ /** Whitespace-splits a CSS value without breaking `rgb( … )` style functions. / `rgb( … )` を壊さずに CSS 値を空白分割。 */
270
+ const splitCssTokens = (value: string): string[] => {
271
+ const tokens: string[] = []
272
+ let current = ""
273
+ let depth = 0
274
+ for (const c of value) {
275
+ if (c === "(") {
276
+ depth += 1
277
+ } else if (c === ")") {
278
+ depth = depth > 0 ? depth - 1 : 0
279
+ }
280
+ // 括弧の外の空白だけがトークン境界
281
+ if (depth === 0 && /\s/.test(c)) {
282
+ if (current !== "") {
283
+ tokens.push(current)
284
+ current = ""
285
+ }
286
+ } else {
287
+ current += c
288
+ }
289
+ }
290
+ if (current !== "") {
291
+ tokens.push(current)
292
+ }
293
+ return tokens
294
+ }
295
+
296
+ /** Class rules of a document plus the `table` rule's displayed-number separators. / 文書のクラス規則と `table` 規則の表示数値区切り。 */
297
+ type StyleRules = { readonly classes: ReadonlyMap<string, ReadonlyMap<string, string>>; readonly separators: NumberSeparators }
298
+
299
+ /** Class selector: `.name` or `tag.name` (Excel's `td.xl65`). / クラスセレクタ: `.name` か `tag.name` (Excel の `td.xl65`)。 */
300
+ const CLASS_SELECTOR = /^(?:[a-z][a-z0-9]*)?\.([A-Za-z_-][\w-]*)$/i
301
+
302
+ /**
303
+ * Reads every `<style>` element's RAW text: HTML comment markers and CSS comments stripped, each
304
+ * `selector { body }` tokenised through tokenizeInlineStyle, class selectors collected (a class
305
+ * declared twice merges, later wins), at-rules skipped, and the `table` rule's
306
+ * `mso-displayed-decimal-separator` / `mso-displayed-thousand-separator` decoded — used only when
307
+ * both are declared, the decimal is non-empty and the two differ (else the stated default).
308
+ * 全 `<style>` の生文字列を読む: HTML コメント印と CSS コメントを除き、各 `selector { body }` を
309
+ * tokenizeInlineStyle で分解、クラスセレクタを集め (重複宣言は後勝ちで併合)、at 規則は飛ばし、
310
+ * `table` 規則の区切り宣言を復号 — 両方宣言され小数区切りが非空で両者が異なるときだけ採用 (他は既定)。
311
+ */
312
+ const readStyleRules = (doc: Document): StyleRules => {
313
+ const classes = new Map<string, Map<string, string>>()
314
+ let decimal: string | null = null
315
+ let group: string | null = null
316
+ for (const styleElement of doc.querySelectorAll("style")) {
317
+ // <style> は raw text 要素 — innerHTML はその文字列そのもの (エスケープなし)
318
+ const css = styleElement.innerHTML
319
+ .replaceAll("<!--", "")
320
+ .replaceAll("-->", "")
321
+ .replace(/\/\*[\s\S]*?\*\//g, "")
322
+ let i = 0
323
+ while (i < css.length) {
324
+ const open = css.indexOf("{", i)
325
+ if (open === -1) {
326
+ break
327
+ }
328
+ const close = css.indexOf("}", open + 1)
329
+ if (close === -1) {
330
+ break
331
+ }
332
+ const selectors = css.slice(i, open)
333
+ const body = css.slice(open + 1, close)
334
+ i = close + 1
335
+ // at 規則 (@page 等) はセルに関係しない
336
+ if (selectors.trim().startsWith("@")) {
337
+ continue
338
+ }
339
+ const declarations = tokenizeInlineStyle(body)
340
+ for (const selector of selectors.split(",")) {
341
+ const trimmed = selector.trim()
342
+ const classMatch = CLASS_SELECTOR.exec(trimmed)
343
+ if (classMatch !== null) {
344
+ const existing = classes.get(classMatch[1])
345
+ if (existing === undefined) {
346
+ classes.set(classMatch[1], new Map(declarations))
347
+ } else {
348
+ for (const [name, value] of declarations) {
349
+ existing.set(name, value)
350
+ }
351
+ }
352
+ } else if (trimmed.toLowerCase() === "table") {
353
+ // 区切り宣言は Excel の CSS 文字列エスケープを解く
354
+ const decimalRaw = declarations.get("mso-displayed-decimal-separator")
355
+ const groupRaw = declarations.get("mso-displayed-thousand-separator")
356
+ if (decimalRaw !== undefined) {
357
+ decimal = decodeMsoNumberFormat(stripCssQuotes(decimalRaw))
358
+ }
359
+ if (groupRaw !== undefined) {
360
+ group = decodeMsoNumberFormat(stripCssQuotes(groupRaw))
361
+ }
362
+ }
363
+ }
364
+ }
365
+ }
366
+ // 退化した宣言 (小数区切りが空 / 両者同一) は numberDisplayToCanonical が拒む — 既定へ
367
+ const separators = decimal !== null && group !== null && decimal !== "" && group !== decimal ? { group, decimal } : DEFAULT_HTML_SEPARATORS
368
+ return { classes, separators }
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------------------------
372
+ // mso-number-format encoding and the serial calendar
373
+ // mso-number-format のエンコードとシリアル暦
374
+ // ---------------------------------------------------------------------------------------------
375
+
376
+ /** Pattern characters Excel leaves unescaped inside the CSS string besides alphanumerics. / 英数字以外で Excel が CSS 文字列内にそのまま書くパターン文字。 */
377
+ const MSO_BARE_CHARS = "%_-*? "
378
+
379
+ /**
380
+ * Applies Excel's CSS-string escaping to a number-format pattern (research map §6 rule 2): `"` →
381
+ * `\0022` (four hex digits, no terminator), `\` → `\\`, letters / digits / `% _ - * ?` / space /
382
+ * non-ASCII kept, every other character backslash-escaped. The exact inverse of
383
+ * `decodeMsoNumberFormat` (`decode(encode(p)) === p` for every pattern).
384
+ * 数値書式パターンへ Excel の CSS 文字列エスケープを施す: `"` → `\0022`、`\` → `\\`、英数字 /
385
+ * `% _ - * ?` / 空白 / 非 ASCII はそのまま、他は逆斜線エスケープ。`decodeMsoNumberFormat` の厳密な逆。
386
+ */
387
+ export const encodeMsoNumberFormat = (pattern: string): string => {
388
+ let out = ""
389
+ for (const c of pattern) {
390
+ if (c === '"') {
391
+ out += "\\0022"
392
+ } else if (c === "\\") {
393
+ out += "\\\\"
394
+ } else if (/[A-Za-z0-9]/.test(c) || MSO_BARE_CHARS.includes(c) || c.charCodeAt(0) >= 0x80) {
395
+ out += c
396
+ } else {
397
+ out += `\\${c}`
398
+ }
399
+ }
400
+ return out
401
+ }
402
+
403
+ /** Days since 1970-01-01 of a proleptic-Gregorian civil date (Hinnant's algorithm). / 先発グレゴリオ暦の日付 → 1970-01-01 起点の日数 (Hinnant の算法)。 */
404
+ const daysFromCivil = (year: number, month: number, day: number): number => {
405
+ const y = month <= 2 ? year - 1 : year
406
+ const era = Math.floor(y / 400)
407
+ const yoe = y - era * 400
408
+ const mp = month > 2 ? month - 3 : month + 9
409
+ const doy = Math.floor((153 * mp + 2) / 5) + day - 1
410
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy
411
+ return era * 146097 + doe - 719468
412
+ }
413
+
414
+ /** Civil date of a day count since 1970-01-01 (inverse of daysFromCivil). / 1970-01-01 起点の日数 → 日付 (daysFromCivil の逆)。 */
415
+ const civilFromDays = (days: number): { readonly year: number; readonly month: number; readonly day: number } => {
416
+ const z = days + 719468
417
+ const era = Math.floor(z / 146097)
418
+ const doe = z - era * 146097
419
+ const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365)
420
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100))
421
+ const mp = Math.floor((5 * doy + 2) / 153)
422
+ const day = doy - Math.floor((153 * mp + 2) / 5) + 1
423
+ const month = mp < 10 ? mp + 3 : mp - 9
424
+ return { year: yoe + era * 400 + (month <= 2 ? 1 : 0), month, day }
425
+ }
426
+
427
+ /** Zero-pads an integer to `width` digits. / 整数を `width` 桁にゼロ埋め。 */
428
+ const pad = (value: number, width: number): string => String(value).padStart(width, "0")
429
+
430
+ /**
431
+ * A FINITE Sheets / Excel date serial (days since 1899-12-30, fraction = time of day) → the
432
+ * package's ISO text: `YYYY-MM-DD`, plus ` HH:mm:ss` when the fraction is non-zero (seconds
433
+ * rounded, a round-up past midnight rolls the day). Null outside the grammar's serial domain
434
+ * [SERIAL_GRAMMAR_MIN, SERIAL_GRAMMAR_MAX] — converting there would synthesise structurally
435
+ * broken pseudo-ISO text that isoDateToSerial cannot round-trip, so the callers keep the
436
+ * display text instead. Pure integer calendar math; both callers reject non-finite serials
437
+ * before calling.
438
+ * 有限の Sheets / Excel 日付シリアル (1899-12-30 起点、小数部 = 時刻) → パッケージの ISO 文字列:
439
+ * `YYYY-MM-DD`、小数部があれば ` HH:mm:ss` (秒は四捨五入、繰り上がりは翌日へ)。文法のシリアル域
440
+ * [SERIAL_GRAMMAR_MIN, SERIAL_GRAMMAR_MAX] の外は null — 域外を変換すると isoDateToSerial が
441
+ * 往復できない壊れた疑似 ISO 文字列を合成するため、呼び手は可視文字列を保つ。純整数暦。非有限は
442
+ * 両呼び手が事前に弾く。
443
+ */
444
+ const serialToIsoDate = (serial: number): string | null => {
445
+ // 域外の暦計算は "2.73…e+297-03-01" / "00-1-12-31" の様な文法外文字列を作るため変換しない
446
+ if (serial < SERIAL_GRAMMAR_MIN || serial > SERIAL_GRAMMAR_MAX) {
447
+ return null
448
+ }
449
+ let days = Math.floor(serial)
450
+ let seconds = Math.round((serial - days) * SECONDS_PER_DAY)
451
+ // 四捨五入で 1 日分に達したら翌日の 0 時
452
+ if (seconds >= SECONDS_PER_DAY) {
453
+ days += 1
454
+ seconds -= SECONDS_PER_DAY
455
+ }
456
+ const civil = civilFromDays(days + SERIAL_EPOCH_DAYS)
457
+ const date = `${pad(civil.year, 4)}-${pad(civil.month, 2)}-${pad(civil.day, 2)}`
458
+ if (seconds === 0) {
459
+ return date
460
+ }
461
+ const hours = Math.floor(seconds / 3600)
462
+ const minutes = Math.floor((seconds % 3600) / 60)
463
+ return `${date} ${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds % 60, 2)}`
464
+ }
465
+
466
+ /** The package date grammar: `YYYY-MM-DD` or `YYYY/MM/DD`, optional ` HH:mm[:ss]` / `THH:mm[:ss]`. / パッケージの日付文法。 */
467
+ const ISO_DATE = /^(\d{4})([-/])(\d{2})\2(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
468
+
469
+ /**
470
+ * The package's ISO date text → a Sheets / Excel serial (days since 1899-12-30 + fraction of
471
+ * the day); null when the text is not a valid calendar date-time (a 31st of April, 24:00, mixed
472
+ * separators). Exact inverse of serialToIsoDate on the grammar.
473
+ * パッケージの ISO 日付文字列 → Sheets / Excel シリアル。暦として無効 (4 月 31 日、24 時、区切り混在)
474
+ * なら null。文法上 serialToIsoDate の厳密な逆。
475
+ */
476
+ const isoDateToSerial = (text: string): number | null => {
477
+ const match = ISO_DATE.exec(text)
478
+ if (match === null) {
479
+ return null
480
+ }
481
+ const year = Number(match[1])
482
+ const month = Number(match[3])
483
+ const day = Number(match[4])
484
+ const hours = match[5] === undefined ? 0 : Number(match[5])
485
+ const minutes = match[6] === undefined ? 0 : Number(match[6])
486
+ const seconds = match[7] === undefined ? 0 : Number(match[7])
487
+ if (month < 1 || month > 12 || day < 1 || hours > 23 || minutes > 59 || seconds > 59) {
488
+ return null
489
+ }
490
+ // 暦の往復で日付の実在を検証 (4 月 31 日は 5 月 1 日へ写り不一致)
491
+ const days = daysFromCivil(year, month, day)
492
+ const back = civilFromDays(days)
493
+ if (back.year !== year || back.month !== month || back.day !== day) {
494
+ return null
495
+ }
496
+ return days - SERIAL_EPOCH_DAYS + (hours * 3600 + minutes * 60 + seconds) / SECONDS_PER_DAY
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------------------------
500
+ // Writer
501
+ // writer
502
+ // ---------------------------------------------------------------------------------------------
503
+
504
+ /** Escapes text content (`& < >`). / テキスト内容のエスケープ (`& < >`)。 */
505
+ const escapeText = (text: string): string => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
506
+
507
+ /** Escapes a double-quoted attribute value. / 二重引用の属性値のエスケープ。 */
508
+ const escapeAttribute = (text: string): string => escapeText(text).replaceAll('"', "&quot;")
509
+
510
+ /** Escapes a single-quoted attribute value (the clip JSON and the style attribute keep raw `"`). / 単一引用の属性値のエスケープ (クリップ JSON と style 属性は `"` を素のまま保つ)。 */
511
+ const escapeSingleQuoted = (text: string): string => escapeText(text).replaceAll("'", "&#39;")
512
+
513
+ /** One cell's inner HTML: escape, then newline / tab forms. / セル 1 つの内側 HTML: エスケープ後に改行 / タブ形。 */
514
+ const serializeContent = (text: string): string => escapeText(text).replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\n", '<br style="mso-data-placement:same-cell">').replaceAll("\t", "&#9;")
515
+
516
+ /** A JSON-valued Sheets attribute in Sheets' own `&quot;` form. / Sheets 自身の `&quot;` 形の JSON 属性。 */
517
+ const sheetsJsonAttribute = (name: string, value: unknown): string => `${name}="${escapeAttribute(JSON.stringify(value))}"`
518
+
519
+ /** The numeric value-format types (the ones whose text is a decimal). / 数値系の値書式型 (文字列が 10 進であるもの)。 */
520
+ type NumericValueFormat =
521
+ | { type: "number"; decimals?: number; grouping?: boolean; locale?: string }
522
+ | { type: "percent"; decimals?: number; scale?: boolean; locale?: string }
523
+ | { type: "currency"; currency: string; decimals?: number; locale?: string }
524
+ | { type: "scientific"; decimals?: number; locale?: string }
525
+
526
+ /** Numeric value-format guard. / 数値系値書式のガード。 */
527
+ const isNumericValueFormat = (value: CellValueFormat): value is NumericValueFormat => value.type === "number" || value.type === "percent" || value.type === "currency" || value.type === "scientific"
528
+
529
+ /** The finite double of a strict-decimal text, or null (non-decimal / overflowing). / 厳密 10 進文字列の有限 double、なければ null (非 10 進 / 桁溢れ)。 */
530
+ const finiteNumberOf = (text: string): number | null => {
531
+ if (parseNumber(text) === null) {
532
+ return null
533
+ }
534
+ const n = Number(text)
535
+ return Number.isFinite(n) ? n : null
536
+ }
537
+
538
+ /** Sheets `NumberFormatType` for a date pattern: 5 DATE, 6 TIME, 7 DATE_TIME by the tokens present. / 日付パターンの Sheets 型コード (存在するトークンで 5 / 6 / 7)。 */
539
+ const sheetsDateCode = (pattern: string): number => {
540
+ const hasDate = /yyyy|MM|dd/.test(pattern)
541
+ const hasTime = /HH|mm|ss/.test(pattern)
542
+ return hasDate && hasTime ? 7 : hasTime ? 6 : 5
543
+ }
544
+
545
+ /** The `data-sheets-numberformat` object of a value format, or null when Excel / Sheets cannot express it. / 値書式の `data-sheets-numberformat` オブジェクト、表現不能は null。 */
546
+ const sheetsNumberFormat = (value: CellValueFormat): Record<string, unknown> | null => {
547
+ if (value.type === "text") {
548
+ return { "1": 1 }
549
+ }
550
+ const pattern = toExcelPattern(value)
551
+ if (pattern === null) {
552
+ return null
553
+ }
554
+ // "3":1 = 明示パターン (ユーザー指定形) — Sheets がパターンをそのまま尊重する
555
+ const code = value.type === "date" ? sheetsDateCode(value.pattern === undefined ? "yyyy/MM/dd" : value.pattern) : SHEETS_NUMBER_FORMAT_CODE[value.type]
556
+ return { "1": code, "2": pattern, "3": 1 }
557
+ }
558
+
559
+ /** Characters a font family may not carry into a style attribute (declaration / markup injection). / style 属性へ持ち込めないフォント族の文字 (宣言 / マークアップ注入)。 */
560
+ const UNSAFE_FAMILY = /[;"'{}<>&\\]/
561
+
562
+ /** Closed-vocabulary guard for align. / align の閉じた語彙ガード。 */
563
+ const isCellAlign = (value: unknown): value is CellAlign => value === "left" || value === "center" || value === "right"
564
+
565
+ /** Closed-vocabulary guard for verticalAlign. / verticalAlign の閉じた語彙ガード。 */
566
+ const isVerticalAlign = (value: unknown): value is CellVerticalAlign => value === "top" || value === "middle" || value === "bottom"
567
+
568
+ /**
569
+ * CSS declarations of a format in the shared vocabulary (order fixed): font-weight, font-style,
570
+ * text-decoration, color, background-color, text-align, vertical-align, font-family (skipped when
571
+ * it holds injection characters), font-size in pt (finite positive only), one `border-<side>` per
572
+ * declared side (`windowtext` for the default colour; a side with an unknown style or an
573
+ * unparseable colour is skipped whole), and `mso-number-format` when `toExcelPattern` can express
574
+ * the value format. Booleans count only when strictly `true`.
575
+ * 書式の CSS 宣言 (共通語彙、順序固定)。フォント族は注入文字を含めば省略、フォントサイズは有限正のみ、
576
+ * 罫線は宣言された辺だけ (既定色は `windowtext`、未知の線種 / 解釈不能な色の辺は丸ごと省略)、
577
+ * `mso-number-format` は `toExcelPattern` が表現できるときだけ。真偽値は厳密 true のみ。
578
+ */
579
+ const formatCss = (format: CellFormat): string[] => {
580
+ const css: string[] = []
581
+ if (format.bold === true) {
582
+ css.push("font-weight:bold")
583
+ }
584
+ if (format.italic === true) {
585
+ css.push("font-style:italic")
586
+ }
587
+ const decorations = [...(format.underline === true ? ["underline"] : []), ...(format.strikethrough === true ? ["line-through"] : [])]
588
+ if (decorations.length > 0) {
589
+ css.push(`text-decoration:${decorations.join(" ")}`)
590
+ }
591
+ const color = typeof format.color === "string" ? normalizeColor(format.color) : null
592
+ if (color !== null) {
593
+ css.push(`color:${color}`)
594
+ }
595
+ const background = typeof format.background === "string" ? normalizeColor(format.background) : null
596
+ if (background !== null) {
597
+ css.push(`background-color:${background}`)
598
+ }
599
+ if (isCellAlign(format.align)) {
600
+ css.push(`text-align:${format.align}`)
601
+ }
602
+ if (isVerticalAlign(format.verticalAlign)) {
603
+ css.push(`vertical-align:${format.verticalAlign}`)
604
+ }
605
+ if (typeof format.fontFamily === "string" && format.fontFamily.trim() !== "" && !UNSAFE_FAMILY.test(format.fontFamily)) {
606
+ css.push(`font-family:${format.fontFamily.trim()}`)
607
+ }
608
+ if (typeof format.fontSize === "number" && Number.isFinite(format.fontSize) && format.fontSize > 0) {
609
+ css.push(`font-size:${format.fontSize}pt`)
610
+ }
611
+ if (format.borders !== undefined) {
612
+ for (const side of BORDER_SIDES) {
613
+ const border = format.borders[side]
614
+ // 語彙外の線種は辺ごと省略 (prototype キーも Object.hasOwn で弾く)
615
+ if (border === undefined || !Object.hasOwn(BORDER_CSS, border.style)) {
616
+ continue
617
+ }
618
+ const borderColor = border.color === undefined ? "windowtext" : normalizeColor(border.color)
619
+ if (borderColor !== null) {
620
+ css.push(`border-${side}:${BORDER_CSS[border.style]} ${borderColor}`)
621
+ }
622
+ }
623
+ }
624
+ if (format.value !== undefined) {
625
+ const pattern = toExcelPattern(format.value)
626
+ if (pattern !== null) {
627
+ css.push(`mso-number-format:"${encodeMsoNumberFormat(pattern)}"`)
628
+ }
629
+ }
630
+ return css
631
+ }
632
+
633
+ /**
634
+ * The cell-independent, format-derived parts of a `<td>`, derived ONCE per dictionary index by
635
+ * `serializeHtmlTable`: the escaped `style` attribute value (white-space mode + `formatCss`
636
+ * joined) and the complete `data-sheets-numberformat` attribute (null when the format has no
637
+ * expressible value format), plus the value format the per-cell classification still needs.
638
+ * These are pure functions of the format object — recomputing them per cell costs O(cells ×
639
+ * format work) where the `fmt` dictionary makes O(distinct formats) available (~half of the
640
+ * 100k-cell HTML serialize wall time).
641
+ * `<td>` の書式由来でセル非依存の部分 (`serializeHtmlTable` が辞書 index 毎に 1 回だけ導出):
642
+ * エスケープ済み `style` 属性値 (white-space モード + `formatCss` の結合) と `data-sheets-numberformat`
643
+ * 属性の完成形 (表現できる値書式が無ければ null)、およびセル毎の分類が引き続き要る値書式。
644
+ * いずれも書式オブジェクトの純関数 — セル毎の再計算は O(セル数 × 書式処理) で、`fmt` 辞書は
645
+ * O(異なり書式数) を可能にしている (100k セル HTML 直列化の壁時計の約半分)。
646
+ */
647
+ type SerializedFormat = { readonly value: CellValueFormat | undefined; readonly style: string; readonly numberFormatAttribute: string | null }
648
+
649
+ /** The parts of a format-less cell (exactly `<td style='white-space:pre'>`). / 書式なしセルの部分 (正確に `<td style='white-space:pre'>`)。 */
650
+ const PLAIN_SERIALIZED_FORMAT: SerializedFormat = { value: undefined, style: "white-space:pre", numberFormatAttribute: null }
651
+
652
+ /**
653
+ * Derives the `SerializedFormat` of one dictionary entry (see the type's contract).
654
+ * 辞書エントリ 1 つの `SerializedFormat` を導出する (契約は型に記載)。
655
+ */
656
+ const serializeFormat = (format: CellFormat): SerializedFormat => {
657
+ const value = format.value
658
+ const whiteSpace = format.wrap === true ? "white-space:pre-wrap;word-wrap:break-word" : "white-space:pre"
659
+ const style = escapeSingleQuoted([whiteSpace, ...formatCss(format)].join(";"))
660
+ let numberFormatAttribute: string | null = null
661
+ if (value !== undefined) {
662
+ const numberFormat = sheetsNumberFormat(value)
663
+ if (numberFormat !== null) {
664
+ numberFormatAttribute = sheetsJsonAttribute("data-sheets-numberformat", numberFormat)
665
+ }
666
+ }
667
+ return { value, style, numberFormatAttribute }
668
+ }
669
+
670
+ /**
671
+ * One `<td>`: the style attribute (single-quoted like Excel so `mso-number-format` keeps its
672
+ * double quotes), then the Sheets typed attributes, then the Excel typed attributes, then the
673
+ * display text (wrapped in `<a href>` for a link). Attributes appear only for the aspects the cell
674
+ * has, so a plain cell is exactly `<td style='white-space:pre'>text</td>`. `format` carries the
675
+ * memoised format-derived strings; the per-cell work here depends on the cell's own text / aspects.
676
+ * `<td>` 1 つ: style 属性 (Excel 同様の単一引用で `mso-number-format` の二重引用を保つ)、Sheets の
677
+ * 型付き属性、Excel の型付き属性、表示文字列 (リンクは `<a href>` で包む)。属性はセルが持つ側面の
678
+ * 分だけ出るので、素のセルは正確に `<td style='white-space:pre'>text</td>`。`format` は memo 済みの
679
+ * 書式由来文字列 — ここでのセル毎の仕事はセル自身の文字列 / 側面にだけ依存する。
680
+ */
681
+ const serializeCell = (cell: ClipCell, format: SerializedFormat, row: number, col: number): string => {
682
+ const value = format.value
683
+ const display = value === undefined ? cell.t : formatCellValue(cell.t, value)
684
+ // 型付き値の分類: "=" リテラル / text 書式は文字列、数値書式 + 10 進は数値、date 書式 + ISO はシリアル
685
+ const stringLiteral = cell.t !== "" && ((value !== undefined && value.type === "text") || cell.t.startsWith("="))
686
+ const numeric = !stringLiteral && value !== undefined && isNumericValueFormat(value) ? finiteNumberOf(cell.t) : null
687
+ const serial = !stringLiteral && numeric === null && value !== undefined && value.type === "date" ? isoDateToSerial(cell.t) : null
688
+ const attributes: string[] = []
689
+ if (stringLiteral) {
690
+ attributes.push(sheetsJsonAttribute("data-sheets-value", { "1": 2, "2": cell.t }))
691
+ } else if (numeric !== null) {
692
+ attributes.push(sheetsJsonAttribute("data-sheets-value", { "1": 3, "3": numeric }))
693
+ } else if (serial !== null) {
694
+ attributes.push(sheetsJsonAttribute("data-sheets-value", { "1": 3, "3": serial }))
695
+ }
696
+ if (cell.f !== undefined) {
697
+ attributes.push(`data-sheets-formula="${escapeAttribute(cell.f)}"`)
698
+ }
699
+ if (format.numberFormatAttribute !== null) {
700
+ attributes.push(format.numberFormatAttribute)
701
+ }
702
+ if (cell.link !== undefined) {
703
+ attributes.push(`data-sheets-hyperlink="${escapeAttribute(cell.link)}"`)
704
+ }
705
+ if (cell.note !== undefined) {
706
+ attributes.push(`data-sheets-note="${escapeAttribute(cell.note)}"`)
707
+ }
708
+ if (stringLiteral) {
709
+ attributes.push("x:str")
710
+ } else if (numeric !== null) {
711
+ attributes.push(`x:num="${escapeAttribute(cell.t)}"`)
712
+ } else if (serial !== null) {
713
+ attributes.push(`x:num="${serial}"`)
714
+ }
715
+ if (cell.f !== undefined) {
716
+ // クリップを A1 に置いた A1 形。クリップ外へ落ちる参照 (#REF!) は Excel へ渡さない
717
+ const a1 = fromR1C1(cell.f, { row, col })
718
+ if (!a1.includes("#REF!")) {
719
+ attributes.push(`x:fmla="${escapeAttribute(a1)}"`)
720
+ }
721
+ }
722
+ const content = serializeContent(display)
723
+ const body = cell.link === undefined ? content : `<a href="${escapeAttribute(cell.link)}">${content}</a>`
724
+ return `<td style='${format.style}'${attributes.map((attribute) => ` ${attribute}`).join("")}>${body}</td>`
725
+ }
726
+
727
+ /**
728
+ * Serializes a clip payload to the interop HTML table (see the fileoverview writer contract).
729
+ * `clipJson` is the payload's `serializeClipPayload` string (the caller already holds it for the
730
+ * custom MIME flavour) and becomes the FIRST attribute of `<table>`. A payload whose `cells`
731
+ * matrix is not `rows × cols` is a RangeError (consumer input on an API surface).
732
+ * クリップ payload を相互運用 HTML 表へ直列化 (fileoverview の writer 契約)。`clipJson` は payload の
733
+ * `serializeClipPayload` 文字列 (呼び手はカスタム MIME 用に既に持つ) で `<table>` の先頭属性になる。
734
+ * `cells` が `rows × cols` でない payload は RangeError (API 面の消費者入力)。
735
+ */
736
+ export const serializeHtmlTable = (payload: ClipPayload, clipJson: string): string => {
737
+ if (payload.cells.length !== payload.rows || payload.cells.some((row) => row.length !== payload.cols)) {
738
+ throw new RangeError(`serializeHtmlTable: cells must be exactly rows × cols (${payload.rows} × ${payload.cols})`)
739
+ }
740
+ const colgroup = payload.colWidths === undefined ? "" : `<colgroup>${payload.colWidths.map((width) => `<col width="${width}">`).join("")}</colgroup>`
741
+ // 書式由来の文字列 (style / data-sheets-numberformat) は辞書 index 毎に 1 回だけ導出する —
742
+ // セル毎の再計算は O(セル数 × 書式処理) で、100k copy の HTML 直列化の壁時計の約半分を占めていた
743
+ const formats = payload.formats.map(serializeFormat)
744
+ let rows = ""
745
+ for (let r = 0; r < payload.rows; r += 1) {
746
+ const height = payload.rowHeights === undefined ? "" : ` height="${payload.rowHeights[r]}" style="height:${payload.rowHeights[r]}px;"`
747
+ let cells = ""
748
+ for (let c = 0; c < payload.cols; c += 1) {
749
+ const cell = payload.cells[r][c]
750
+ // 辞書範囲外の fmt は旧来どおり書式なしセルと同じ直列化 (契約上は serializeClipPayload が先に拒む)
751
+ const memo = cell.fmt === undefined ? undefined : formats[cell.fmt]
752
+ cells += serializeCell(cell, memo === undefined ? PLAIN_SERIALIZED_FORMAT : memo, r, c)
753
+ }
754
+ rows += `<tr${height}>${cells}</tr>`
755
+ }
756
+ return `<${SHEETS_ORIGIN_TAG}><style type="text/css">br{mso-data-placement:same-cell;}</style><table ${CLIP_ATTRIBUTE}='${escapeSingleQuoted(clipJson)}' data-sheets-root="1" cellspacing="0" cellpadding="0" border="1" style="table-layout:fixed;border-collapse:collapse">${colgroup}<tbody>${rows}</tbody></table></${SHEETS_ORIGIN_TAG}>`
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------------------------
760
+ // Reader — CSS vocabulary → CellFormat
761
+ // reader — CSS 語彙 → CellFormat
762
+ // ---------------------------------------------------------------------------------------------
763
+
764
+ /** Mutable declaration map (class rules merged into inline). / 可変の宣言 map (クラス規則 + インライン)。 */
765
+ type Declarations = Map<string, string>
766
+
767
+ /** Decorations contributed by run TAGS (`u`, `s`, …), additive to the declared tokens. / run タグ (`u`, `s` …) が寄与する装飾 (宣言トークンへ加算)。 */
768
+ type RunFlags = { underline: boolean; strikethrough: boolean }
769
+
770
+ /** What the reader needs per document. / reader が文書毎に要するもの。 */
771
+ type ReadContext = { readonly kind: HtmlTableKind; readonly rules: StyleRules; readonly options: NumberFormatDecodeOptions; readonly separators: NumberSeparators }
772
+
773
+ /** Merges `source` into `target` (later wins). / `source` を `target` へ併合 (後勝ち)。 */
774
+ const mergeDeclarations = (target: Declarations, source: ReadonlyMap<string, string>): void => {
775
+ for (const [name, value] of source) {
776
+ target.set(name, value)
777
+ }
778
+ }
779
+
780
+ /**
781
+ * The declarations of an element in cascade order: the `bgcolor` attribute (lowest — entered as
782
+ * the `background` shorthand so any CSS declaration wins over it), class rules in class-list
783
+ * order, then the inline `style`.
784
+ * 要素の宣言 (カスケード順): `bgcolor` 属性 (最下位 — `background` 短縮形として入れ、CSS 宣言が
785
+ * 属性に勝つ)、クラス一覧順のクラス規則、インライン `style`。
786
+ */
787
+ const declarationsOf = (element: Element, rules: StyleRules): Declarations => {
788
+ const merged: Declarations = new Map()
789
+ const bgcolor = element.getAttribute("bgcolor")
790
+ if (bgcolor !== null && bgcolor.trim() !== "") {
791
+ merged.set("background", bgcolor)
792
+ }
793
+ for (const className of element.classList) {
794
+ const classDeclarations = rules.classes.get(className)
795
+ if (classDeclarations !== undefined) {
796
+ mergeDeclarations(merged, classDeclarations)
797
+ }
798
+ }
799
+ const style = element.getAttribute("style")
800
+ if (style !== null) {
801
+ mergeDeclarations(merged, tokenizeInlineStyle(style))
802
+ }
803
+ return merged
804
+ }
805
+
806
+ /** A border width: its pt value and the unit it was declared in (keywords count as px). / 罫線幅: pt 値と宣言単位 (キーワードは px 扱い)。 */
807
+ type BorderWidth = { readonly pt: number; readonly unit: "pt" | "px" }
808
+
809
+ /** Border width from a CSS token (`0.5pt`, `2px`; the keywords as 1 / 2 / 3 px so they map to their own names), or null. / CSS トークンからの罫線幅 (キーワードは同名へ写るよう 1 / 2 / 3 px)、なければ null。 */
810
+ const borderWidthOf = (token: string): BorderWidth | null => {
811
+ const lower = token.toLowerCase()
812
+ if (lower === "thin") {
813
+ return { pt: 0.75, unit: "px" }
814
+ }
815
+ if (lower === "medium") {
816
+ return { pt: 1.5, unit: "px" }
817
+ }
818
+ if (lower === "thick") {
819
+ return { pt: 3, unit: "px" }
820
+ }
821
+ const match = /^(\d*\.?\d+)(pt|px)$/.exec(lower)
822
+ if (match === null) {
823
+ return null
824
+ }
825
+ const n = Number(match[1])
826
+ return match[2] === "pt" ? { pt: n, unit: "pt" } : { pt: n * 0.75, unit: "px" }
827
+ }
828
+
829
+ /**
830
+ * Solid-line style from a width: px widths follow Sheets (1 / 2 / ≥ 3 px), pt widths follow Excel
831
+ * (`.5pt` thin, `1.0pt` medium, `1.5pt` thick) — thresholds < 0.75 / < 1.25 pt.
832
+ * 幅からの実線種: px は Sheets (1 / 2 / 3 px 以上)、pt は Excel (.5 / 1.0 / 1.5pt) — 閾値 0.75 / 1.25pt。
833
+ */
834
+ const solidStyleOf = (width: BorderWidth): CellBorderStyle => {
835
+ if (width.unit === "px") {
836
+ const px = width.pt / 0.75
837
+ return px <= 1 ? "thin" : px < 3 ? "medium" : "thick"
838
+ }
839
+ return width.pt < 0.75 ? "thin" : width.pt < 1.25 ? "medium" : "thick"
840
+ }
841
+
842
+ /** Style keyword → package border style (solid-like keywords defer to the width). / 線種キーワード → パッケージの罫線種 (実線系は幅に委ねる)。 */
843
+ const BORDER_STYLE_OF: ReadonlyMap<string, CellBorderStyle> = new Map([
844
+ ["dashed", "dashed"],
845
+ ["dotted", "dotted"],
846
+ ["double", "double"],
847
+ ["hairline", "hair"],
848
+ ])
849
+
850
+ /** CSS `medium` — the width when a border value declares none (maps to the medium style). / CSS の `medium` — 幅の宣言が無いときの幅 (medium 線種へ写る)。 */
851
+ const CSS_MEDIUM_WIDTH: BorderWidth = { pt: 1.5, unit: "px" }
852
+
853
+ /**
854
+ * One border side from a CSS border value: `null` when the side is absent (`none` / `hidden`, no
855
+ * style token, a transparent or unparseable colour); otherwise the style (keyword, else by width —
856
+ * a missing width is CSS `medium`) and the colour (omitted for `windowtext` / `currentcolor`).
857
+ * CSS の border 値から 1 辺: 辺なし (`none` / `hidden`、線種トークンなし、透明 / 解釈不能な色) は
858
+ * null、他は線種 (キーワード、なければ幅から — 幅なしは CSS の `medium`) と色 (`windowtext` /
859
+ * `currentcolor` は省略)。
860
+ */
861
+ const parseBorderValue = (value: string): CellBorder | null => {
862
+ let width: BorderWidth | null = null
863
+ let keyword: string | null = null
864
+ const colorTokens: string[] = []
865
+ for (const token of splitCssTokens(value)) {
866
+ const lower = token.toLowerCase()
867
+ const asWidth = borderWidthOf(token)
868
+ if (BORDER_STYLE_KEYWORDS.has(lower)) {
869
+ keyword = lower
870
+ } else if (asWidth !== null) {
871
+ width = asWidth
872
+ } else {
873
+ colorTokens.push(token)
874
+ }
875
+ }
876
+ // 線種トークンが無い / none / hidden は辺なし (CSS の既定 style は none)
877
+ if (keyword === null || keyword === "none" || keyword === "hidden") {
878
+ return null
879
+ }
880
+ const mapped = BORDER_STYLE_OF.get(keyword)
881
+ const style = mapped === undefined ? solidStyleOf(width === null ? CSS_MEDIUM_WIDTH : width) : mapped
882
+ const colorText = colorTokens.join(" ")
883
+ if (colorText === "" || DEFAULT_COLOR_WORDS.has(colorText.toLowerCase())) {
884
+ return { style }
885
+ }
886
+ const color = normalizeColor(colorText)
887
+ // 解釈不能な色は辺ごとスキップ、透明 (alpha 00) は罫線なし
888
+ if (color === null || color.length === 9) {
889
+ return null
890
+ }
891
+ return { style, color }
892
+ }
893
+
894
+ /** Sheets' own grid-line declaration (the `td` rule a sanitising engine inlines) — not a user border. / Sheets 自身の罫線既定 (サニタイズ engine がインライン化する `td` 規則) — ユーザーの罫線ではない。 */
895
+ const isSheetsGridLine = (border: CellBorder): boolean => border.style === "thin" && border.color === "#cccccc"
896
+
897
+ /** Colour of a `background` shorthand: the whole value, else the first token normalizeColor accepts. / `background` 短縮形の色: 値全体、なければ normalizeColor が受ける最初のトークン。 */
898
+ const colorFromShorthand = (value: string): string | null => {
899
+ const whole = normalizeColor(value)
900
+ if (whole !== null) {
901
+ return whole
902
+ }
903
+ for (const token of splitCssTokens(value)) {
904
+ const color = normalizeColor(token)
905
+ if (color !== null) {
906
+ return color
907
+ }
908
+ }
909
+ return null
910
+ }
911
+
912
+ /** The first family of a `font-family` list, unquoted; null for a generic keyword or nothing. / `font-family` 一覧の先頭族 (引用符なし)、総称キーワード / 空は null。 */
913
+ const firstFontFamily = (value: string): string | null => {
914
+ const first = stripCssQuotes(splitCssTokens(value.split(",")[0]).join(" ").trim()).trim()
915
+ return first === "" || GENERIC_FAMILIES.has(first.toLowerCase()) ? null : first
916
+ }
917
+
918
+ /** `font-size` in pt from `NNpt` / `NNpx` (× 0.75); null for keywords, percentages, em, non-positive. / `NNpt` / `NNpx` (× 0.75) からの pt サイズ、キーワード / % / em / 非正は null。 */
919
+ const fontSizePt = (value: string): number | null => {
920
+ const match = /^(\d*\.?\d+)(pt|px)$/i.exec(value.trim())
921
+ if (match === null) {
922
+ return null
923
+ }
924
+ const n = Number(match[1])
925
+ const pt = match[2].toLowerCase() === "pt" ? n : n * 0.75
926
+ return Number.isFinite(pt) && pt > 0 ? pt : null
927
+ }
928
+
929
+ /** Decoration tokens of `text-decoration` and `text-decoration-line` (union, `none` contributes nothing). / `text-decoration` と `text-decoration-line` の装飾トークン (和集合、`none` は寄与しない)。 */
930
+ const decorationTokens = (declarations: Declarations): ReadonlySet<string> => {
931
+ const tokens = new Set<string>()
932
+ for (const name of ["text-decoration", "text-decoration-line"]) {
933
+ const value = declarations.get(name)
934
+ if (value !== undefined) {
935
+ for (const token of value.toLowerCase().split(/\s+/)) {
936
+ tokens.add(token)
937
+ }
938
+ }
939
+ }
940
+ return tokens
941
+ }
942
+
943
+ /** The wrap / preserve mode of a cell from its `white-space`. / セルの `white-space` からの折返し / 保存モード。 */
944
+ type WhiteSpaceMode = "collapse" | "pre" | "pre-line"
945
+
946
+ /** `white-space` → text mode (`pre` / `pre-wrap` verbatim, `pre-line` newline-preserving, else collapse). / `white-space` → 文字列モード。 */
947
+ const whiteSpaceModeOf = (declarations: Declarations): WhiteSpaceMode => {
948
+ const value = declarations.get("white-space")
949
+ const lower = value === undefined ? "" : value.toLowerCase()
950
+ return lower === "pre" || lower === "pre-wrap" ? "pre" : lower === "pre-line" ? "pre-line" : "collapse"
951
+ }
952
+
953
+ /** `white-space` values that mean "wrap". / 「折返し」を意味する `white-space` 値。 */
954
+ const WRAP_VALUES: ReadonlySet<string> = new Set(["normal", "pre-wrap", "pre-line"])
955
+
956
+ /**
957
+ * Builds the CellFormat a cell's merged declarations describe (the vocabulary mapping of the
958
+ * fileoverview). Values outside the closed vocabularies are skipped, never substituted; the
959
+ * value format is attached by the kind-specific caller.
960
+ * セルの併合済み宣言が表す CellFormat を組む (fileoverview の語彙写像)。閉じた語彙の外は代替せず
961
+ * スキップ。値書式は kind 毎の呼び手が付ける。
962
+ */
963
+ const formatFromDeclarations = (declarations: Declarations, flags: RunFlags, kind: HtmlTableKind): CellFormat => {
964
+ const format: CellFormat = {}
965
+ const weight = declarations.get("font-weight")
966
+ if (weight !== undefined) {
967
+ const lower = weight.toLowerCase()
968
+ if (lower === "bold" || lower === "bolder" || Number(lower) >= 600) {
969
+ format.bold = true
970
+ }
971
+ }
972
+ const fontStyle = declarations.get("font-style")
973
+ if (fontStyle !== undefined && (fontStyle.toLowerCase() === "italic" || fontStyle.toLowerCase().startsWith("oblique"))) {
974
+ format.italic = true
975
+ }
976
+ const decorations = decorationTokens(declarations)
977
+ if (flags.underline || decorations.has("underline")) {
978
+ format.underline = true
979
+ }
980
+ if (flags.strikethrough || decorations.has("line-through")) {
981
+ format.strikethrough = true
982
+ }
983
+ const colorValue = declarations.get("color")
984
+ const color = colorValue === undefined ? null : normalizeColor(colorValue)
985
+ if (color !== null && color.length !== 9) {
986
+ format.color = color
987
+ }
988
+ // 長形 background-color が短縮形 background (と bgcolor 属性) に優先する
989
+ const backgroundValue = declarations.has("background-color") ? declarations.get("background-color") : declarations.get("background")
990
+ const background = backgroundValue === undefined ? null : colorFromShorthand(backgroundValue)
991
+ if (background !== null && background.length !== 9) {
992
+ format.background = background
993
+ }
994
+ const align = declarations.get("text-align")
995
+ const alignLower = align === undefined ? "" : align.toLowerCase()
996
+ if (isCellAlign(alignLower)) {
997
+ format.align = alignLower
998
+ }
999
+ const verticalAlign = declarations.get("vertical-align")
1000
+ const verticalLower = verticalAlign === undefined ? "" : verticalAlign.toLowerCase()
1001
+ if (isVerticalAlign(verticalLower)) {
1002
+ format.verticalAlign = verticalLower
1003
+ }
1004
+ const family = declarations.get("font-family")
1005
+ const firstFamily = family === undefined ? null : firstFontFamily(family)
1006
+ if (firstFamily !== null) {
1007
+ format.fontFamily = firstFamily
1008
+ }
1009
+ const size = declarations.get("font-size")
1010
+ const pt = size === undefined ? null : fontSizePt(size)
1011
+ if (pt !== null) {
1012
+ format.fontSize = pt
1013
+ }
1014
+ const whiteSpace = declarations.get("white-space")
1015
+ if (whiteSpace !== undefined && WRAP_VALUES.has(whiteSpace.toLowerCase())) {
1016
+ format.wrap = true
1017
+ }
1018
+ const borders: CellBorders = {}
1019
+ const shorthand = declarations.get("border")
1020
+ const all = shorthand === undefined ? null : parseBorderValue(shorthand)
1021
+ for (const side of BORDER_SIDES) {
1022
+ const sideValue = declarations.get(`border-${side}`)
1023
+ // 辺の宣言が短縮形に優先する
1024
+ const border = sideValue === undefined ? all : parseBorderValue(sideValue)
1025
+ if (border !== null && !(kind === "sheets" && isSheetsGridLine(border))) {
1026
+ borders[side] = border
1027
+ }
1028
+ }
1029
+ if (Object.keys(borders).length > 0) {
1030
+ format.borders = borders
1031
+ }
1032
+ return format
1033
+ }
1034
+
1035
+ // ---------------------------------------------------------------------------------------------
1036
+ // Reader — cell text and runs
1037
+ // reader — セル文字列と run
1038
+ // ---------------------------------------------------------------------------------------------
1039
+
1040
+ /**
1041
+ * A run element and the `[before, end)` window of the walked text its subtree produced — offsets,
1042
+ * never a copy, so capturing a run is O(1) whatever the nesting depth or text length.
1043
+ * run 要素とその部分木が生んだ走査文字列の `[before, end)` 窓 — 複製でなくオフセット。捕捉は
1044
+ * 入れ子深さ / 文字数に依らず run 1 つ O(1)。
1045
+ */
1046
+ type RunCandidate = { readonly element: Element; readonly before: number; readonly end: number }
1047
+
1048
+ /** The walk state of one cell. / セル 1 つの走査状態。 */
1049
+ type WalkState = { text: string; readonly runs: RunCandidate[]; href: string | null; readonly mode: WhiteSpaceMode }
1050
+
1051
+ /** Ends the current line in collapse mode (once). / collapse モードで現在行を閉じる (1 回だけ)。 */
1052
+ const lineBoundary = (state: WalkState): void => {
1053
+ if (state.text !== "" && !state.text.endsWith("\n")) {
1054
+ state.text += "\n"
1055
+ }
1056
+ }
1057
+
1058
+ /** True when an element is an `mso-spacerun:yes` span (accounting padding — dropped whole). / 要素が `mso-spacerun:yes` の span (会計形式の詰め物 — 丸ごと落とす) なら true。 */
1059
+ const isSpaceRun = (element: Element): boolean => {
1060
+ const style = element.getAttribute("style")
1061
+ return style !== null && tokenizeInlineStyle(style).get("mso-spacerun") === "yes"
1062
+ }
1063
+
1064
+ /** One traversal frame of `walkCell`: `enter` visits a node, `exit` closes an element (run capture + trailing line boundary). / `walkCell` の走査フレーム 1 つ: `enter` はノード訪問、`exit` は要素の閉じ (run 捕捉 + 後置きの行境界)。 */
1065
+ type WalkFrame = { readonly kind: "enter"; readonly node: Node } | { readonly kind: "exit"; readonly element: Element; readonly before: number; readonly block: boolean }
1066
+
1067
+ /**
1068
+ * Walks a cell subtree appending text to the state: text nodes (newlines / tabs → spaces in
1069
+ * collapse mode, CR forms → LF otherwise), `<br>` → newline, skipped tags and space-run spans
1070
+ * → nothing, block elements and nested rows → line boundaries (collapse mode), nested cells → a
1071
+ * separating space, the first `<a href>` recorded, run elements recorded with their text WINDOW
1072
+ * (start / end offsets — never a text copy, so a run costs O(1)).
1073
+ * Iterative (explicit enter / exit stack over `lastChild` / `previousSibling`, same visit order
1074
+ * as the recursive form): the call-stack depth is O(1) whatever the markup nesting — the total
1075
+ * fail-soft contract must survive pathologically deep cells.
1076
+ * セル部分木を走査して状態へ文字列を追記: テキストノード (collapse では改行 / タブ → 空白、他は CR 形
1077
+ * → LF)、`<br>` → 改行、除外タグ / spacerun → 無、ブロック要素 / 入れ子行 → 行境界 (collapse)、入れ子
1078
+ * セル → 区切り空白、最初の `<a href>` を記録、run 要素はその文字列**窓** (開始 / 終了オフセット —
1079
+ * 文字列複製なし、run 1 つ O(1)) と共に記録。反復走査 (enter / exit の
1080
+ * 明示スタック、`lastChild` / `previousSibling` — 訪問順は再帰形と同一): 呼び出しスタックの深さは
1081
+ * マークアップの入れ子深さに依存しない O(1) — 病的に深いセルでも全域 fail-soft 契約を保つ。
1082
+ */
1083
+ const walkCell = (root: Node, state: WalkState): void => {
1084
+ const stack: WalkFrame[] = [{ kind: "enter", node: root }]
1085
+ for (let frame = stack.pop(); frame !== undefined; frame = stack.pop()) {
1086
+ // exit = 再帰の帰り道に相当: 子孫の生んだ文字列で run を捕捉し、ブロックの後置き行境界を閉じる
1087
+ if (frame.kind === "exit") {
1088
+ if (RUN_TAGS.has(frame.element.localName)) {
1089
+ // run はオフセット窓だけ記録する — 深い入れ子 × 長文の敵対セルで run 毎の文字列
1090
+ // 実体化が O(深さ × 文字数) の同期費用にならないため (readCell 側の判定も O(1))
1091
+ state.runs.push({ element: frame.element, before: frame.before, end: state.text.length })
1092
+ }
1093
+ if (frame.block) {
1094
+ lineBoundary(state)
1095
+ }
1096
+ continue
1097
+ }
1098
+ const node = frame.node
1099
+ if (node.nodeType === TEXT_NODE) {
1100
+ const data = (node as Text).data
1101
+ state.text += state.mode === "collapse" ? data.replace(/[\r\n\t\f]/g, " ") : data.replace(/\r\n?/g, "\n")
1102
+ continue
1103
+ }
1104
+ if (node.nodeType !== ELEMENT_NODE) {
1105
+ continue
1106
+ }
1107
+ const element = node as Element
1108
+ const tag = element.localName
1109
+ if (SKIP_TAGS.has(tag) || isSpaceRun(element)) {
1110
+ continue
1111
+ }
1112
+ if (tag === "br") {
1113
+ state.text += "\n"
1114
+ continue
1115
+ }
1116
+ const block = state.mode === "collapse" && BLOCK_TAGS.has(tag)
1117
+ if (block) {
1118
+ lineBoundary(state)
1119
+ }
1120
+ // 入れ子表のセル同士は空白 1 つで区切る
1121
+ if (state.mode === "collapse" && (tag === "td" || tag === "th") && state.text !== "" && !/\s$/.test(state.text)) {
1122
+ state.text += " "
1123
+ }
1124
+ if (tag === "a" && state.href === null) {
1125
+ const href = element.getAttribute("href")
1126
+ if (href !== null) {
1127
+ state.href = href
1128
+ }
1129
+ }
1130
+ // exit を先に積み、子は末子から積む (pop 順 = 文書順 — 再帰版と同一の訪問順)
1131
+ stack.push({ kind: "exit", element, before: state.text.length, block })
1132
+ for (let child = element.lastChild; child !== null; child = child.previousSibling) {
1133
+ stack.push({ kind: "enter", node: child })
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Applies every whole-cell run of the walked state on top of the cell declarations, in capture
1140
+ * (inner → outer) order. A run is whole-cell exactly when nothing outside its window is
1141
+ * non-whitespace — equivalent to the whitespace-normalised text equality `key(run) === key(cell)`
1142
+ * (whitespace collapsing never changes the non-whitespace characters, and a run's text is a
1143
+ * contiguous slice of the cell text), but O(1) per run instead of re-materialising and
1144
+ * re-scanning up to the whole cell text per run: a hostile cell nesting thousands of run tags
1145
+ * around a long string must not cost O(depth × text length) inside the paste handler. An
1146
+ * all-whitespace cell has no whole-cell run. `String.prototype.trim` removes exactly the regex
1147
+ * `\s` class, so the window test matches the normalised-key semantics character for character.
1148
+ * 走査済み状態の全「全体 run」をセル宣言の上へ捕捉順 (内 → 外) に適用する。全体 run とは
1149
+ * 「窓の外に非空白が 1 文字も無い」run のことで、空白正規化キー一致 `key(run) === key(cell)` と
1150
+ * 等価 (空白畳みは非空白文字を増減させず、run の文字列はセル文字列の連続 slice) — ただし run 毎に
1151
+ * セル全文級の文字列を実体化 / 再走査せず 1 run O(1) で判定する: 長文を数千の run タグで包んだ
1152
+ * 敵対セルが paste ハンドラ内で O(深さ × 文字数) の同期費用になってはならない。空白だけのセルに
1153
+ * 全体 run は無い。`String.prototype.trim` の除去集合は正規表現 `\s` と同一なので、窓判定は
1154
+ * 正規化キー意味論と文字単位で一致する。
1155
+ */
1156
+ const applyWholeCellRuns = (state: WalkState, declarations: Declarations, flags: RunFlags, rules: StyleRules): void => {
1157
+ // 非空白の範囲は 1 回だけ求める: [first, trimmedLength) — trimmedLength = 最後の非空白の直後
1158
+ const trimmedLength = state.text.trimEnd().length
1159
+ if (trimmedLength === 0) {
1160
+ return
1161
+ }
1162
+ const first = state.text.length - state.text.trimStart().length
1163
+ for (const run of state.runs) {
1164
+ // セル全文 (空白を除く) を窓に含む run だけがセルを書式付けする (部分 run は無視)
1165
+ if (run.before <= first && run.end >= trimmedLength) {
1166
+ applyRun(run.element, declarations, flags, rules)
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Finishes the walked text per mode: U+00A0 → space everywhere; `pre` verbatim; `pre-line` and
1173
+ * collapse split into lines, runs of spaces / tabs → one space, each line trimmed; collapse also
1174
+ * drops leading / trailing empty lines.
1175
+ * 走査済み文字列をモード毎に仕上げる: U+00A0 → 空白 (全モード)、`pre` は原文、`pre-line` と collapse は
1176
+ * 行毎に空白 / タブの連なりを 1 空白へ畳み trim、collapse は先頭 / 末尾の空行も落とす。
1177
+ */
1178
+ const finishText = (raw: string, mode: WhiteSpaceMode): string => {
1179
+ const spaced = raw.replaceAll(NBSP, " ")
1180
+ if (mode === "pre") {
1181
+ return spaced
1182
+ }
1183
+ const lines = spaced.split("\n").map((line) => line.replace(/[ \t\f\r]+/g, " ").trim())
1184
+ if (mode === "pre-line") {
1185
+ return lines.join("\n")
1186
+ }
1187
+ let first = 0
1188
+ while (first < lines.length && lines[first] === "") {
1189
+ first += 1
1190
+ }
1191
+ let last = lines.length
1192
+ while (last > first && lines[last - 1] === "") {
1193
+ last -= 1
1194
+ }
1195
+ return lines.slice(first, last).join("\n")
1196
+ }
1197
+
1198
+ /**
1199
+ * Applies a whole-cell run element on top of the cell declarations: tag semantics (`b` /
1200
+ * `strong` bold, `i` / `em` italic, `u` underline, `s` / `strike` / `del` line-through, `font`
1201
+ * `color` / `face` attributes) then the run's own class rules and inline style (later wins).
1202
+ * セル宣言の上へ全体 run 要素を適用: タグ意味論 (`b` / `strong` 太字、`i` / `em` 斜体、`u` 下線、
1203
+ * `s` / `strike` / `del` 取消線、`font` の `color` / `face` 属性)、次に run 自身のクラス規則と
1204
+ * インライン style (後勝ち)。
1205
+ */
1206
+ const applyRun = (run: Element, declarations: Declarations, flags: RunFlags, rules: StyleRules): void => {
1207
+ const tag = run.localName
1208
+ if (tag === "b" || tag === "strong") {
1209
+ declarations.set("font-weight", "bold")
1210
+ } else if (tag === "i" || tag === "em") {
1211
+ declarations.set("font-style", "italic")
1212
+ } else if (tag === "u") {
1213
+ flags.underline = true
1214
+ } else if (tag === "s" || tag === "strike" || tag === "del") {
1215
+ flags.strikethrough = true
1216
+ } else if (tag === "font") {
1217
+ const color = run.getAttribute("color")
1218
+ const face = run.getAttribute("face")
1219
+ if (color !== null && color.trim() !== "") {
1220
+ declarations.set("color", color)
1221
+ }
1222
+ if (face !== null && face.trim() !== "") {
1223
+ declarations.set("font-family", face)
1224
+ }
1225
+ }
1226
+ mergeDeclarations(declarations, declarationsOf(run, rules))
1227
+ }
1228
+
1229
+ // ---------------------------------------------------------------------------------------------
1230
+ // Reader — values
1231
+ // reader — 値
1232
+ // ---------------------------------------------------------------------------------------------
1233
+
1234
+ /**
1235
+ * The typed text of a `data-sheets-value` JSON (object form `{"1":type,"<type>":payload}` or the
1236
+ * 2015 positional array): 2 string, 3 number (a serial → ISO under a date value format), 4
1237
+ * boolean, 5 error sub-code. Null for malformed JSON, an unknown type or a payload of the wrong
1238
+ * shape (a non-finite number, or a date serial outside the grammar's serial domain, included) —
1239
+ * the caller then uses the visible text.
1240
+ * `data-sheets-value` JSON (object 形か 2015 年の位置配列) の型付き文字列: 2 文字列、3 数値 (date
1241
+ * 書式下のシリアルは ISO へ)、4 真偽値、5 エラー副コード。不正 JSON / 未知の型 / 形違いの payload
1242
+ * (非有限や文法シリアル域外の date シリアルを含む) は null — 呼び手は可視文字列を使う。
1243
+ */
1244
+ const readSheetsValue = (json: string, value: CellValueFormat | undefined): string | null => {
1245
+ let parsed: unknown
1246
+ try {
1247
+ parsed = JSON.parse(json)
1248
+ } catch {
1249
+ // 不正 JSON は「型付き値なし」— 可視文字列へ (全域 fail-soft)
1250
+ return null
1251
+ }
1252
+ if (typeof parsed !== "object" || parsed === null) {
1253
+ return null
1254
+ }
1255
+ const record = parsed as Record<string, unknown>
1256
+ const type = record["1"]
1257
+ if (typeof type !== "number") {
1258
+ return null
1259
+ }
1260
+ const payload = record[String(type)]
1261
+ if (type === 2) {
1262
+ return typeof payload === "string" ? payload : null
1263
+ }
1264
+ if (type === 3) {
1265
+ if (typeof payload !== "number" || !Number.isFinite(payload)) {
1266
+ return null
1267
+ }
1268
+ return value !== undefined && value.type === "date" ? serialToIsoDate(payload) : String(payload)
1269
+ }
1270
+ if (type === 4) {
1271
+ return payload === 1 || payload === true ? "TRUE" : payload === 0 || payload === false ? "FALSE" : null
1272
+ }
1273
+ if (type === 5 && typeof payload === "object" && payload !== null) {
1274
+ const code = (payload as Record<string, unknown>)["1"]
1275
+ return typeof code === "number" && code >= 1 && code < SHEETS_ERROR_TEXT.length ? SHEETS_ERROR_TEXT[code] : null
1276
+ }
1277
+ return null
1278
+ }
1279
+
1280
+ /** The visible text as a value: canonicalised under a numeric value format, verbatim otherwise. / 可視文字列としての値: 数値書式下は正準化、他は原文。 */
1281
+ const displayValue = (text: string, value: CellValueFormat | undefined, separators: NumberSeparators): string => (value !== undefined && isNumericValueFormat(value) ? numberDisplayToCanonical(text, value, separators) : text)
1282
+
1283
+ /**
1284
+ * The value text of an Excel / generic cell: `x:str` (its value, or the display when bare),
1285
+ * `x:bool` (TRUE / FALSE), `x:err`, `x:num` (a strict decimal — a serial inside the grammar's
1286
+ * serial domain becomes ISO under a date format; outside it the display text is kept), else the
1287
+ * display text (canonicalised under a numeric format).
1288
+ * Excel / 汎用セルの値文字列: `x:str` (値、裸なら表示)、`x:bool` (TRUE / FALSE)、`x:err`、`x:num`
1289
+ * (厳密 10 進 — date 書式下は文法シリアル域内だけ ISO へ、域外は可視文字列を保つ)、無ければ表示
1290
+ * 文字列 (数値書式下は正準化)。
1291
+ */
1292
+ const excelValueText = (cell: Element, text: string, value: CellValueFormat | undefined, separators: NumberSeparators): string => {
1293
+ const str = cell.getAttribute("x:str")
1294
+ if (str !== null) {
1295
+ return str === "" ? text : str
1296
+ }
1297
+ const bool = cell.getAttribute("x:bool")
1298
+ if (bool !== null) {
1299
+ const upper = bool.toUpperCase()
1300
+ return upper === "TRUE" || upper === "FALSE" ? upper : text
1301
+ }
1302
+ const err = cell.getAttribute("x:err")
1303
+ if (err !== null) {
1304
+ return err === "" ? text : err
1305
+ }
1306
+ const num = cell.getAttribute("x:num")
1307
+ if (num !== null && num !== "" && parseNumber(num) !== null) {
1308
+ // parseNumber は有限の 10 進だけを通す — date 書式下ではシリアル
1309
+ if (value !== undefined && value.type === "date") {
1310
+ const iso = serialToIsoDate(Number(num))
1311
+ // 文法シリアル域外 (null) は変換せず可視文字列へ (下の displayValue は date 書式で原文を返す)
1312
+ if (iso !== null) {
1313
+ return iso
1314
+ }
1315
+ } else {
1316
+ return num
1317
+ }
1318
+ }
1319
+ return displayValue(text, value, separators)
1320
+ }
1321
+
1322
+ // ---------------------------------------------------------------------------------------------
1323
+ // Reader — cells, sizes, layout
1324
+ // reader — セル・寸法・配置
1325
+ // ---------------------------------------------------------------------------------------------
1326
+
1327
+ /** One parsed cell: the wire cell and its (possibly empty) format. / 解析済みセル 1 つ: wire セルと (空かもしれない) 書式。 */
1328
+ type ReadCell = { readonly cell: ClipCell; readonly format: CellFormat }
1329
+
1330
+ /**
1331
+ * Reads one `<td>` / `<th>` at (row, col): declarations (bgcolor < classes < inline), the text
1332
+ * walk, whole-cell runs applied, the CellFormat, then the kind-specific value / formula / link /
1333
+ * note carriers described in the fileoverview.
1334
+ * (row, col) の `<td>` / `<th>` を 1 つ読む: 宣言 (bgcolor < クラス < インライン)、文字列走査、全体
1335
+ * run の適用、CellFormat、次に fileoverview の kind 毎の値 / 数式 / リンク / ノート運搬体。
1336
+ */
1337
+ const readCell = (element: Element, row: number, col: number, context: ReadContext): ReadCell => {
1338
+ const declarations = declarationsOf(element, context.rules)
1339
+ const flags: RunFlags = { underline: false, strikethrough: false }
1340
+ const state: WalkState = { text: "", runs: [], href: null, mode: whiteSpaceModeOf(declarations) }
1341
+ for (const child of nodeChildren(element)) {
1342
+ walkCell(child, state)
1343
+ }
1344
+ applyWholeCellRuns(state, declarations, flags, context.rules)
1345
+ const text = finishText(state.text, state.mode)
1346
+ const format = formatFromDeclarations(declarations, flags, context.kind)
1347
+ const cell: ClipCell = { t: "" }
1348
+ if (context.kind === "sheets") {
1349
+ const numberFormat = element.getAttribute("data-sheets-numberformat")
1350
+ const value = numberFormat === null ? undefined : fromSheetsNumberFormat(numberFormat, context.options)
1351
+ if (value !== undefined) {
1352
+ format.value = value
1353
+ }
1354
+ const typedJson = element.getAttribute("data-sheets-value")
1355
+ const typed = typedJson === null ? null : readSheetsValue(typedJson, value)
1356
+ cell.t = typed === null ? displayValue(text, value, DEFAULT_HTML_SEPARATORS) : typed
1357
+ const formula = element.getAttribute("data-sheets-formula")
1358
+ if (formula?.startsWith("=")) {
1359
+ cell.f = formula
1360
+ }
1361
+ const hyperlink = element.getAttribute("data-sheets-hyperlink")
1362
+ // 属性が運搬体 (在れば <a> は見ない)、無ければ最初の <a href>
1363
+ const rawLink = hyperlink !== null ? hyperlink : state.href
1364
+ const link = rawLink === null ? null : normalizeHyperlink(rawLink)
1365
+ if (link !== null) {
1366
+ cell.link = link
1367
+ }
1368
+ const note = element.getAttribute("data-sheets-note")
1369
+ if (note !== null && note !== "") {
1370
+ cell.note = note
1371
+ }
1372
+ } else {
1373
+ const msoFormat = declarations.get("mso-number-format")
1374
+ const value = msoFormat === undefined ? undefined : fromExcelPattern(decodeMsoNumberFormat(stripCssQuotes(msoFormat)), context.options)
1375
+ if (value !== undefined) {
1376
+ format.value = value
1377
+ }
1378
+ cell.t = excelValueText(element, text, value, context.separators)
1379
+ const formula = element.getAttribute("x:fmla")
1380
+ if (formula?.startsWith("=")) {
1381
+ cell.f = toR1C1(formula, { row, col })
1382
+ }
1383
+ const link = state.href === null ? null : normalizeHyperlink(state.href)
1384
+ if (link !== null) {
1385
+ cell.link = link
1386
+ }
1387
+ }
1388
+ return { cell, format }
1389
+ }
1390
+
1391
+ /** HTML non-negative-integer reading of a span attribute clamped to [1, max] (missing / 0 / garbage ⇒ 1). / span 属性の HTML 非負整数読み ([1, max] へクランプ、欠落 / 0 / 不正は 1)。 */
1392
+ const spanOf = (element: Element, name: string, max: number): number => {
1393
+ const raw = element.getAttribute(name)
1394
+ if (raw === null) {
1395
+ return 1
1396
+ }
1397
+ const match = /^\s*\+?(\d+)/.exec(raw)
1398
+ if (match === null) {
1399
+ return 1
1400
+ }
1401
+ const n = Number(match[1])
1402
+ return n < 1 ? 1 : n > max ? max : n
1403
+ }
1404
+
1405
+ /** A CSS length in px from `Npx` / `Npt` (pt × 4 / 3, rounded), or null. / `Npx` / `Npt` (pt × 4 / 3、丸め) からの px 長、なければ null。 */
1406
+ const lengthPx = (value: string): number | null => {
1407
+ const match = /^(\d*\.?\d+)(px|pt)$/i.exec(value.trim())
1408
+ if (match === null) {
1409
+ return null
1410
+ }
1411
+ const n = Number(match[1])
1412
+ return match[2].toLowerCase() === "pt" ? Math.round((n * 4) / 3) : Math.round(n)
1413
+ }
1414
+
1415
+ /** An HTML dimension attribute (`width=69`) in px, or null. / HTML の寸法属性 (`width=69`) の px、なければ null。 */
1416
+ const attributePx = (element: Element, name: string): number | null => {
1417
+ const raw = element.getAttribute(name)
1418
+ if (raw === null) {
1419
+ return null
1420
+ }
1421
+ const match = /^\s*\+?(\d*\.?\d+)/.exec(raw)
1422
+ return match === null ? null : Math.round(Number(match[1]))
1423
+ }
1424
+
1425
+ /**
1426
+ * A track size in px: `display:none` ⇒ 0; then the style length (`height:36.5pt` is Excel's native
1427
+ * row height, `height:21px` Sheets'); then the HTML attribute (`<col width=69>` is Excel's native
1428
+ * column width). Null when nothing declares it. Every declared size is clamped to
1429
+ * MAX_TRACK_SIZE_PX — foreign HTML is a trust boundary and a hostile width must not wedge the
1430
+ * non-undoable grid geometry (same bound as the resize gate and the wire validator).
1431
+ * トラック寸法 (px): `display:none` は 0、次に style の長さ (`height:36.5pt` は Excel の行高の
1432
+ * 原値、`height:21px` は Sheets)、次に HTML 属性 (`<col width=69>` は Excel の列幅の原値)。無ければ null。
1433
+ * 宣言済み寸法は全て MAX_TRACK_SIZE_PX へクランプ (外来 HTML は信頼境界 — 敵対幅に非 undo の
1434
+ * 格子幾何を破壊させない。リサイズゲート / wire 検証器と同一上限)。
1435
+ */
1436
+ const trackSize = (element: Element, property: "width" | "height"): number | null => {
1437
+ const style = element.getAttribute("style")
1438
+ const declarations = style === null ? null : tokenizeInlineStyle(style)
1439
+ if (declarations !== null) {
1440
+ const display = declarations.get("display")
1441
+ if (display !== undefined && display.toLowerCase() === "none") {
1442
+ return 0
1443
+ }
1444
+ const length = declarations.get(property)
1445
+ const px = length === undefined ? null : lengthPx(length)
1446
+ if (px !== null) {
1447
+ return Math.min(px, MAX_TRACK_SIZE_PX)
1448
+ }
1449
+ }
1450
+ const attribute = attributePx(element, property)
1451
+ return attribute === null ? null : Math.min(attribute, MAX_TRACK_SIZE_PX)
1452
+ }
1453
+
1454
+ /**
1455
+ * Column widths declared by `<col>` elements (direct or in `<colgroup>`), `span` expanded, read
1456
+ * only up to `cols` entries (a run of `<col span=1000>` never materialises beyond the cell columns).
1457
+ * `<col>` (直下 / `<colgroup>` 内) が宣言する列幅 (`span` 展開済み)。`cols` 件までしか読まない
1458
+ * (`<col span=1000>` の連打はセル列数を超えて実体化しない)。
1459
+ */
1460
+ const readColumnWidths = (table: Element, cols: number): (number | null)[] => {
1461
+ const widths: (number | null)[] = []
1462
+ const pushCol = (col: Element): void => {
1463
+ const size = trackSize(col, "width")
1464
+ for (let k = spanOf(col, "span", HTML_COLSPAN_MAX); k > 0 && widths.length < cols; k -= 1) {
1465
+ widths.push(size)
1466
+ }
1467
+ }
1468
+ for (const child of elementChildren(table)) {
1469
+ // 列数ぶん揃ったら以後の宣言は読まない
1470
+ if (widths.length >= cols) {
1471
+ break
1472
+ }
1473
+ if (child.localName === "col") {
1474
+ pushCol(child)
1475
+ } else if (child.localName === "colgroup") {
1476
+ for (const col of elementChildren(child)) {
1477
+ if (col.localName === "col" && widths.length < cols) {
1478
+ pushCol(col)
1479
+ }
1480
+ }
1481
+ }
1482
+ }
1483
+ return widths
1484
+ }
1485
+
1486
+ /**
1487
+ * The element children of a parent through the sibling links (`firstElementChild` /
1488
+ * `nextElementSibling`) — O(1) a step in every DOM. A live `HTMLCollection` (`children`) resolves
1489
+ * its index on every access, which jsdom answers in O(index): walking a 65,534-row table through
1490
+ * it is quadratic (minutes), through the links it is milliseconds.
1491
+ * 親の要素子を兄弟リンク (`firstElementChild` / `nextElementSibling`) で列挙する — どの DOM でも
1492
+ * 1 歩 O(1)。live な `HTMLCollection` (`children`) は参照毎に添字解決され jsdom では O(添字):
1493
+ * 65,534 行の表を添字で歩くと二次 (分単位)、リンクならミリ秒。
1494
+ */
1495
+ function* elementChildren(parent: Element): Generator<Element, undefined, undefined> {
1496
+ for (let child = parent.firstElementChild; child !== null; child = child.nextElementSibling) {
1497
+ yield child
1498
+ }
1499
+ }
1500
+
1501
+ /** The child nodes (text nodes included) of a node through `firstChild` / `nextSibling` — the `NodeList` counterpart of `elementChildren`. / ノードの子ノード (テキストノード含む) を `firstChild` / `nextSibling` で列挙する — `elementChildren` の `NodeList` 版。 */
1502
+ function* nodeChildren(parent: Node): Generator<Node, undefined, undefined> {
1503
+ for (let child = parent.firstChild; child !== null; child = child.nextSibling) {
1504
+ yield child
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * The rows of a table in HTML `rows` order — `thead` rows, then direct / `tbody` rows in tree
1510
+ * order, then `tfoot` — yielded lazily (three passes over the table's direct children, one per
1511
+ * section kind) so the reader that stops at its `maxCells` bound never walks the rows behind it.
1512
+ * 表の行を HTML の `rows` 順 — `thead`、直下 / `tbody` の行 (木順)、`tfoot` — で遅延列挙する
1513
+ * (表の直下の子を節種別ごとに 3 回通る): `maxCells` の下界で止まる reader はその先の行を歩かない。
1514
+ */
1515
+ function* tableRows(table: Element): Generator<Element, undefined, undefined> {
1516
+ for (const pass of ["thead", "tbody", "tfoot"] as const) {
1517
+ for (const child of elementChildren(table)) {
1518
+ const tag = child.localName
1519
+ if (pass === "tbody" && tag === "tr") {
1520
+ // 直下の tr は tbody の行と同じ通過で木順に混ぜる
1521
+ yield child
1522
+ } else if (tag === pass) {
1523
+ for (const row of elementChildren(child)) {
1524
+ if (row.localName === "tr") {
1525
+ yield row
1526
+ }
1527
+ }
1528
+ }
1529
+ }
1530
+ }
1531
+ }
1532
+
1533
+ /** A fresh blank cell (covered by a merge or padding a ragged row). / 新規の空白セル (結合に覆われた / ラグ行の詰め物)。 */
1534
+ const blankCell = (): ClipCell => ({ t: "" })
1535
+
1536
+ /**
1537
+ * The outcome of `parseHtmlTable`: the payload of the first table; `limit` when the table would
1538
+ * materialise more than the caller's `maxCells` (`cells` = the lower bound of `rows × cols` at
1539
+ * which the reader stopped — already above the cap); `none` when there is no table, no row or no
1540
+ * cell column.
1541
+ * `parseHtmlTable` の結果: 最初の表の payload。`limit` は表が呼び手の `maxCells` を超えて実体化する
1542
+ * とき (`cells` = reader が止まった時点の `rows × cols` の下界 — 既に上限超)。`none` は表 / 行 /
1543
+ * セル列が無いとき。
1544
+ */
1545
+ export type HtmlTableResult = { readonly outcome: "payload"; readonly payload: ClipPayload } | { readonly outcome: "limit"; readonly cells: number } | { readonly outcome: "none" }
1546
+
1547
+ /**
1548
+ * Reads the FIRST table of a parsed document into a payload (see the fileoverview reader
1549
+ * contract), stopping with `limit` as soon as the lower bound of the final `rows × cols` exceeds
1550
+ * `maxCells` — checked BEFORE each allocation, so the reader never holds more than `maxCells`
1551
+ * cells; the rows come lazily from `tableRows`, so the stop also ends the row walk. `none` when
1552
+ * there is no table row or no cell column.
1553
+ * 解析済み文書の最初の表を payload へ読む (fileoverview の reader 契約)。最終 `rows × cols` の
1554
+ * 下界が `maxCells` を超えた時点で `limit` — 各実体化の**前**に検査するので reader が持つセルは
1555
+ * `maxCells` を超えない。行は `tableRows` の遅延列挙なので、停止は行の走査も終える。行がない /
1556
+ * セル列がないときは `none`。
1557
+ */
1558
+ const readTable = (table: Element, context: ReadContext, maxCells: number): HtmlTableResult => {
1559
+ const interner = createFormatInterner()
1560
+ const matrix: (ClipCell | undefined)[][] = []
1561
+ const heights: (number | null)[] = []
1562
+ // 上の行の rowspan が各列を何行ぶん覆っているか
1563
+ const pending: number[] = []
1564
+ let cols = 0
1565
+ // 行は遅延列挙: 下界超えの return で以後の行は訪れない
1566
+ for (const rowElement of tableRows(table)) {
1567
+ const r = matrix.length
1568
+ // 行の開始: 下界 (r + 1) × max(cols, 1) が上限を超えるなら持越し空白の実体化前に止める。
1569
+ // max(…, 1) はセル未出現 (cols = 0) の <tr> 爆弾でも行数だけで走査を上限に束ねるため —
1570
+ // 列を 1 つでも得れば上限内で終われない表は同じ下界で止まる (セル無し表は none → limit の
1571
+ // 拒否同士の置換で、行数が上限内の表は挙動不変)
1572
+ const rowBound = (r + 1) * Math.max(cols, 1)
1573
+ if (rowBound > maxCells) {
1574
+ return { outcome: "limit", cells: rowBound }
1575
+ }
1576
+ const row: (ClipCell | undefined)[] = []
1577
+ for (let c = 0; c < pending.length; c += 1) {
1578
+ if (pending[c] > 0) {
1579
+ row[c] = blankCell()
1580
+ pending[c] -= 1
1581
+ }
1582
+ }
1583
+ let cursor = 0
1584
+ for (const child of elementChildren(rowElement)) {
1585
+ const tag = child.localName
1586
+ if (tag !== "td" && tag !== "th") {
1587
+ continue
1588
+ }
1589
+ // 最初の空き列へ置く (HTML 表モデル)
1590
+ while (row[cursor] !== undefined) {
1591
+ cursor += 1
1592
+ }
1593
+ const colspan = spanOf(child, "colspan", HTML_COLSPAN_MAX)
1594
+ const rowspan = spanOf(child, "rowspan", HTML_ROWSPAN_MAX)
1595
+ // このセルを置いた後の下界 (r + 1) × max(cols, 幅) を置く前に検査する
1596
+ const bound = (r + 1) * Math.max(cols, cursor + colspan)
1597
+ if (bound > maxCells) {
1598
+ return { outcome: "limit", cells: bound }
1599
+ }
1600
+ const read = readCell(child, r, cursor, context)
1601
+ const fmt = interner.intern(read.format)
1602
+ if (fmt !== undefined) {
1603
+ read.cell.fmt = fmt
1604
+ }
1605
+ row[cursor] = read.cell
1606
+ for (let k = 1; k < colspan; k += 1) {
1607
+ if (row[cursor + k] === undefined) {
1608
+ row[cursor + k] = blankCell()
1609
+ }
1610
+ }
1611
+ if (rowspan > 1) {
1612
+ for (let k = 0; k < colspan; k += 1) {
1613
+ const carry = rowspan - 1
1614
+ const index = cursor + k
1615
+ pending[index] = pending[index] === undefined || pending[index] < carry ? carry : pending[index]
1616
+ }
1617
+ }
1618
+ cursor += colspan
1619
+ }
1620
+ if (row.length > cols) {
1621
+ cols = row.length
1622
+ }
1623
+ matrix.push(row)
1624
+ heights.push(trackSize(rowElement, "height"))
1625
+ }
1626
+ if (matrix.length === 0 || cols === 0) {
1627
+ return { outcome: "none" }
1628
+ }
1629
+ // 矩形化: 穴とラグ行の不足分を空白で埋める
1630
+ const cells: ClipCell[][] = matrix.map((row) => {
1631
+ const filled: ClipCell[] = []
1632
+ for (let c = 0; c < cols; c += 1) {
1633
+ const cell = row[c]
1634
+ filled.push(cell === undefined ? blankCell() : cell)
1635
+ }
1636
+ return filled
1637
+ })
1638
+ const widths = readColumnWidths(table, cols)
1639
+ const colWidths = widths.length === cols && widths.every((width) => width !== null) ? widths.map((width) => (width === null ? 0 : width)) : undefined
1640
+ const rowHeights = heights.every((height) => height !== null) ? heights.map((height) => (height === null ? 0 : height)) : undefined
1641
+ const payload: ClipPayload = {
1642
+ v: 1,
1643
+ rows: matrix.length,
1644
+ cols,
1645
+ clipId: newClipId(),
1646
+ formats: [...interner.entries],
1647
+ validations: [],
1648
+ cells,
1649
+ ...(colWidths === undefined ? {} : { colWidths }),
1650
+ ...(rowHeights === undefined ? {} : { rowHeights }),
1651
+ wholeRows: false,
1652
+ wholeCols: false,
1653
+ conditionalFormats: [],
1654
+ }
1655
+ return { outcome: "payload", payload }
1656
+ }
1657
+
1658
+ /**
1659
+ * Parses an Excel / Sheets / generic HTML clipboard string into a ClipPayload (the fileoverview
1660
+ * reader contract). `kind` is the marker-detected source (`detectClipSource`), `options` the host's
1661
+ * declared currency / locale for number-format decoding, `maxCells` the ADR-12 cap the reader
1662
+ * itself enforces (a positive integer — anything else is a RangeError, an API surface, thrown
1663
+ * before the DOMParser is built). TOTAL fail-soft on the markup: any string yields `payload`,
1664
+ * `limit` or `none` — a blow-up of the ENVIRONMENT's own parser (an engine recursion limit on
1665
+ * pathologically deep nesting) resolves `none` too; a payload is always valid for
1666
+ * `serializeClipPayload`. Callers build the
1667
+ * DOMParser only under the ADR-12 estimate and re-gate the payload's `rows × cols` with their
1668
+ * tiling.
1669
+ * Excel / Sheets / 汎用 HTML のクリップボード文字列を ClipPayload へ解析 (fileoverview の reader 契約)。
1670
+ * `kind` はマーカー検出済みソース (`detectClipSource`)、`options` は数値書式復号用のホスト宣言
1671
+ * (通貨 / locale)、`maxCells` は reader 自身が守る ADR-12 上限 (正整数 — 他は DOMParser 構築前に
1672
+ * RangeError、API 面)。マークアップに対しては全域 fail-soft: 任意の文字列に `payload` / `limit` /
1673
+ * `none` — 環境の parser 自身の破綻 (病的な入れ子深さでのエンジン再帰限界) も `none` に畳む。payload は
1674
+ * 常に `serializeClipPayload` に有効。呼び手は ADR-12 見積もり下でだけ DOMParser
1675
+ * を作り、payload の `rows × cols` をタイル込みで再ゲートする。
1676
+ */
1677
+ export const parseHtmlTable = (html: string, kind: HtmlTableKind, options: NumberFormatDecodeOptions, maxCells: number): HtmlTableResult => {
1678
+ if (!Number.isInteger(maxCells) || maxCells <= 0) {
1679
+ throw new RangeError(`[spreadsheet] parseHtmlTable requires a positive integer maxCells, received ${maxCells}`)
1680
+ }
1681
+ let doc: Document
1682
+ try {
1683
+ doc = new DOMParser().parseFromString(html, "text/html")
1684
+ } catch {
1685
+ // 環境の parser 自身の破綻 (深さ等のエンジン限界) も全域 fail-soft 契約に畳む — 読めない表
1686
+ return { outcome: "none" }
1687
+ }
1688
+ const table = doc.querySelector("table")
1689
+ if (table === null) {
1690
+ return { outcome: "none" }
1691
+ }
1692
+ const rules = readStyleRules(doc)
1693
+ return readTable(table, { kind, rules, options, separators: rules.separators }, maxCells)
1694
+ }