@aiquants/spreadsheet 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1103 -0
- package/LICENSE.md +155 -0
- package/README.md +817 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +1960 -0
- package/dist/index.d.ts +1960 -0
- package/dist/index.js +7711 -0
- package/dist/styles/spreadsheet.css +2 -0
- package/dist/styles/spreadsheet.standalone.css +3 -0
- package/package.json +94 -0
- package/src/Spreadsheet.tsx +2313 -0
- package/src/clipboard/clipPayload.ts +1242 -0
- package/src/clipboard/fixtures/excel.fixture.ts +301 -0
- package/src/clipboard/fixtures/html.fixture.ts +90 -0
- package/src/clipboard/fixtures/sheets.fixture.ts +83 -0
- package/src/clipboard/htmlInterop.ts +1694 -0
- package/src/clipboard/numberFormatCodec.ts +870 -0
- package/src/clipboard/pasteSpecial.ts +608 -0
- package/src/clipboard/tiling.ts +370 -0
- package/src/clipboard/tsv.ts +186 -0
- package/src/clipboard/useClipboard.ts +1154 -0
- package/src/commands/selfCalc.ts +279 -0
- package/src/commands/useCommands.ts +946 -0
- package/src/editor/useCellEditor.ts +708 -0
- package/src/grid/CellView.tsx +51 -0
- package/src/grid/GridBody.tsx +489 -0
- package/src/header/ColumnHeader.tsx +167 -0
- package/src/header/RowHeader.tsx +144 -0
- package/src/header/columnName.ts +31 -0
- package/src/header/resizeGates.ts +146 -0
- package/src/header/resizeStrips.ts +79 -0
- package/src/header/useHeaderResize.ts +598 -0
- package/src/header/useHeaderSelection.ts +454 -0
- package/src/index.ts +122 -0
- package/src/keyboard/compositionGuard.ts +53 -0
- package/src/keyboard/ensureCellVisible.ts +77 -0
- package/src/keyboard/shortcuts.ts +718 -0
- package/src/keyboard/useGridKeyboard.ts +940 -0
- package/src/model/axisModel.ts +186 -0
- package/src/model/cellAnnotations.ts +92 -0
- package/src/model/cellCoord.ts +114 -0
- package/src/model/cellFormat.ts +562 -0
- package/src/model/cellPaint.ts +537 -0
- package/src/model/conditionalFormat.ts +805 -0
- package/src/model/dataAdapter.ts +478 -0
- package/src/model/dataValidation.ts +542 -0
- package/src/model/formulaRefs.ts +984 -0
- package/src/model/frozenPartition.ts +100 -0
- package/src/model/labels.ts +228 -0
- package/src/model/viewStore.ts +124 -0
- package/src/overlay/SelectionOverlay.tsx +1425 -0
- package/src/overlay/useFillHandle.ts +432 -0
- package/src/popover/CommentPopover.tsx +109 -0
- package/src/popover/LinkPopover.tsx +108 -0
- package/src/popover/ListPopover.tsx +161 -0
- package/src/popover/MessagePopover.tsx +36 -0
- package/src/popover/NotePopover.tsx +106 -0
- package/src/popover/NoteTip.tsx +26 -0
- package/src/popover/PopoverLayer.tsx +210 -0
- package/src/popover/placement.ts +138 -0
- package/src/popover/usePopovers.ts +642 -0
- package/src/selection/selectionState.ts +239 -0
- package/src/selection/useAutoScroller.ts +226 -0
- package/src/selection/usePointDrag.ts +273 -0
- package/src/selection/useSelectionEngine.ts +687 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/spreadsheet.css +1029 -0
- package/src/styles/standalone.entry.css +9 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CellValueFormat ⇄ Excel / Google Sheets number-format pattern codec (v0.8 — the
|
|
3
|
+
* `text/html` interop channel of ADR-14). Pure, React-free, and TOTAL: every decoder is fail-soft
|
|
4
|
+
* (an unrepresentable or malformed pattern ⇒ `undefined`, never a throw) and every encoder returns
|
|
5
|
+
* `null` for a value format Excel cannot express (the writer then omits the property).
|
|
6
|
+
*
|
|
7
|
+
* Contract (each clause is mutation-gated in numberFormatCodec.spec.ts):
|
|
8
|
+
* - Six value-format types map to Excel patterns: number → `0.00` / `#,##0.00`, percent → `0.0%`
|
|
9
|
+
* (`scale:false` → the literal form `0.0"%"`, which Excel renders WITHOUT ×100), currency →
|
|
10
|
+
* `[$¥-411]#,##0` through a closed LCID table (USD in en-US → the native `"$"#,##0.00`),
|
|
11
|
+
* scientific → `0.00E+00`, date → lower-case Excel tokens (`yyyy/MM/dd` ⇄ `yyyy/mm/dd`, the
|
|
12
|
+
* month-vs-minute `mm` resolved by Excel's neighbour rule and verified by a decode round trip),
|
|
13
|
+
* text → `@`.
|
|
14
|
+
* - The LCID table is the SINGLE source for currency identity: a `[$sym-lcid]` token resolves
|
|
15
|
+
* currency AND locale deterministically (an unknown token ⇒ `undefined`); a bare symbol (no LCID)
|
|
16
|
+
* is only ever the host's DECLARED currency (`opts.currency`) — the codec never infers a code
|
|
17
|
+
* from a symbol glyph.
|
|
18
|
+
* - Excel named formats (`General` ⇒ no format, `Fixed`, `Standard`, `Percent`, `Scientific`,
|
|
19
|
+
* `Short Date`, …) and Sheets `data-sheets-numberformat` JSON (`{"1":type,"2":pattern}`) decode
|
|
20
|
+
* through the same pattern engine.
|
|
21
|
+
* - `decodeMsoNumberFormat` undoes Excel's CSS-string escaping (`\0022` → `"`, `\\` → `\`, `\X` → X)
|
|
22
|
+
* and nothing else, so the pattern engine sees genuine Excel syntax.
|
|
23
|
+
* - `numberDisplayToCanonical` recovers the raw decimal string from a DISPLAYED number under the
|
|
24
|
+
* table's declared separators by pure string math (percent ÷100 and scientific expansion are
|
|
25
|
+
* decimal-point shifts, never floating point); anything that is not a recognisable rendering
|
|
26
|
+
* returns the display text unchanged.
|
|
27
|
+
*
|
|
28
|
+
* CellValueFormat ⇄ Excel / Google Sheets 数値書式パターンのコーデック (v0.8 — ADR-14 の
|
|
29
|
+
* `text/html` 相互運用経路)。純関数・React 非依存・全域 fail-soft (表現不能 / 不正は `undefined`、
|
|
30
|
+
* throw しない。エンコード不能は `null` で writer が省略)。6 型 ⇄ パターン、閉じた LCID 表が通貨同一性の
|
|
31
|
+
* 単一情報源 (`[$記号-LCID]` は通貨 + locale を決定的に解決、裸の記号はホスト宣言 `opts.currency`
|
|
32
|
+
* のみ)、Excel 名前付き書式と Sheets JSON も同じエンジンで復号、`decodeMsoNumberFormat` は CSS
|
|
33
|
+
* エスケープ解除だけ、`numberDisplayToCanonical` は表示文字列から素の 10 進を文字列演算で復元。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { CellValueFormat } from "../model/cellFormat.ts"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Host declarations consulted while decoding: `currency` = the code a BARE currency symbol
|
|
40
|
+
* denotes (undeclared ⇒ such patterns are dropped), `locale` = the rendering locale stamped on
|
|
41
|
+
* decoded numeric formats (an LCID-tagged currency carries its own locale instead).
|
|
42
|
+
* 復号時に参照するホスト宣言: `currency` = 裸の通貨記号が指す通貨コード (未宣言なら該当パターンは
|
|
43
|
+
* 省略)、`locale` = 復号した数値書式へ刻む描画 locale (LCID 付き通貨は自身の locale を持つ)。
|
|
44
|
+
*/
|
|
45
|
+
export type NumberFormatDecodeOptions = { readonly currency?: string; readonly locale?: string }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The separators a displayed number was rendered with (`group` may be empty = no grouping ever
|
|
49
|
+
* appears; `decimal` is required and must differ from `group`).
|
|
50
|
+
* 表示数値に使われた区切り (`group` は空可 = 桁区切りなし、`decimal` は必須で `group` と異なること)。
|
|
51
|
+
*/
|
|
52
|
+
export type NumberSeparators = { readonly group: string; readonly decimal: string }
|
|
53
|
+
|
|
54
|
+
/** One LCID-table row: ISO code, Excel symbol, hex LCID (no leading zeros, lower case), BCP-47 locale, ISO minor-unit digits. / LCID 表の 1 行。 */
|
|
55
|
+
type CurrencyRow = { readonly code: string; readonly symbol: string; readonly lcid: string; readonly locale: string; readonly digits: number }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The closed LCID table. The FIRST row of each code is its home locale, which the encoder emits;
|
|
59
|
+
* every row decodes. Rows are static ISO 4217 / Windows-LCID facts, not runtime probes.
|
|
60
|
+
* 閉じた LCID 表。各通貨の先頭行がホーム locale (エンコードが使用)、全行が復号対象。静的事実のみ。
|
|
61
|
+
*/
|
|
62
|
+
const CURRENCY_TABLE: readonly CurrencyRow[] = [
|
|
63
|
+
{ code: "JPY", symbol: "¥", lcid: "411", locale: "ja-JP", digits: 0 },
|
|
64
|
+
{ code: "USD", symbol: "$", lcid: "409", locale: "en-US", digits: 2 },
|
|
65
|
+
{ code: "EUR", symbol: "€", lcid: "407", locale: "de-DE", digits: 2 },
|
|
66
|
+
{ code: "GBP", symbol: "£", lcid: "809", locale: "en-GB", digits: 2 },
|
|
67
|
+
{ code: "CNY", symbol: "¥", lcid: "804", locale: "zh-CN", digits: 2 },
|
|
68
|
+
{ code: "KRW", symbol: "₩", lcid: "412", locale: "ko-KR", digits: 0 },
|
|
69
|
+
{ code: "AUD", symbol: "$", lcid: "c09", locale: "en-AU", digits: 2 },
|
|
70
|
+
{ code: "CAD", symbol: "$", lcid: "1009", locale: "en-CA", digits: 2 },
|
|
71
|
+
{ code: "CHF", symbol: "CHF", lcid: "807", locale: "de-CH", digits: 2 },
|
|
72
|
+
{ code: "INR", symbol: "₹", lcid: "4009", locale: "en-IN", digits: 2 },
|
|
73
|
+
{ code: "HKD", symbol: "HK$", lcid: "c04", locale: "zh-HK", digits: 2 },
|
|
74
|
+
{ code: "SGD", symbol: "$", lcid: "1004", locale: "zh-SG", digits: 2 },
|
|
75
|
+
{ code: "TWD", symbol: "NT$", lcid: "404", locale: "zh-TW", digits: 2 },
|
|
76
|
+
{ code: "EUR", symbol: "€", lcid: "40c", locale: "fr-FR", digits: 2 },
|
|
77
|
+
{ code: "EUR", symbol: "€", lcid: "410", locale: "it-IT", digits: 2 },
|
|
78
|
+
{ code: "EUR", symbol: "€", lcid: "c0a", locale: "es-ES", digits: 2 },
|
|
79
|
+
{ code: "EUR", symbol: "€", lcid: "413", locale: "nl-NL", digits: 2 },
|
|
80
|
+
{ code: "EUR", symbol: "€", lcid: "816", locale: "pt-PT", digits: 2 },
|
|
81
|
+
{ code: "EUR", symbol: "€", lcid: "1809", locale: "en-IE", digits: 2 },
|
|
82
|
+
{ code: "NZD", symbol: "$", lcid: "1409", locale: "en-NZ", digits: 2 },
|
|
83
|
+
{ code: "MXN", symbol: "$", lcid: "80a", locale: "es-MX", digits: 2 },
|
|
84
|
+
{ code: "BRL", symbol: "R$", lcid: "416", locale: "pt-BR", digits: 2 },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
/** Builds the canonical Excel currency token `[$sym-lcid]` for a row. / 行の正準通貨トークン。 */
|
|
88
|
+
const currencyToken = (symbol: string, lcid: string): string => `[$${symbol}-${lcid}]`
|
|
89
|
+
|
|
90
|
+
/** Canonical token → row (every table row). / 正準トークン → 行 (全行)。 */
|
|
91
|
+
const ROW_BY_TOKEN: ReadonlyMap<string, CurrencyRow> = new Map(CURRENCY_TABLE.map((row) => [currencyToken(row.symbol, row.lcid), row]))
|
|
92
|
+
|
|
93
|
+
/** ISO code → its home row (the first row per code). / 通貨コード → ホーム行 (各コードの先頭行)。 */
|
|
94
|
+
const HOME_ROW_BY_CODE: ReadonlyMap<string, CurrencyRow> = new Map(CURRENCY_TABLE.filter((row, index) => CURRENCY_TABLE.findIndex((other) => other.code === row.code) === index).map((row) => [row.code, row]))
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Excel currency token `[$¥-411]` → ISO code ("JPY"). Keys are canonical (lower-case hex without
|
|
98
|
+
* leading zeros); `fromExcelPattern` normalises incoming tokens before the lookup.
|
|
99
|
+
* Excel 通貨トークン → ISO コード。キーは正準形 (小文字 hex・先頭 0 なし)、復号側で正規化して照会。
|
|
100
|
+
*/
|
|
101
|
+
export const LCID_CURRENCY: ReadonlyMap<string, string> = new Map(CURRENCY_TABLE.map((row) => [currencyToken(row.symbol, row.lcid), row.code]))
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Literals that may stand for a currency without an LCID (table symbols, ISO codes, and the
|
|
105
|
+
* full-width yen that Japanese Excel emits). Any other literal is a unit / caption ⇒ unrepresentable.
|
|
106
|
+
* LCID なしで通貨を表し得るリテラル (表の記号・ISO コード・日本語 Excel の全角円)。他は単位 / 見出し。
|
|
107
|
+
*/
|
|
108
|
+
const CURRENCY_LITERALS: ReadonlySet<string> = new Set([...CURRENCY_TABLE.map((row) => row.symbol), ...CURRENCY_TABLE.map((row) => row.code), "¥"])
|
|
109
|
+
|
|
110
|
+
/** Fraction-digit ceiling the encoder accepts (the display engine's guaranteed Intl maximum). / エンコーダが受理する小数桁上限 (表示エンジンの Intl 保証最大)。 */
|
|
111
|
+
const MAX_DECIMALS = 20
|
|
112
|
+
|
|
113
|
+
/** The display engine's date pattern when `pattern` is absent (mirrors formatCellValue). / `pattern` 省略時の表示エンジン既定 (formatCellValue と同値)。 */
|
|
114
|
+
const DEFAULT_DATE_PATTERN = "yyyy/MM/dd"
|
|
115
|
+
|
|
116
|
+
/** The package date-token grammar (the exact alternation formatCellValue substitutes). / パッケージの日付トークン文法 (formatCellValue と同一の選択肢)。 */
|
|
117
|
+
const PACKAGE_DATE_TOKEN = /yyyy|MM|dd|HH|mm|ss/g
|
|
118
|
+
|
|
119
|
+
/** Characters Excel accepts unquoted inside a pattern (literal spacing / punctuation). / Excel が引用なしで受理する文字 (空白 / 句読点)。 */
|
|
120
|
+
const BARE_LITERAL_CHARS = " -+/():!^&'~{}<>="
|
|
121
|
+
|
|
122
|
+
/** Unquoted currency glyphs Excel renders literally. / Excel が引用なしでそのまま描画する通貨記号。 */
|
|
123
|
+
const BARE_CURRENCY_CHARS = "$¥€£₩₹¥"
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Undoes the CSS-string escaping Excel applies to `mso-number-format` values: `\` + exactly four
|
|
127
|
+
* hex digits → that code point (`\0022` → `"`), `\` + any other character → that character
|
|
128
|
+
* (`\\` → `\`, `\#` → `#`), a lone trailing `\` stays. Input is the property value WITHOUT its
|
|
129
|
+
* surrounding CSS quotes; the output is genuine Excel pattern syntax.
|
|
130
|
+
* Excel が `mso-number-format` 値に施す CSS 文字列エスケープを解除: `\` + hex 4 桁 → その符号位置、
|
|
131
|
+
* `\` + 他 1 文字 → その文字、末尾の孤立 `\` はそのまま。入力は外側の CSS 引用符を除いた値。
|
|
132
|
+
*/
|
|
133
|
+
export const decodeMsoNumberFormat = (raw: string): string => {
|
|
134
|
+
let out = ""
|
|
135
|
+
let i = 0
|
|
136
|
+
while (i < raw.length) {
|
|
137
|
+
const c = raw[i]
|
|
138
|
+
if (c !== "\\") {
|
|
139
|
+
out += c
|
|
140
|
+
i += 1
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
// 4 桁 hex エスケープ (\0022 = ") — Excel は常に 4 桁で書く
|
|
144
|
+
const hex = raw.slice(i + 1, i + 5)
|
|
145
|
+
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
146
|
+
out += String.fromCharCode(Number.parseInt(hex, 16))
|
|
147
|
+
i += 5
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
// 単一文字エスケープ (\\ → \, \# → #)。末尾の孤立 \ は素通し
|
|
151
|
+
if (i + 1 < raw.length) {
|
|
152
|
+
out += raw[i + 1]
|
|
153
|
+
i += 2
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
out += c
|
|
157
|
+
i += 1
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One lexical token of an Excel pattern section. / Excel パターン節の字句トークン。 */
|
|
163
|
+
type PatternToken =
|
|
164
|
+
| { readonly kind: "digits"; readonly text: string }
|
|
165
|
+
| { readonly kind: "literal"; readonly text: string }
|
|
166
|
+
| { readonly kind: "currency"; readonly symbol: string; readonly lcid: string | null }
|
|
167
|
+
| { readonly kind: "locale" }
|
|
168
|
+
| { readonly kind: "percent" }
|
|
169
|
+
| { readonly kind: "exponent" }
|
|
170
|
+
| { readonly kind: "date"; readonly text: string }
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns the first `;`-section of a pattern (the positive section) — the separator is honoured
|
|
174
|
+
* only outside quotes, brackets and escapes. The remaining sections (negative / zero / text) are
|
|
175
|
+
* dropped: the package renders negatives with a sign, never with colour or parentheses.
|
|
176
|
+
* パターンの先頭節 (正数節) を返す。区切りは引用 / 角括弧 / エスケープの外だけ有効。残りの節は捨てる。
|
|
177
|
+
*/
|
|
178
|
+
const firstSection = (pattern: string): string => {
|
|
179
|
+
let i = 0
|
|
180
|
+
while (i < pattern.length) {
|
|
181
|
+
const c = pattern[i]
|
|
182
|
+
if (c === '"') {
|
|
183
|
+
// 閉じ引用まで読み飛ばす (未終端なら末尾まで = 節全体)
|
|
184
|
+
const close = pattern.indexOf('"', i + 1)
|
|
185
|
+
i = close < 0 ? pattern.length : close + 1
|
|
186
|
+
} else if (c === "[") {
|
|
187
|
+
const close = pattern.indexOf("]", i + 1)
|
|
188
|
+
i = close < 0 ? pattern.length : close + 1
|
|
189
|
+
} else if (c === "\\" || c === "_" || c === "*") {
|
|
190
|
+
i += 2
|
|
191
|
+
} else if (c === ";") {
|
|
192
|
+
return pattern.slice(0, i)
|
|
193
|
+
} else {
|
|
194
|
+
i += 1
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return pattern
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Tokenises one section of GENUINE Excel syntax; `null` on any construct the codec does not
|
|
202
|
+
* model (unterminated quote, colour / condition / elapsed-time brackets, dangling escape,
|
|
203
|
+
* non-literal bare characters). Adjacent literals are merged.
|
|
204
|
+
* 真の Excel 構文の 1 節を字句解析。未終端引用 / 色・条件・経過時間の角括弧 / 末尾エスケープ /
|
|
205
|
+
* リテラル不可の裸文字は `null`。隣接リテラルは結合する。
|
|
206
|
+
*/
|
|
207
|
+
const tokenizeSection = (section: string): readonly PatternToken[] | null => {
|
|
208
|
+
const tokens: PatternToken[] = []
|
|
209
|
+
// 隣接するリテラルを 1 トークンへ結合 (前置 / 後置の判定を単純にする)
|
|
210
|
+
const pushLiteral = (text: string): void => {
|
|
211
|
+
const last = tokens[tokens.length - 1]
|
|
212
|
+
if (last !== undefined && last.kind === "literal") {
|
|
213
|
+
tokens[tokens.length - 1] = { kind: "literal", text: last.text + text }
|
|
214
|
+
} else {
|
|
215
|
+
tokens.push({ kind: "literal", text })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
let i = 0
|
|
219
|
+
while (i < section.length) {
|
|
220
|
+
const c = section[i]
|
|
221
|
+
if (c === '"') {
|
|
222
|
+
const close = section.indexOf('"', i + 1)
|
|
223
|
+
if (close < 0) {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
pushLiteral(section.slice(i + 1, close))
|
|
227
|
+
i = close + 1
|
|
228
|
+
} else if (c === "\\") {
|
|
229
|
+
// 真の Excel エスケープ: 次の 1 文字がリテラル
|
|
230
|
+
if (i + 1 >= section.length) {
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
pushLiteral(section[i + 1])
|
|
234
|
+
i += 2
|
|
235
|
+
} else if (c === "_" || c === "*") {
|
|
236
|
+
// 幅合わせ (_X) / 充填 (*X) は列内の詰め物だけ — パッケージは表現しないため落とす
|
|
237
|
+
if (i + 1 >= section.length) {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
i += 2
|
|
241
|
+
} else if (c === "[") {
|
|
242
|
+
const close = section.indexOf("]", i + 1)
|
|
243
|
+
const inner = close < 0 ? "" : section.slice(i + 1, close)
|
|
244
|
+
if (close < 0 || inner[0] !== "$") {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
// [$記号-LCID] / [$記号] / [$-LCID]: 最後の "-" が記号と LCID を分ける
|
|
248
|
+
const body = inner.slice(1)
|
|
249
|
+
const dash = body.lastIndexOf("-")
|
|
250
|
+
const symbol = dash < 0 ? body : body.slice(0, dash)
|
|
251
|
+
const lcidRaw = dash < 0 ? null : body.slice(dash + 1)
|
|
252
|
+
if (lcidRaw !== null && !/^[0-9a-fA-F]{1,8}$/.test(lcidRaw)) {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
if (symbol === "" && lcidRaw === null) {
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
const lcid = lcidRaw === null ? null : Number.parseInt(lcidRaw, 16).toString(16)
|
|
259
|
+
tokens.push(symbol === "" ? { kind: "locale" } : { kind: "currency", symbol, lcid })
|
|
260
|
+
i = close + 1
|
|
261
|
+
} else if (c === "%") {
|
|
262
|
+
tokens.push({ kind: "percent" })
|
|
263
|
+
i += 1
|
|
264
|
+
} else if ("#0?,.".includes(c)) {
|
|
265
|
+
let j = i
|
|
266
|
+
while (j < section.length && "#0?,.".includes(section[j])) {
|
|
267
|
+
j += 1
|
|
268
|
+
}
|
|
269
|
+
// # 0 ? を含む連なりだけが桁構造。"." / "," だけの連なりは区切りリテラル (dd.mm.yyyy 等)
|
|
270
|
+
const run = section.slice(i, j)
|
|
271
|
+
if (/[#0?]/.test(run)) {
|
|
272
|
+
tokens.push({ kind: "digits", text: run })
|
|
273
|
+
} else {
|
|
274
|
+
pushLiteral(run)
|
|
275
|
+
}
|
|
276
|
+
i = j
|
|
277
|
+
} else if (/[A-Za-z]/.test(c)) {
|
|
278
|
+
if ((c === "E" || c === "e") && (section[i + 1] === "+" || section[i + 1] === "-")) {
|
|
279
|
+
// 指数記号は E+ / E- の後に 0 が 1 個以上
|
|
280
|
+
let j = i + 2
|
|
281
|
+
while (section[j] === "0") {
|
|
282
|
+
j += 1
|
|
283
|
+
}
|
|
284
|
+
if (j === i + 2) {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
tokens.push({ kind: "exponent" })
|
|
288
|
+
i = j
|
|
289
|
+
} else {
|
|
290
|
+
// 日付トークン = 同一文字 (大小同一視) の連なり
|
|
291
|
+
let j = i
|
|
292
|
+
while (j < section.length && section[j].toLowerCase() === c.toLowerCase()) {
|
|
293
|
+
j += 1
|
|
294
|
+
}
|
|
295
|
+
tokens.push({ kind: "date", text: section.slice(i, j).toLowerCase() })
|
|
296
|
+
i = j
|
|
297
|
+
}
|
|
298
|
+
} else if (BARE_LITERAL_CHARS.includes(c) || BARE_CURRENCY_CHARS.includes(c) || c.charCodeAt(0) >= 0x80) {
|
|
299
|
+
pushLiteral(c)
|
|
300
|
+
i += 1
|
|
301
|
+
} else {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return tokens
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `{ locale }` when declared, `{}` otherwise — keeps decoded formats free of `locale: undefined` keys. / 宣言時のみ `locale` キーを持たせる。 */
|
|
309
|
+
const localeField = (locale: string | undefined): { locale?: string } => (locale === undefined ? {} : { locale })
|
|
310
|
+
|
|
311
|
+
/** Digit structure the package can render: `0` or `#,##0`, optionally with a fixed `0`-run fraction. / パッケージが描画できる桁構造。 */
|
|
312
|
+
const DIGIT_STRUCTURE = /^(0|#,##0)(?:\.(0+))?$/
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resolves a currency: an LCID-tagged token through the closed table (currency AND locale from the
|
|
316
|
+
* row — an unknown token is unrepresentable), a bare symbol ONLY as the host's declared currency
|
|
317
|
+
* (`opts.currency`) and only when the literal is a currency literal (a unit suffix never is).
|
|
318
|
+
* 通貨の解決: LCID 付きトークンは閉じた表 (通貨 + locale を行から。未知は表現不能)、裸の記号は
|
|
319
|
+
* ホスト宣言 `opts.currency` に限り、かつ通貨リテラル集合の要素だけ (単位の後置は通貨ではない)。
|
|
320
|
+
*/
|
|
321
|
+
const decodeCurrency = (symbol: string, lcid: string | null, decimals: number, opts: NumberFormatDecodeOptions): CellValueFormat | undefined => {
|
|
322
|
+
if (lcid !== null) {
|
|
323
|
+
const row = ROW_BY_TOKEN.get(currencyToken(symbol, lcid))
|
|
324
|
+
return row === undefined ? undefined : { type: "currency", currency: row.code, decimals, locale: row.locale }
|
|
325
|
+
}
|
|
326
|
+
// 記号から通貨を推定しない — 宣言がなければ省略
|
|
327
|
+
if (!CURRENCY_LITERALS.has(symbol) || opts.currency === undefined) {
|
|
328
|
+
return undefined
|
|
329
|
+
}
|
|
330
|
+
return { type: "currency", currency: opts.currency, decimals, ...localeField(opts.locale) }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Decodes a numeric section (one digit structure, optional `%` / exponent / currency token /
|
|
335
|
+
* one-sided literal). Grouping on percent and currency is accepted and dropped (those package
|
|
336
|
+
* types carry no grouping flag); every other extra construct is unrepresentable.
|
|
337
|
+
* 数値節の復号 (桁構造 1 つ + 任意の % / 指数 / 通貨トークン / 片側リテラル)。percent / currency の
|
|
338
|
+
* 桁区切りは受理して落とす (両型に grouping は無い)。他の余分な構成は表現不能。
|
|
339
|
+
*/
|
|
340
|
+
const decodeNumericSection = (tokens: readonly PatternToken[], opts: NumberFormatDecodeOptions): CellValueFormat | undefined => {
|
|
341
|
+
let digits = ""
|
|
342
|
+
let digitsSeen = false
|
|
343
|
+
let currency: { readonly symbol: string; readonly lcid: string | null } | null = null
|
|
344
|
+
let exponent = false
|
|
345
|
+
let percentSigns = 0
|
|
346
|
+
let prefix = ""
|
|
347
|
+
let suffix = ""
|
|
348
|
+
for (const token of tokens) {
|
|
349
|
+
switch (token.kind) {
|
|
350
|
+
case "locale":
|
|
351
|
+
// [$-LCID] 単独の locale タグは無視 — 描画 locale はホスト宣言が決める
|
|
352
|
+
break
|
|
353
|
+
case "literal":
|
|
354
|
+
if (digitsSeen) {
|
|
355
|
+
suffix += token.text
|
|
356
|
+
} else {
|
|
357
|
+
prefix += token.text
|
|
358
|
+
}
|
|
359
|
+
break
|
|
360
|
+
case "digits":
|
|
361
|
+
if (digitsSeen) {
|
|
362
|
+
return undefined
|
|
363
|
+
}
|
|
364
|
+
digits = token.text
|
|
365
|
+
digitsSeen = true
|
|
366
|
+
break
|
|
367
|
+
case "currency":
|
|
368
|
+
if (currency !== null) {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
currency = token
|
|
372
|
+
break
|
|
373
|
+
case "percent":
|
|
374
|
+
percentSigns += 1
|
|
375
|
+
break
|
|
376
|
+
case "exponent":
|
|
377
|
+
if (exponent || !digitsSeen) {
|
|
378
|
+
return undefined
|
|
379
|
+
}
|
|
380
|
+
exponent = true
|
|
381
|
+
break
|
|
382
|
+
default:
|
|
383
|
+
// 数値節の中の日付トークンは表現不能
|
|
384
|
+
return undefined
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const structure = DIGIT_STRUCTURE.exec(digits)
|
|
388
|
+
if (structure === null) {
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
const grouping = structure[1] === "#,##0"
|
|
392
|
+
const decimals = structure[2] === undefined ? 0 : structure[2].length
|
|
393
|
+
const before = prefix.trim()
|
|
394
|
+
const after = suffix.trim()
|
|
395
|
+
// 指数: 桁区切り / % / 通貨 / リテラルのいずれも伴えない
|
|
396
|
+
if (exponent) {
|
|
397
|
+
return percentSigns === 0 && currency === null && !grouping && before === "" && after === "" ? { type: "scientific", decimals, ...localeField(opts.locale) } : undefined
|
|
398
|
+
}
|
|
399
|
+
// % 記号 (×100 描画) はちょうど 1 つ、他の装飾なし
|
|
400
|
+
if (percentSigns > 0) {
|
|
401
|
+
return percentSigns === 1 && currency === null && before === "" && after === "" ? { type: "percent", decimals, ...localeField(opts.locale) } : undefined
|
|
402
|
+
}
|
|
403
|
+
// 後置リテラル "%" は ×100 しない percent (値が既に百分率)
|
|
404
|
+
if (after === "%" && before === "" && currency === null) {
|
|
405
|
+
return { type: "percent", decimals, scale: false, ...localeField(opts.locale) }
|
|
406
|
+
}
|
|
407
|
+
if (currency !== null) {
|
|
408
|
+
return before === "" && after === "" ? decodeCurrency(currency.symbol, currency.lcid, decimals, opts) : undefined
|
|
409
|
+
}
|
|
410
|
+
// 片側のリテラルだけが裸の通貨記号候補 (両側は表現不能)
|
|
411
|
+
if (before !== "" && after !== "") {
|
|
412
|
+
return undefined
|
|
413
|
+
}
|
|
414
|
+
if (before !== "" || after !== "") {
|
|
415
|
+
return decodeCurrency(before + after, null, decimals, opts)
|
|
416
|
+
}
|
|
417
|
+
return { type: "number", decimals, ...(grouping ? { grouping: true } : {}), ...localeField(opts.locale) }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Excel date token → package token for the unambiguous tokens (`mm` is resolved by the neighbour rule). / 曖昧でない日付トークンの写像 (`mm` は隣接規則)。 */
|
|
421
|
+
const EXCEL_DATE_TOKEN: ReadonlyMap<string, string> = new Map([
|
|
422
|
+
["yyyy", "yyyy"],
|
|
423
|
+
["dd", "dd"],
|
|
424
|
+
["hh", "HH"],
|
|
425
|
+
["ss", "ss"],
|
|
426
|
+
])
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Verifies the package scanner reads back exactly the intended tokens at the intended offsets —
|
|
430
|
+
* a literal containing a token (`"dd"`) or a literal + token boundary forming one would make
|
|
431
|
+
* formatCellValue substitute where Excel showed text.
|
|
432
|
+
* パッケージの走査が意図したトークンを意図した位置で読み戻すことを検証 — リテラル内のトークンや
|
|
433
|
+
* 境界で生じるトークンは formatCellValue の置換を誤らせる。
|
|
434
|
+
*/
|
|
435
|
+
const packageTokensMatch = (pattern: string, expected: readonly { readonly index: number; readonly text: string }[]): boolean => {
|
|
436
|
+
const found = [...pattern.matchAll(PACKAGE_DATE_TOKEN)]
|
|
437
|
+
return found.length === expected.length && found.every((match, k) => match.index === expected[k].index && match[0] === expected[k].text)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Decodes a date section: `yyyy` / `dd` / `hh` / `ss` map directly, `mm` is minutes when the
|
|
442
|
+
* neighbouring date token is `hh` before or `ss` after (Excel's rule) else month; separators and
|
|
443
|
+
* quoted literals pass through; any other token (`m`, `yy`, `mmm`, `AM/PM`, digits) is unrepresentable.
|
|
444
|
+
* 日付節の復号: `yyyy` / `dd` / `hh` / `ss` は直写、`mm` は隣接する日付トークンが前 `hh` か後 `ss` なら
|
|
445
|
+
* 分・それ以外は月 (Excel の規則)、区切りと引用リテラルは素通し、他のトークンは表現不能。
|
|
446
|
+
*/
|
|
447
|
+
const decodeDateSection = (tokens: readonly PatternToken[]): CellValueFormat | undefined => {
|
|
448
|
+
const dateTexts = tokens.flatMap((token) => (token.kind === "date" ? [token.text] : []))
|
|
449
|
+
const expected: { readonly index: number; readonly text: string }[] = []
|
|
450
|
+
let pattern = ""
|
|
451
|
+
let dateIndex = 0
|
|
452
|
+
for (const token of tokens) {
|
|
453
|
+
if (token.kind === "locale") {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
if (token.kind === "literal") {
|
|
457
|
+
pattern += token.text
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
if (token.kind !== "date") {
|
|
461
|
+
return undefined
|
|
462
|
+
}
|
|
463
|
+
// mm の月 / 分は隣接する日付トークンで決まる (間の区切りは無視)
|
|
464
|
+
const mapped = token.text === "mm" ? (dateTexts[dateIndex - 1] === "hh" || dateTexts[dateIndex + 1] === "ss" ? "mm" : "MM") : EXCEL_DATE_TOKEN.get(token.text)
|
|
465
|
+
dateIndex += 1
|
|
466
|
+
if (mapped === undefined) {
|
|
467
|
+
return undefined
|
|
468
|
+
}
|
|
469
|
+
expected.push({ index: pattern.length, text: mapped })
|
|
470
|
+
pattern += mapped
|
|
471
|
+
}
|
|
472
|
+
return expected.length > 0 && packageTokensMatch(pattern, expected) ? { type: "date", pattern } : undefined
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Excel named formats (exact spelling). `General` and `@` are the two non-pattern spellings;
|
|
477
|
+
* `Currency` follows the bare-symbol rule (declared currency only); `Short Date` / `Short Time` are
|
|
478
|
+
* declared dates whose exact rendering is locale-bound, so they map to the package's default date
|
|
479
|
+
* rendering / `HH:mm`. Names absent here (`Medium Date`, `Long Time`, `Yes/No`, …) fall through to
|
|
480
|
+
* the tokenizer, where they are unrepresentable.
|
|
481
|
+
* Excel 名前付き書式 (綴り厳密)。`Currency` は裸記号規則 (宣言通貨のみ)、`Short Date` / `Short Time`
|
|
482
|
+
* は描画が locale 依存の宣言済み日付 → パッケージ既定 / `HH:mm`。未掲載名は字句解析で表現不能。
|
|
483
|
+
*/
|
|
484
|
+
/** A named-format decoder (named formats depend on the host declarations). / 名前付き書式の復号器 (ホスト宣言に依存)。 */
|
|
485
|
+
type NamedDecoder = (opts: NumberFormatDecodeOptions) => CellValueFormat | undefined
|
|
486
|
+
const NAMED_FORMATS: ReadonlyMap<string, NamedDecoder> = new Map<string, NamedDecoder>([
|
|
487
|
+
["General", () => undefined],
|
|
488
|
+
["@", () => ({ type: "text" })],
|
|
489
|
+
["Fixed", (opts) => ({ type: "number", decimals: 2, ...localeField(opts.locale) })],
|
|
490
|
+
["Standard", (opts) => ({ type: "number", decimals: 2, grouping: true, ...localeField(opts.locale) })],
|
|
491
|
+
["Percent", (opts) => ({ type: "percent", decimals: 2, ...localeField(opts.locale) })],
|
|
492
|
+
["Scientific", (opts) => ({ type: "scientific", decimals: 2, ...localeField(opts.locale) })],
|
|
493
|
+
["Currency", (opts) => (opts.currency === undefined ? undefined : { type: "currency", currency: opts.currency, decimals: 2, ...localeField(opts.locale) })],
|
|
494
|
+
["Euro Currency", (opts) => ({ type: "currency", currency: "EUR", decimals: 2, ...localeField(opts.locale) })],
|
|
495
|
+
["Short Date", () => ({ type: "date" })],
|
|
496
|
+
["Short Time", () => ({ type: "date", pattern: "HH:mm" })],
|
|
497
|
+
])
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Decodes an Excel / Sheets pattern (GENUINE syntax — run decodeMsoNumberFormat first for
|
|
501
|
+
* `mso-number-format` values) into a CellValueFormat. TOTAL: named formats, the first `;`
|
|
502
|
+
* section of a custom pattern, currency via the LCID table or the declared bare currency,
|
|
503
|
+
* dates via the token grammar; anything else (or `General`) ⇒ `undefined`. Never throws.
|
|
504
|
+
* Excel / Sheets パターン (真の構文 — `mso-number-format` 値は先に decodeMsoNumberFormat) を
|
|
505
|
+
* CellValueFormat へ復号。全域: 名前付き書式、カスタムの先頭節、LCID 表または宣言通貨の裸記号、
|
|
506
|
+
* トークン文法の日付。それ以外 (と `General`) は `undefined`。throw しない。
|
|
507
|
+
*/
|
|
508
|
+
export const fromExcelPattern = (pattern: string, opts: NumberFormatDecodeOptions): CellValueFormat | undefined => {
|
|
509
|
+
const section = firstSection(pattern).trim()
|
|
510
|
+
const named = NAMED_FORMATS.get(section)
|
|
511
|
+
if (named !== undefined) {
|
|
512
|
+
return named(opts)
|
|
513
|
+
}
|
|
514
|
+
const tokens = tokenizeSection(section)
|
|
515
|
+
if (tokens === null) {
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
// 桁構造があれば数値節、日付トークンだけなら日付節、どちらも無ければ (リテラルのみ) 表現不能
|
|
519
|
+
if (tokens.some((token) => token.kind === "digits")) {
|
|
520
|
+
return decodeNumericSection(tokens, opts)
|
|
521
|
+
}
|
|
522
|
+
if (tokens.some((token) => token.kind === "date")) {
|
|
523
|
+
return decodeDateSection(tokens)
|
|
524
|
+
}
|
|
525
|
+
return undefined
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Decodes a Sheets `data-sheets-numberformat` JSON value (`{"1":type,"2":pattern}`; the entity
|
|
530
|
+
* decoding is the caller's). Type codes: 0 automatic ⇒ no format, 1 TEXT ⇒ text, 5 DATE ⇒ the
|
|
531
|
+
* pattern when representable else the default date rendering (the type declares date-ness), and
|
|
532
|
+
* 2 NUMBER / 3 PERCENT / 4 CURRENCY / 6 TIME / 7 DATE_TIME / 8 SCIENTIFIC ⇒ the pattern (the
|
|
533
|
+
* pattern is the source of truth for rendering). Malformed JSON / shape / code ⇒ `undefined`.
|
|
534
|
+
* Sheets の `data-sheets-numberformat` JSON を復号 (実体復号は呼び出し側)。型コード 0 は書式なし、
|
|
535
|
+
* 1 はテキスト、5 は表現可能ならパターン・不能なら既定日付描画 (型が日付性を宣言)、他はパターンが
|
|
536
|
+
* 描画の真実の源。不正な JSON / 形 / コードは `undefined`。
|
|
537
|
+
*/
|
|
538
|
+
export const fromSheetsNumberFormat = (json: string, opts: NumberFormatDecodeOptions): CellValueFormat | undefined => {
|
|
539
|
+
let parsed: unknown
|
|
540
|
+
try {
|
|
541
|
+
parsed = JSON.parse(json)
|
|
542
|
+
} catch {
|
|
543
|
+
// 不正 JSON は「書式なし」— 全域 fail-soft の契約 (値の代替ではなく省略)
|
|
544
|
+
return undefined
|
|
545
|
+
}
|
|
546
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
547
|
+
return undefined
|
|
548
|
+
}
|
|
549
|
+
const record = parsed as Record<string, unknown>
|
|
550
|
+
const type = record["1"]
|
|
551
|
+
const pattern = record["2"]
|
|
552
|
+
if (typeof type !== "number" || !Number.isInteger(type) || type < 0 || type > 8) {
|
|
553
|
+
return undefined
|
|
554
|
+
}
|
|
555
|
+
if (pattern !== undefined && typeof pattern !== "string") {
|
|
556
|
+
return undefined
|
|
557
|
+
}
|
|
558
|
+
if (type === 0) {
|
|
559
|
+
return undefined
|
|
560
|
+
}
|
|
561
|
+
if (type === 1) {
|
|
562
|
+
return { type: "text" }
|
|
563
|
+
}
|
|
564
|
+
const decoded = pattern === undefined ? undefined : fromExcelPattern(pattern, opts)
|
|
565
|
+
// DATE (5) は pattern が表現不能でも日付であることが宣言済み — 既定描画の date へ
|
|
566
|
+
if (type === 5) {
|
|
567
|
+
return decoded !== undefined && decoded.type === "date" ? decoded : { type: "date" }
|
|
568
|
+
}
|
|
569
|
+
return decoded
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `.00`-style fraction placeholders for a decimals count (empty for 0). / 小数桁のプレースホルダ。 */
|
|
573
|
+
const fractionPattern = (decimals: number): string => (decimals === 0 ? "" : `.${"0".repeat(decimals)}`)
|
|
574
|
+
|
|
575
|
+
/** A decimals count Excel and the display engine both accept (integer 0..20). / Excel と表示エンジン双方が受理する小数桁数。 */
|
|
576
|
+
const isValidDecimals = (decimals: number | undefined): decimals is number => decimals !== undefined && Number.isInteger(decimals) && decimals >= 0 && decimals <= MAX_DECIMALS
|
|
577
|
+
|
|
578
|
+
/** Chars the encoder leaves unquoted inside a date section. / 日付節でエンコーダが引用しない文字。 */
|
|
579
|
+
const BARE_DATE_LITERAL_CHARS = `${BARE_LITERAL_CHARS}.,`
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Quotes a package date literal for Excel: separators / spacing stay bare, anything else (e.g.
|
|
583
|
+
* 年 月 日) is wrapped in quotes; a literal containing `"` is unrepresentable.
|
|
584
|
+
* パッケージ日付リテラルの Excel 化: 区切り / 空白は裸、他 (年 月 日 等) は引用で包む。`"` を含めば表現不能。
|
|
585
|
+
*/
|
|
586
|
+
const quoteDateLiteral = (literal: string): string | null => {
|
|
587
|
+
if (literal.includes('"')) {
|
|
588
|
+
return null
|
|
589
|
+
}
|
|
590
|
+
return [...literal].every((c) => BARE_DATE_LITERAL_CHARS.includes(c)) ? literal : `"${literal}"`
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Encodes a package date pattern: each token lower-cased (`MM` → `mm`, `HH` → `hh` — Excel's
|
|
595
|
+
* spelling), literals quoted as needed, then VERIFIED by decoding back — a pattern Excel would read
|
|
596
|
+
* differently (a minutes-only `mm`, which Excel reads as month) is unrepresentable.
|
|
597
|
+
* パッケージ日付パターンのエンコード: トークン小文字化、リテラルは必要に応じ引用、復号して往復検証 —
|
|
598
|
+
* Excel が別読みする形 (分だけの `mm` は月と読まれる) は表現不能。
|
|
599
|
+
*/
|
|
600
|
+
const encodeDatePattern = (pattern: string): string | null => {
|
|
601
|
+
const parts: string[] = []
|
|
602
|
+
let tokenCount = 0
|
|
603
|
+
let last = 0
|
|
604
|
+
for (const match of pattern.matchAll(PACKAGE_DATE_TOKEN)) {
|
|
605
|
+
const literal = quoteDateLiteral(pattern.slice(last, match.index))
|
|
606
|
+
if (literal === null) {
|
|
607
|
+
return null
|
|
608
|
+
}
|
|
609
|
+
parts.push(literal, match[0].toLowerCase())
|
|
610
|
+
tokenCount += 1
|
|
611
|
+
last = match.index + match[0].length
|
|
612
|
+
}
|
|
613
|
+
const tail = quoteDateLiteral(pattern.slice(last))
|
|
614
|
+
if (tail === null || tokenCount === 0) {
|
|
615
|
+
return null
|
|
616
|
+
}
|
|
617
|
+
parts.push(tail)
|
|
618
|
+
const excel = parts.join("")
|
|
619
|
+
// 往復検証 — Excel の隣接規則で月 / 分が入れ替わる形は表現不能
|
|
620
|
+
const back = fromExcelPattern(excel, {})
|
|
621
|
+
return back !== undefined && back.type === "date" && back.pattern === pattern ? excel : null
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Encodes a currency format through the home row of its code (`decimals` absent ⇒ the ISO minor
|
|
626
|
+
* units, which is what the display engine renders). USD in en-US is written in the native quoted
|
|
627
|
+
* form `"$"#,##0.00` that Excel / Sheets recognise as their own currency; every other case carries
|
|
628
|
+
* the LCID token. A code outside the table is unrepresentable.
|
|
629
|
+
* 通貨書式のエンコード (ホーム行、`decimals` 省略は ISO 補助単位 = 表示エンジンの描画)。en-US の
|
|
630
|
+
* USD だけは Excel / Sheets が自国通貨と認識する `"$"#,##0.00`、他は LCID トークン。表外の通貨は表現不能。
|
|
631
|
+
*/
|
|
632
|
+
const encodeCurrency = (value: { readonly currency: string; readonly decimals?: number; readonly locale?: string }): string | null => {
|
|
633
|
+
const row = HOME_ROW_BY_CODE.get(value.currency.toUpperCase())
|
|
634
|
+
if (row === undefined) {
|
|
635
|
+
return null
|
|
636
|
+
}
|
|
637
|
+
const decimals = value.decimals === undefined ? row.digits : value.decimals
|
|
638
|
+
if (!isValidDecimals(decimals)) {
|
|
639
|
+
return null
|
|
640
|
+
}
|
|
641
|
+
const digits = `#,##0${fractionPattern(decimals)}`
|
|
642
|
+
return row.code === "USD" && value.locale === "en-US" ? `"$"${digits}` : `${currencyToken(row.symbol, row.lcid)}${digits}`
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Encodes a CellValueFormat as an Excel / Sheets pattern; `null` when Excel cannot express it
|
|
647
|
+
* (a `number` / `scientific` without fixed decimals — Excel has no "as many digits as needed"
|
|
648
|
+
* without a dangling point — an out-of-range decimals count, a currency outside the LCID table,
|
|
649
|
+
* a date pattern Excel would read differently). `percent` without decimals is exactly `0%`.
|
|
650
|
+
* CellValueFormat を Excel / Sheets パターンへ。表現不能は `null` (小数桁未固定の number /
|
|
651
|
+
* scientific、範囲外の小数桁、表外通貨、Excel が別読みする日付)。decimals 省略の percent は厳密に `0%`。
|
|
652
|
+
*/
|
|
653
|
+
export const toExcelPattern = (value: CellValueFormat): string | null => {
|
|
654
|
+
switch (value.type) {
|
|
655
|
+
case "text":
|
|
656
|
+
return "@"
|
|
657
|
+
case "date":
|
|
658
|
+
return encodeDatePattern(value.pattern === undefined ? DEFAULT_DATE_PATTERN : value.pattern)
|
|
659
|
+
case "number":
|
|
660
|
+
return isValidDecimals(value.decimals) ? `${value.grouping === true ? "#,##0" : "0"}${fractionPattern(value.decimals)}` : null
|
|
661
|
+
case "percent": {
|
|
662
|
+
// decimals 省略 = Intl の percent 既定 (小数 0 桁) と厳密に等価
|
|
663
|
+
const decimals = value.decimals === undefined ? 0 : value.decimals
|
|
664
|
+
if (!isValidDecimals(decimals)) {
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
// scale:false は値が既に百分率 — Excel の "%" リテラル (×100 なし) で表す
|
|
668
|
+
return value.scale === false ? `0${fractionPattern(decimals)}"%"` : `0${fractionPattern(decimals)}%`
|
|
669
|
+
}
|
|
670
|
+
case "scientific":
|
|
671
|
+
return isValidDecimals(value.decimals) ? `0${fractionPattern(value.decimals)}E+00` : null
|
|
672
|
+
case "currency":
|
|
673
|
+
return encodeCurrency(value)
|
|
674
|
+
default: {
|
|
675
|
+
// 到達不能 (6 型を網羅) — 将来の型追加を型エラーで検出しつつ実行時は表現不能
|
|
676
|
+
const exhaustive: never = value
|
|
677
|
+
void exhaustive
|
|
678
|
+
return null
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** The minus glyphs a rendering may carry (ASCII hyphen-minus, U+2212). / 描画が持ち得るマイナス記号。 */
|
|
684
|
+
const MINUS_SIGNS = "-−"
|
|
685
|
+
|
|
686
|
+
/** A display core split into digit strings plus an exponent (string math only). / 表示核の分解 (文字列演算のみ)。 */
|
|
687
|
+
type DisplayCore = { readonly integer: string; readonly fraction: string; readonly exponent: number }
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Removes a group separator from an integer digit string, requiring Excel's structure (1-3 leading
|
|
691
|
+
* digits, then groups of exactly 3) so a stray separator never silently merges digits; without a
|
|
692
|
+
* separator the string must be plain digits. `null` otherwise.
|
|
693
|
+
* 整数桁文字列から桁区切りを除く。Excel の構造 (先頭 1-3 桁 + 3 桁組) を要求し、迷い込んだ区切りで
|
|
694
|
+
* 桁を黙って併合しない。区切りなしなら素の数字列。それ以外は `null`。
|
|
695
|
+
*/
|
|
696
|
+
const ungroupDigits = (integerText: string, group: string): string | null => {
|
|
697
|
+
if (group === "" || !integerText.includes(group)) {
|
|
698
|
+
return /^\d+$/.test(integerText) ? integerText : null
|
|
699
|
+
}
|
|
700
|
+
const chunks = integerText.split(group)
|
|
701
|
+
if (!/^\d{1,3}$/.test(chunks[0]) || chunks.slice(1).some((chunk) => !/^\d{3}$/.test(chunk))) {
|
|
702
|
+
return null
|
|
703
|
+
}
|
|
704
|
+
return chunks.join("")
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Parses the digit core of a rendering: optional exponent (`required` for scientific, `optional`
|
|
709
|
+
* for General, `none` otherwise), at most one decimal separator, digit-only fraction, grouped
|
|
710
|
+
* integer per ungroupDigits. `null` when the core is not a rendering.
|
|
711
|
+
* 描画の数字核を解析: 指数 (scientific は必須、General は任意、他は不可)、小数区切りは高々 1 つ、
|
|
712
|
+
* 小数部は数字のみ、整数部は ungroupDigits。描画でなければ `null`。
|
|
713
|
+
*/
|
|
714
|
+
const parseDisplayCore = (core: string, group: string, decimal: string, exponentMode: "required" | "optional" | "none"): DisplayCore | null => {
|
|
715
|
+
const exponentMatch = /^(.*?)[eE]([+-]?\d{1,4})$/.exec(core)
|
|
716
|
+
if (exponentMatch !== null && exponentMode === "none") {
|
|
717
|
+
return null
|
|
718
|
+
}
|
|
719
|
+
if (exponentMatch === null && exponentMode === "required") {
|
|
720
|
+
return null
|
|
721
|
+
}
|
|
722
|
+
const mantissa = exponentMatch === null ? core : exponentMatch[1]
|
|
723
|
+
const exponent = exponentMatch === null ? 0 : Number(exponentMatch[2])
|
|
724
|
+
const parts = mantissa.split(decimal)
|
|
725
|
+
if (parts.length > 2) {
|
|
726
|
+
return null
|
|
727
|
+
}
|
|
728
|
+
const fraction = parts.length === 2 ? parts[1] : ""
|
|
729
|
+
if (parts.length === 2 && !/^\d+$/.test(fraction)) {
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
const integer = ungroupDigits(parts[0], group)
|
|
733
|
+
return integer === null ? null : { integer, fraction, exponent }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Builds the canonical decimal string from digit strings, a decimal-point shift (exponent and
|
|
738
|
+
* percent ÷100 combined) and a sign — pure string math: leading zeros trimmed, trailing fraction
|
|
739
|
+
* zeros trimmed, and never a negative zero.
|
|
740
|
+
* 桁文字列・小数点シフト (指数と percent ÷100 の合算)・符号から正準 10 進文字列を組む — 純文字列演算。
|
|
741
|
+
* 先頭 0 と小数末尾 0 を除き、負ゼロは作らない。
|
|
742
|
+
*/
|
|
743
|
+
const canonicalDecimal = (integer: string, fraction: string, shift: number, negative: boolean): string => {
|
|
744
|
+
const digits = integer + fraction
|
|
745
|
+
const point = integer.length + shift
|
|
746
|
+
let intPart: string
|
|
747
|
+
let fracPart: string
|
|
748
|
+
if (point <= 0) {
|
|
749
|
+
intPart = "0"
|
|
750
|
+
fracPart = "0".repeat(-point) + digits
|
|
751
|
+
} else if (point >= digits.length) {
|
|
752
|
+
intPart = digits + "0".repeat(point - digits.length)
|
|
753
|
+
fracPart = ""
|
|
754
|
+
} else {
|
|
755
|
+
intPart = digits.slice(0, point)
|
|
756
|
+
fracPart = digits.slice(point)
|
|
757
|
+
}
|
|
758
|
+
intPart = intPart.replace(/^0+(?=\d)/, "")
|
|
759
|
+
fracPart = fracPart.replace(/0+$/, "")
|
|
760
|
+
const magnitude = fracPart === "" ? intPart : `${intPart}.${fracPart}`
|
|
761
|
+
return negative && magnitude !== "0" ? `-${magnitude}` : magnitude
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Recovers the raw canonical decimal string from a DISPLAYED number under the declared
|
|
766
|
+
* separators: grouping removed ("2,728,548" → "2728548"), percent divided by 100 by a
|
|
767
|
+
* decimal-point shift ("20%" → "0.2"; `scale:false` keeps "20"), scientific expanded
|
|
768
|
+
* ("1.23E+05" → "123000"), accounting decorations stripped (parentheses negative, currency symbol
|
|
769
|
+
* / code and padding around the digits, U+2212 minus). A General (undefined) format accepts only a
|
|
770
|
+
* bare decimal with optional exponent (Excel never groups under General); `date` / `text` formats and
|
|
771
|
+
* anything that is not a recognisable rendering return the text UNCHANGED (total fail-soft).
|
|
772
|
+
* A rendering whose expansion leaves the finite-double domain (point shift beyond ±330, or a result
|
|
773
|
+
* overflowing ±e308) is not a recognisable rendering either — the text stays verbatim, so the output
|
|
774
|
+
* always satisfies parseNumber and a crafted clipboard cannot amplify itself in memory.
|
|
775
|
+
* Degenerate separators (empty decimal, or group equal to decimal) are a consumer error ⇒ RangeError.
|
|
776
|
+
* 表示済み数値から宣言区切りで素の正準 10 進文字列を復元: 桁区切り除去、percent は小数点シフトで ÷100
|
|
777
|
+
* (`scale:false` はそのまま)、scientific は展開、会計装飾 (括弧負数・通貨記号 / コード・詰め物・U+2212)
|
|
778
|
+
* を除去。General (undefined) は裸の 10 進 (+ 任意の指数) のみ、`date` / `text` と描画でない文字列は
|
|
779
|
+
* 無変更 (全域 fail-soft)。有限 double の外へ出る展開 (小数点シフト ±330 超、±e308 溢れ) も描画とは
|
|
780
|
+
* 認めず原文のまま — 出力は常に parseNumber に適合し、細工クリップボードのメモリ増幅も遮断。
|
|
781
|
+
* 退化した区切り (空の小数区切り・group = decimal) は消費者エラー ⇒ RangeError。
|
|
782
|
+
*/
|
|
783
|
+
export const numberDisplayToCanonical = (text: string, format: CellValueFormat | undefined, separators: NumberSeparators): string => {
|
|
784
|
+
if (separators.decimal === "" || separators.group === separators.decimal) {
|
|
785
|
+
throw new RangeError(`numberDisplayToCanonical: separators must declare a non-empty decimal separator distinct from the group separator (got group="${separators.group}", decimal="${separators.decimal}")`)
|
|
786
|
+
}
|
|
787
|
+
// 日付 / テキスト書式は数値描画ではない
|
|
788
|
+
if (format !== undefined && (format.type === "date" || format.type === "text")) {
|
|
789
|
+
return text
|
|
790
|
+
}
|
|
791
|
+
const kind = format === undefined ? "general" : format.type
|
|
792
|
+
let s = text.trim()
|
|
793
|
+
let negative = false
|
|
794
|
+
// 会計形式の括弧負数
|
|
795
|
+
if (s.startsWith("(") && s.endsWith(")")) {
|
|
796
|
+
negative = true
|
|
797
|
+
s = s.slice(1, -1).trim()
|
|
798
|
+
}
|
|
799
|
+
// percent は末尾 % が必須、他の型に % があれば描画ではない
|
|
800
|
+
if (kind === "percent") {
|
|
801
|
+
if (!s.endsWith("%")) {
|
|
802
|
+
return text
|
|
803
|
+
}
|
|
804
|
+
s = s.slice(0, -1).trim()
|
|
805
|
+
} else if (s.includes("%")) {
|
|
806
|
+
return text
|
|
807
|
+
}
|
|
808
|
+
// General は桁区切りを描画しない
|
|
809
|
+
const group = kind === "general" ? "" : separators.group
|
|
810
|
+
const exponentAllowed = kind === "scientific" || kind === "general"
|
|
811
|
+
const inCore = (c: string): boolean => (c >= "0" && c <= "9") || (group !== "" && group.includes(c)) || separators.decimal.includes(c) || (exponentAllowed && (c === "e" || c === "E"))
|
|
812
|
+
let start = 0
|
|
813
|
+
while (start < s.length && !inCore(s[start])) {
|
|
814
|
+
start += 1
|
|
815
|
+
}
|
|
816
|
+
let end = s.length
|
|
817
|
+
while (end > start && !inCore(s[end - 1])) {
|
|
818
|
+
end -= 1
|
|
819
|
+
}
|
|
820
|
+
// 前後の装飾: 負号は高々 1 つ (記号の内側の会計括弧 "$ (1,234.00)" も負)、通貨以外は空白しか許さない
|
|
821
|
+
let signs = 0
|
|
822
|
+
let opens = 0
|
|
823
|
+
let closes = 0
|
|
824
|
+
let decoration = ""
|
|
825
|
+
for (const c of s.slice(0, start)) {
|
|
826
|
+
if (MINUS_SIGNS.includes(c)) {
|
|
827
|
+
signs += 1
|
|
828
|
+
} else if (c === "(") {
|
|
829
|
+
opens += 1
|
|
830
|
+
} else {
|
|
831
|
+
decoration += c
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const c of s.slice(end)) {
|
|
835
|
+
if (MINUS_SIGNS.includes(c)) {
|
|
836
|
+
signs += 1
|
|
837
|
+
} else if (c === ")") {
|
|
838
|
+
closes += 1
|
|
839
|
+
} else {
|
|
840
|
+
decoration += c
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// 負の印 (負号 / 括弧対) は全体で高々 1 つ、括弧の不揃いは描画ではない
|
|
844
|
+
const marks = signs + opens
|
|
845
|
+
if (opens !== closes || marks > 1 || (marks === 1 && negative)) {
|
|
846
|
+
return text
|
|
847
|
+
}
|
|
848
|
+
negative = negative || marks === 1
|
|
849
|
+
if (kind !== "currency" && decoration.trim() !== "") {
|
|
850
|
+
return text
|
|
851
|
+
}
|
|
852
|
+
// 核の外側空白を落とす (空白系の桁区切りが記号の手前に残る形 "1 234 €" を救う)
|
|
853
|
+
const core = parseDisplayCore(s.slice(start, end).trim(), group, separators.decimal, kind === "scientific" ? "required" : kind === "general" ? "optional" : "none")
|
|
854
|
+
if (core === null) {
|
|
855
|
+
return text
|
|
856
|
+
}
|
|
857
|
+
// percent (scale 既定) は ÷100 = 小数点を 2 桁左へ
|
|
858
|
+
const percentShift = format !== undefined && format.type === "percent" && format.scale !== false ? -2 : 0
|
|
859
|
+
const shift = core.exponent + percentShift
|
|
860
|
+
// 値域フェンス: double の全域 (±e308) を越える小数点シフトは実在の Excel / Sheets 描画に無い。
|
|
861
|
+
// ADR-12 は行 × 列しか見ないため、展開すると ~7 文字の表示から最大 ~10,001 文字のセル文字列を
|
|
862
|
+
// 合成する敵対クリップボードがゲートを素通りする — 展開前に遮断する (描画と認めず原文のまま)
|
|
863
|
+
if (shift > 330 || shift < -330) {
|
|
864
|
+
return text
|
|
865
|
+
}
|
|
866
|
+
const canonical = canonicalDecimal(core.integer, core.fraction, shift, negative)
|
|
867
|
+
// フェンス内でも有限 double を溢れる展開 (例 "2E+308" → 2e308 = Infinity) は parseNumber が
|
|
868
|
+
// 拒む文字列になり selfCalc の不変量 (数値書式セルの正準文字列は数値文法に適合) を破る — 原文へ
|
|
869
|
+
return Number.isFinite(Number(canonical)) ? canonical : text
|
|
870
|
+
}
|