@rowsncolumns/utils 9.2.39 → 9.2.40
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/dist/cjs/excel-number-format.d.ts +128 -0
- package/dist/cjs/excel-number-format.d.ts.map +1 -0
- package/dist/cjs/excel-number-format.js +659 -0
- package/dist/cjs/formatter.d.ts +8 -0
- package/dist/cjs/formatter.d.ts.map +1 -1
- package/dist/cjs/formatter.js +151 -2
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/sheet-span.d.ts +13 -0
- package/dist/cjs/sheet-span.d.ts.map +1 -0
- package/dist/cjs/sheet-span.js +24 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/excel-number-format.d.ts +128 -0
- package/dist/esm/excel-number-format.d.ts.map +1 -0
- package/dist/esm/excel-number-format.js +640 -0
- package/dist/esm/formatter.d.ts +8 -0
- package/dist/esm/formatter.d.ts.map +1 -1
- package/dist/esm/formatter.js +135 -1
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/sheet-span.d.ts +13 -0
- package/dist/esm/sheet-span.d.ts.map +1 -0
- package/dist/esm/sheet-span.js +20 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel-parity pre-pass for number formatting.
|
|
3
|
+
*
|
|
4
|
+
* `numfmt` renders the bulk of Excel's format language, but it decides three
|
|
5
|
+
* things from the binary double where Excel decides them from the number's
|
|
6
|
+
* 15-significant-digit decimal representation:
|
|
7
|
+
*
|
|
8
|
+
* - rounding to a fixed number of decimals (`0.00` of `1.005` is `1.01`, not
|
|
9
|
+
* the `1.00` the double `1.00499999999999989…` rounds to);
|
|
10
|
+
* - `General`, which switches to scientific notation below 1E-4 and above
|
|
11
|
+
* 11 digits and rounds (never truncates) the 11th digit;
|
|
12
|
+
* - magnitudes ≥ 1E15, where Excel pads the 15 significant digits with
|
|
13
|
+
* zeros (`0.00` of `1E21` is `1000000000000000000000.00`).
|
|
14
|
+
*
|
|
15
|
+
* It also renders a date/time format for a serial outside Excel's calendar
|
|
16
|
+
* (negative, or ≥ 10000-01-01) as `#######`, as Excel does in the cell.
|
|
17
|
+
*
|
|
18
|
+
* Everything here works on the pattern's SECTION the value selects, so
|
|
19
|
+
* multi-section formats (`0.00;(0.00);"-"`) keep their Excel section rules.
|
|
20
|
+
* A format with `[>100]`-style conditions is resolved here too — which
|
|
21
|
+
* section matches, and whether the value's sign is shown — so the matched
|
|
22
|
+
* section gets the same decimal rules; the library then renders that one
|
|
23
|
+
* section on its own.
|
|
24
|
+
*/
|
|
25
|
+
/** Excel's largest date serial: 9999-12-31 is 2958465 (1900 date system). */
|
|
26
|
+
const MAX_DATE_SERIAL_EXCLUSIVE = 2958466;
|
|
27
|
+
/**
|
|
28
|
+
* What Excel shows when a value cannot be rendered by its format: a
|
|
29
|
+
* date/time outside the calendar, or a number no conditional section takes.
|
|
30
|
+
*/
|
|
31
|
+
export const DATE_OVERFLOW_DISPLAY = "#######";
|
|
32
|
+
/**
|
|
33
|
+
* Split a format code into its `;` sections, honouring quotes, bracket
|
|
34
|
+
* tokens and backslash escapes.
|
|
35
|
+
*/
|
|
36
|
+
export const splitFormatSections = (code) => {
|
|
37
|
+
const sections = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
let inQuotes = false;
|
|
40
|
+
let inBrackets = false;
|
|
41
|
+
for (let i = 0; i < code.length; i++) {
|
|
42
|
+
const c = code[i];
|
|
43
|
+
if (inQuotes) {
|
|
44
|
+
current += c;
|
|
45
|
+
if (c === '"')
|
|
46
|
+
inQuotes = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inBrackets) {
|
|
50
|
+
current += c;
|
|
51
|
+
if (c === "]")
|
|
52
|
+
inBrackets = false;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === "\\" && i + 1 < code.length) {
|
|
56
|
+
current += c + code[i + 1];
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '"') {
|
|
61
|
+
inQuotes = true;
|
|
62
|
+
current += c;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (c === "[") {
|
|
66
|
+
inBrackets = true;
|
|
67
|
+
current += c;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (c === ";") {
|
|
71
|
+
sections.push(current);
|
|
72
|
+
current = "";
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
current += c;
|
|
76
|
+
}
|
|
77
|
+
sections.push(current);
|
|
78
|
+
return sections;
|
|
79
|
+
};
|
|
80
|
+
const isDigitPlaceholder = (c) => c === "0" || c === "#" || c === "?";
|
|
81
|
+
const isDateLetter = (c) => /[ymdhsYMDHS]/.test(c);
|
|
82
|
+
const CONDITION_TOKEN = /^(<>|<=|>=|=|<|>)\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*$/;
|
|
83
|
+
const parseCondition = (token) => {
|
|
84
|
+
const match = CONDITION_TOKEN.exec(token);
|
|
85
|
+
if (!match)
|
|
86
|
+
return undefined;
|
|
87
|
+
return {
|
|
88
|
+
op: match[1],
|
|
89
|
+
operand: Number(match[2]),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/** Classify one section of a format code. */
|
|
93
|
+
export const analyzeFormatSection = (section) => {
|
|
94
|
+
const info = {
|
|
95
|
+
kind: "other",
|
|
96
|
+
body: "",
|
|
97
|
+
hasCondition: false,
|
|
98
|
+
isElapsed: false,
|
|
99
|
+
generalPrefix: "",
|
|
100
|
+
decimals: 0,
|
|
101
|
+
percent: 0,
|
|
102
|
+
commaScale: 0,
|
|
103
|
+
grouping: false,
|
|
104
|
+
isPlainNumber: true,
|
|
105
|
+
volatileSign: false,
|
|
106
|
+
};
|
|
107
|
+
// Pass 1: strip bracket tokens, classify them.
|
|
108
|
+
let body = "";
|
|
109
|
+
let inQuotes = false;
|
|
110
|
+
let hasToken = false;
|
|
111
|
+
for (let i = 0; i < section.length; i++) {
|
|
112
|
+
const c = section[i];
|
|
113
|
+
if (inQuotes) {
|
|
114
|
+
body += c;
|
|
115
|
+
if (c === '"')
|
|
116
|
+
inQuotes = false;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === "\\" && i + 1 < section.length) {
|
|
120
|
+
body += c + section[i + 1];
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === '"') {
|
|
125
|
+
inQuotes = true;
|
|
126
|
+
body += c;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === "[") {
|
|
130
|
+
const end = section.indexOf("]", i);
|
|
131
|
+
if (end === -1) {
|
|
132
|
+
info.kind = "other";
|
|
133
|
+
return info;
|
|
134
|
+
}
|
|
135
|
+
const token = section.slice(i + 1, end);
|
|
136
|
+
i = end;
|
|
137
|
+
hasToken = true;
|
|
138
|
+
if (/^[<>=]/.test(token)) {
|
|
139
|
+
const condition = parseCondition(token);
|
|
140
|
+
if (!condition) {
|
|
141
|
+
info.kind = "other";
|
|
142
|
+
return info;
|
|
143
|
+
}
|
|
144
|
+
info.hasCondition = true;
|
|
145
|
+
info.condition = condition;
|
|
146
|
+
}
|
|
147
|
+
else if (/^[hmsHMS]+$/.test(token)) {
|
|
148
|
+
info.isElapsed = true;
|
|
149
|
+
body += `[${token}]`;
|
|
150
|
+
}
|
|
151
|
+
else if (/^\$/.test(token) || /^color\s*\d+$/i.test(token)) {
|
|
152
|
+
// currency/locale pin or color: no effect on the digits
|
|
153
|
+
}
|
|
154
|
+
else if (/^(black|blue|cyan|green|magenta|red|white|yellow)$/i.test(token)) {
|
|
155
|
+
// color
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
info.kind = "other";
|
|
159
|
+
return info;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
body += c;
|
|
164
|
+
}
|
|
165
|
+
info.body = body;
|
|
166
|
+
if (body.trim() === "") {
|
|
167
|
+
// `0;;;` hides the value; a section holding only `[Red]` / `[$-409]` /
|
|
168
|
+
// `[<0]` tokens is Excel's `General` (`[Blue];[Red]` colors by sign).
|
|
169
|
+
info.kind = hasToken ? "general" : "empty";
|
|
170
|
+
return info;
|
|
171
|
+
}
|
|
172
|
+
// General (optionally with a literal prefix such as `-General`).
|
|
173
|
+
const generalMatch = /^([^"A-Za-z0#?@]*)general$/i.exec(body.trim());
|
|
174
|
+
if (generalMatch) {
|
|
175
|
+
info.kind = "general";
|
|
176
|
+
info.generalPrefix = generalMatch[1];
|
|
177
|
+
return info;
|
|
178
|
+
}
|
|
179
|
+
// Pass 2: scan the body outside quotes / escapes / pad markers.
|
|
180
|
+
let placeholders = 0;
|
|
181
|
+
let afterDot = false;
|
|
182
|
+
let sawDot = false;
|
|
183
|
+
let hasDate = info.isElapsed;
|
|
184
|
+
let hasText = false;
|
|
185
|
+
let hasScientific = false;
|
|
186
|
+
let hasFraction = false;
|
|
187
|
+
let hasOtherLetter = false;
|
|
188
|
+
let hasLiteral = false;
|
|
189
|
+
const chars = [];
|
|
190
|
+
inQuotes = false;
|
|
191
|
+
for (let i = 0; i < body.length; i++) {
|
|
192
|
+
const c = body[i];
|
|
193
|
+
if (inQuotes) {
|
|
194
|
+
if (c === '"')
|
|
195
|
+
inQuotes = false;
|
|
196
|
+
else
|
|
197
|
+
hasLiteral = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (c === '"') {
|
|
201
|
+
inQuotes = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (c === "\\" || c === "_" || c === "*") {
|
|
205
|
+
hasLiteral = true;
|
|
206
|
+
i++;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (c === "[") {
|
|
210
|
+
// elapsed token kept from pass 1
|
|
211
|
+
const end = body.indexOf("]", i);
|
|
212
|
+
i = end === -1 ? body.length : end;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
chars.push(c);
|
|
216
|
+
if (isDigitPlaceholder(c)) {
|
|
217
|
+
placeholders++;
|
|
218
|
+
if (afterDot)
|
|
219
|
+
info.decimals++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (c === ".") {
|
|
223
|
+
if (!sawDot) {
|
|
224
|
+
sawDot = true;
|
|
225
|
+
afterDot = true;
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (c === ",") {
|
|
230
|
+
// A comma directly followed by a digit placeholder groups; any
|
|
231
|
+
// other comma placed after a digit placeholder scales by 1000.
|
|
232
|
+
let j = i + 1;
|
|
233
|
+
while (j < body.length && (body[j] === "_" || body[j] === "*"))
|
|
234
|
+
j += 2;
|
|
235
|
+
if (j < body.length && isDigitPlaceholder(body[j])) {
|
|
236
|
+
info.grouping = true;
|
|
237
|
+
}
|
|
238
|
+
else if (placeholders > 0) {
|
|
239
|
+
info.commaScale++;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
hasLiteral = true;
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (c === "%") {
|
|
247
|
+
info.percent++;
|
|
248
|
+
afterDot = false;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if ((c === "E" || c === "e") &&
|
|
252
|
+
(body[i + 1] === "+" || body[i + 1] === "-")) {
|
|
253
|
+
hasScientific = true;
|
|
254
|
+
i++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === "/") {
|
|
258
|
+
if (placeholders > 0)
|
|
259
|
+
hasFraction = true;
|
|
260
|
+
else
|
|
261
|
+
hasLiteral = true;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (c === "@") {
|
|
265
|
+
hasText = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (isDateLetter(c) || c === "a" || c === "A" || c === "p" || c === "P") {
|
|
269
|
+
if (c === "a" || c === "A" || c === "p" || c === "P") {
|
|
270
|
+
// AM/PM or A/P markers
|
|
271
|
+
const rest = body.slice(i).toUpperCase();
|
|
272
|
+
if (rest.startsWith("AM/PM") || rest.startsWith("A/P")) {
|
|
273
|
+
hasDate = true;
|
|
274
|
+
i += rest.startsWith("AM/PM") ? 4 : 2;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
hasOtherLetter = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
hasDate = true;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (/[A-Za-z]/.test(c)) {
|
|
284
|
+
hasOtherLetter = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// $ - + ( ) : space and other literal symbols
|
|
288
|
+
hasLiteral = true;
|
|
289
|
+
}
|
|
290
|
+
if (hasOtherLetter) {
|
|
291
|
+
info.kind = "other";
|
|
292
|
+
}
|
|
293
|
+
else if (hasText) {
|
|
294
|
+
info.kind = "text";
|
|
295
|
+
}
|
|
296
|
+
else if (hasDate) {
|
|
297
|
+
info.kind = "date";
|
|
298
|
+
}
|
|
299
|
+
else if (hasScientific || hasFraction) {
|
|
300
|
+
info.kind = "other";
|
|
301
|
+
}
|
|
302
|
+
else if (placeholders === 0) {
|
|
303
|
+
info.kind = "other";
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
info.kind = "number";
|
|
307
|
+
}
|
|
308
|
+
info.isPlainNumber =
|
|
309
|
+
info.kind === "number" && !hasLiteral && !info.hasCondition;
|
|
310
|
+
info.volatileSign = placeholders > 0 && !hasFraction;
|
|
311
|
+
return info;
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* Which section Excel applies to a numeric value: one section covers every
|
|
315
|
+
* number; two sections split positive-or-zero / negative; three add a zero
|
|
316
|
+
* section. Returns the index and whether the section's sign is implicit.
|
|
317
|
+
*/
|
|
318
|
+
export const pickNumberSection = (sectionCount, value) => {
|
|
319
|
+
const numeric = Math.min(sectionCount, 3);
|
|
320
|
+
if (numeric <= 1)
|
|
321
|
+
return { index: 0, implicitSign: true };
|
|
322
|
+
if (value < 0)
|
|
323
|
+
return { index: 1, implicitSign: false };
|
|
324
|
+
if (value === 0 && numeric >= 3)
|
|
325
|
+
return { index: 2, implicitSign: false };
|
|
326
|
+
return { index: 0, implicitSign: false };
|
|
327
|
+
};
|
|
328
|
+
const conditionHolds = ({ op, operand }, value) => {
|
|
329
|
+
switch (op) {
|
|
330
|
+
case "=":
|
|
331
|
+
return value === operand;
|
|
332
|
+
case ">":
|
|
333
|
+
return value > operand;
|
|
334
|
+
case "<":
|
|
335
|
+
return value < operand;
|
|
336
|
+
case ">=":
|
|
337
|
+
return value >= operand;
|
|
338
|
+
case "<=":
|
|
339
|
+
return value <= operand;
|
|
340
|
+
default:
|
|
341
|
+
return value !== operand;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* A condition only negative numbers satisfy. Excel renders such a section
|
|
346
|
+
* without the value's `-` (`[<-25]0` of -50 is `50`); every other conditional
|
|
347
|
+
* section shows it (`[<0]"neg"` of -3 is `-neg`).
|
|
348
|
+
*/
|
|
349
|
+
const matchesOnlyNegatives = (cond) => cond !== undefined &&
|
|
350
|
+
cond.operand < 0 &&
|
|
351
|
+
(cond.op === "<" || cond.op === "<=" || cond.op === "=");
|
|
352
|
+
/**
|
|
353
|
+
* Excel's section selection for a format with `[>100]`-style conditions:
|
|
354
|
+
* the first of the (up to three) numeric sections whose condition holds —
|
|
355
|
+
* an unconditional section always holds — or `undefined` when none does.
|
|
356
|
+
* `conditions[i]` is section i's condition (or `undefined`).
|
|
357
|
+
*/
|
|
358
|
+
export const resolveConditionalSection = (conditions, value) => {
|
|
359
|
+
const parts = conditions.length === 1 ? [conditions[0], undefined] : conditions.slice(0, 4);
|
|
360
|
+
const first = parts[0];
|
|
361
|
+
const showsSign = (index) => {
|
|
362
|
+
const cond = parts[index];
|
|
363
|
+
if (parts.length <= 2 && index === 1 && cond === undefined) {
|
|
364
|
+
// The unconditional second section of a two-section conditional
|
|
365
|
+
// format shows the sign only when the first condition leaves the
|
|
366
|
+
// negatives to it.
|
|
367
|
+
return (first !== undefined &&
|
|
368
|
+
(first.op === "=" ||
|
|
369
|
+
(first.operand >= 0 && (first.op === ">" || first.op === ">="))));
|
|
370
|
+
}
|
|
371
|
+
return !matchesOnlyNegatives(cond);
|
|
372
|
+
};
|
|
373
|
+
for (let index = 0; index < Math.min(parts.length, 3); index++) {
|
|
374
|
+
const cond = parts[index];
|
|
375
|
+
if (cond === undefined || conditionHolds(cond, value)) {
|
|
376
|
+
return {
|
|
377
|
+
index,
|
|
378
|
+
generated: index >= conditions.length,
|
|
379
|
+
minus: showsSign(index),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
};
|
|
385
|
+
/** Remove the `[>100]`-style condition tokens from one section. */
|
|
386
|
+
export const stripConditionTokens = (section) => {
|
|
387
|
+
let out = "";
|
|
388
|
+
let inQuotes = false;
|
|
389
|
+
for (let i = 0; i < section.length; i++) {
|
|
390
|
+
const c = section[i];
|
|
391
|
+
if (inQuotes) {
|
|
392
|
+
out += c;
|
|
393
|
+
if (c === '"')
|
|
394
|
+
inQuotes = false;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (c === "\\" && i + 1 < section.length) {
|
|
398
|
+
out += c + section[i + 1];
|
|
399
|
+
i++;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (c === '"') {
|
|
403
|
+
inQuotes = true;
|
|
404
|
+
out += c;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (c === "[") {
|
|
408
|
+
const end = section.indexOf("]", i);
|
|
409
|
+
if (end !== -1 && /^[<>=]/.test(section.slice(i + 1, end))) {
|
|
410
|
+
i = end;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
out += c;
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Decompose |x| into 15 significant decimal digits: x = digits × 10^exp10
|
|
420
|
+
* where `digits` is a 15-character string and exp10 = exponent − 14.
|
|
421
|
+
*/
|
|
422
|
+
const decimal15 = (x) => {
|
|
423
|
+
const [mantissa, exponent] = Math.abs(x).toExponential(14).split("e");
|
|
424
|
+
return { digits: mantissa.replace(".", ""), exp10: Number(exponent) - 14 };
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Round |x|·10^shift half away from zero to `decimals` places using its
|
|
428
|
+
* 15-significant-digit decimal representation, and return the rounded value
|
|
429
|
+
* scaled back (÷10^shift). Returns `x` unchanged when no digit is dropped.
|
|
430
|
+
*/
|
|
431
|
+
export const roundDecimal15 = (x, decimals, shift = 0) => {
|
|
432
|
+
if (!Number.isFinite(x) || x === 0)
|
|
433
|
+
return x;
|
|
434
|
+
const { digits, exp10 } = decimal15(x);
|
|
435
|
+
const exp = exp10 + shift;
|
|
436
|
+
if (exp >= -decimals)
|
|
437
|
+
return x;
|
|
438
|
+
const drop = -decimals - exp;
|
|
439
|
+
const sign = x < 0 ? -1 : 1;
|
|
440
|
+
if (drop > 15)
|
|
441
|
+
return sign * 0;
|
|
442
|
+
const mantissa = Number(digits); // ≤ 15 digits, exact
|
|
443
|
+
const p = 10 ** drop;
|
|
444
|
+
const remainder = mantissa % p;
|
|
445
|
+
let quotient = (mantissa - remainder) / p;
|
|
446
|
+
if (remainder >= p / 2)
|
|
447
|
+
quotient += 1;
|
|
448
|
+
const scale = decimals + shift;
|
|
449
|
+
const rounded = scale >= 0 ? quotient / 10 ** scale : quotient * 10 ** -scale;
|
|
450
|
+
return sign * rounded;
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* |v| rounded half away from zero to `precision` significant digits on its
|
|
454
|
+
* 15-digit decimal representation, as (`digits`, decimal exponent of the
|
|
455
|
+
* first digit). `toPrecision` would round the binary double instead.
|
|
456
|
+
*/
|
|
457
|
+
const significantDigits = (v, precision) => {
|
|
458
|
+
const { digits, exp10 } = decimal15(v);
|
|
459
|
+
let exponent = exp10 + 14;
|
|
460
|
+
if (precision >= 15)
|
|
461
|
+
return { digits, exponent };
|
|
462
|
+
const drop = 15 - precision;
|
|
463
|
+
const mantissa = Number(digits);
|
|
464
|
+
const p = 10 ** drop;
|
|
465
|
+
const remainder = mantissa % p;
|
|
466
|
+
let quotient = (mantissa - remainder) / p;
|
|
467
|
+
if (remainder >= p / 2)
|
|
468
|
+
quotient += 1;
|
|
469
|
+
if (quotient >= 10 ** precision) {
|
|
470
|
+
quotient /= 10;
|
|
471
|
+
exponent += 1;
|
|
472
|
+
}
|
|
473
|
+
return { digits: String(quotient).padStart(precision, "0"), exponent };
|
|
474
|
+
};
|
|
475
|
+
/** `precision` significant digits of |v| in fixed notation. */
|
|
476
|
+
const toPrecisionFixed = (v, precision) => {
|
|
477
|
+
const { digits, exponent } = significantDigits(v, precision);
|
|
478
|
+
if (exponent >= 0) {
|
|
479
|
+
const intLength = exponent + 1;
|
|
480
|
+
if (digits.length <= intLength)
|
|
481
|
+
return digits.padEnd(intLength, "0");
|
|
482
|
+
return `${digits.slice(0, intLength)}.${digits.slice(intLength)}`;
|
|
483
|
+
}
|
|
484
|
+
return `0.${"0".repeat(-exponent - 1)}${digits}`;
|
|
485
|
+
};
|
|
486
|
+
/** Excel `General` for a finite number, using `.`/`E` (caller localizes). */
|
|
487
|
+
export const excelGeneral = (value) => {
|
|
488
|
+
if (value === 0 || !Number.isFinite(value))
|
|
489
|
+
return "0";
|
|
490
|
+
const negative = value < 0;
|
|
491
|
+
const v = Math.abs(value);
|
|
492
|
+
const exponent = decimal15(v).exp10 + 14;
|
|
493
|
+
let out;
|
|
494
|
+
if (exponent >= -4 && exponent <= -1) {
|
|
495
|
+
out = stripTrailingZeros(toPrecisionFixed(v, 10 + exponent));
|
|
496
|
+
}
|
|
497
|
+
else if (exponent >= 0 && exponent <= 9) {
|
|
498
|
+
out = stripTrailingZeros(toPrecisionFixed(v, 10));
|
|
499
|
+
}
|
|
500
|
+
else if (exponent === 10) {
|
|
501
|
+
out = toPrecisionFixed(v, 11);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
out = generalScientific(v);
|
|
505
|
+
}
|
|
506
|
+
if (out.length > 11)
|
|
507
|
+
out = generalScientific(v);
|
|
508
|
+
return negative ? `-${out}` : out;
|
|
509
|
+
};
|
|
510
|
+
const generalScientific = (v) => {
|
|
511
|
+
const { digits, exponent } = significantDigits(v, 6);
|
|
512
|
+
const m = stripTrailingZeros(`${digits[0]}.${digits.slice(1)}`);
|
|
513
|
+
const sign = exponent < 0 ? "-" : "+";
|
|
514
|
+
return `${m}E${sign}${String(Math.abs(exponent)).padStart(2, "0")}`;
|
|
515
|
+
};
|
|
516
|
+
const stripTrailingZeros = (s) => {
|
|
517
|
+
if (!s.includes("."))
|
|
518
|
+
return s;
|
|
519
|
+
return s.replace(/\.?0+$/, "");
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* Digits of |x| for |x| ≥ 1E15 as Excel shows them: the 15 significant
|
|
523
|
+
* digits followed by zeros (Excel stores nothing beyond 15 digits).
|
|
524
|
+
*/
|
|
525
|
+
export const hugeIntegerDigits = (x) => {
|
|
526
|
+
const { digits, exp10 } = decimal15(x);
|
|
527
|
+
if (exp10 >= 0)
|
|
528
|
+
return digits + "0".repeat(exp10);
|
|
529
|
+
// |x| ≥ 1E15 always has exp10 ≥ 0; guard anyway.
|
|
530
|
+
return digits.slice(0, digits.length + exp10);
|
|
531
|
+
};
|
|
532
|
+
export const groupDigits = (digits, separator) => {
|
|
533
|
+
let out = "";
|
|
534
|
+
for (let i = 0; i < digits.length; i++) {
|
|
535
|
+
const fromEnd = digits.length - i;
|
|
536
|
+
if (i > 0 && fromEnd % 3 === 0)
|
|
537
|
+
out += separator;
|
|
538
|
+
out += digits[i];
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
};
|
|
542
|
+
const HUGE = 1e15;
|
|
543
|
+
const GENERAL_SECTION = analyzeFormatSection("General");
|
|
544
|
+
/**
|
|
545
|
+
* Excel's decimal rules for one non-date section. `signPrefix` is prepended
|
|
546
|
+
* to a rendering produced here; `value` keeps its sign for the library.
|
|
547
|
+
*/
|
|
548
|
+
const prePassSection = (info, value, signPrefix, l10n) => {
|
|
549
|
+
const magnitude = Math.abs(value);
|
|
550
|
+
switch (info.kind) {
|
|
551
|
+
case "empty":
|
|
552
|
+
return { text: "", value };
|
|
553
|
+
case "general": {
|
|
554
|
+
const rendered = excelGeneral(magnitude).replace(".", l10n.decimal);
|
|
555
|
+
return { text: `${signPrefix}${info.generalPrefix}${rendered}`, value };
|
|
556
|
+
}
|
|
557
|
+
case "number": {
|
|
558
|
+
const shift = 2 * info.percent - 3 * info.commaScale;
|
|
559
|
+
if (magnitude >= HUGE && info.isPlainNumber && shift === 0) {
|
|
560
|
+
const digits = hugeIntegerDigits(value);
|
|
561
|
+
const intPart = info.grouping
|
|
562
|
+
? groupDigits(digits, l10n.group)
|
|
563
|
+
: digits;
|
|
564
|
+
const frac = info.decimals > 0
|
|
565
|
+
? `${l10n.decimal}${"0".repeat(info.decimals)}`
|
|
566
|
+
: "";
|
|
567
|
+
return { text: `${signPrefix}${intPart}${frac}`, value };
|
|
568
|
+
}
|
|
569
|
+
if (magnitude * 10 ** shift >= HUGE)
|
|
570
|
+
return { value };
|
|
571
|
+
return { value: roundDecimal15(value, info.decimals, shift) };
|
|
572
|
+
}
|
|
573
|
+
default:
|
|
574
|
+
return { value };
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* A format with conditions: pick the section Excel applies, render that
|
|
579
|
+
* section alone for the magnitude (so it gets the decimal rules), and show
|
|
580
|
+
* the sign by Excel's conditional-section rule.
|
|
581
|
+
*/
|
|
582
|
+
const renderConditional = (sections, infos, value, l10n, render) => {
|
|
583
|
+
const resolved = resolveConditionalSection(infos.map((info) => info.condition), value);
|
|
584
|
+
if (!resolved)
|
|
585
|
+
return { text: DATE_OVERFLOW_DISPLAY, value };
|
|
586
|
+
const info = resolved.generated ? GENERAL_SECTION : infos[resolved.index];
|
|
587
|
+
const code = resolved.generated
|
|
588
|
+
? "General"
|
|
589
|
+
: stripConditionTokens(sections[resolved.index]);
|
|
590
|
+
if (info.kind === "date" && !info.isElapsed) {
|
|
591
|
+
// A date never carries a sign; the calendar rule applies to the serial.
|
|
592
|
+
if (value < 0 || value >= MAX_DATE_SERIAL_EXCLUSIVE) {
|
|
593
|
+
return { text: DATE_OVERFLOW_DISPLAY, value };
|
|
594
|
+
}
|
|
595
|
+
return { text: render(code, value), value };
|
|
596
|
+
}
|
|
597
|
+
const pre = prePassSection(info, Math.abs(value), "", l10n);
|
|
598
|
+
const rendered = pre.text ?? render(code, pre.value);
|
|
599
|
+
// Elapsed durations keep their `-36:00` policy; the volatile sign is
|
|
600
|
+
// dropped when every rendered digit is zero.
|
|
601
|
+
const showSign = value < 0 &&
|
|
602
|
+
(info.isElapsed ||
|
|
603
|
+
(resolved.minus && (!info.volatileSign || /[1-9]/.test(rendered))));
|
|
604
|
+
return { text: showSign ? `-${rendered}` : rendered, value };
|
|
605
|
+
};
|
|
606
|
+
/**
|
|
607
|
+
* Apply Excel's decimal rules before handing a numeric value to `numfmt`.
|
|
608
|
+
* `render` formats one section of the pattern with the library; it is used
|
|
609
|
+
* when the pre-pass has to resolve a conditional format itself.
|
|
610
|
+
*/
|
|
611
|
+
export const excelNumberPrePass = (pattern, value, l10n, render) => {
|
|
612
|
+
if (!Number.isFinite(value) || pattern.trim() === "")
|
|
613
|
+
return { value };
|
|
614
|
+
const sections = splitFormatSections(pattern);
|
|
615
|
+
const infos = sections.map(analyzeFormatSection);
|
|
616
|
+
if (infos.some((s) => s.hasCondition)) {
|
|
617
|
+
return renderConditional(sections, infos, value, l10n, render);
|
|
618
|
+
}
|
|
619
|
+
const { index, implicitSign } = pickNumberSection(infos.length, value);
|
|
620
|
+
const info = infos[index];
|
|
621
|
+
if (!info)
|
|
622
|
+
return { value };
|
|
623
|
+
if (info.kind === "date") {
|
|
624
|
+
if (info.isElapsed && sections.length === 1)
|
|
625
|
+
return { value };
|
|
626
|
+
if (value < 0 || value >= MAX_DATE_SERIAL_EXCLUSIVE) {
|
|
627
|
+
return { text: DATE_OVERFLOW_DISPLAY, value };
|
|
628
|
+
}
|
|
629
|
+
return { value };
|
|
630
|
+
}
|
|
631
|
+
const pre = prePassSection(info, value, implicitSign && value < 0 ? "-" : "", l10n);
|
|
632
|
+
// Excel picks the section from the value's sign, then rounds: a tiny
|
|
633
|
+
// negative under an explicit negative section still renders that section
|
|
634
|
+
// (`0.00;(0.00)` of -0.001 is `(0.00)`). The library would see the rounded
|
|
635
|
+
// -0 as positive, so render the negative section for the zero magnitude.
|
|
636
|
+
if (index === 1 && value < 0 && pre.text === undefined && pre.value === 0) {
|
|
637
|
+
return { text: render(sections[index], 0), value };
|
|
638
|
+
}
|
|
639
|
+
return pre;
|
|
640
|
+
};
|
package/dist/esm/formatter.d.ts
CHANGED
|
@@ -23,6 +23,14 @@ export declare const isTextPattern: (pattern: string | undefined | null) => bool
|
|
|
23
23
|
* characters are skipped; `ddd`/`dddd` are weekday names, not a day.
|
|
24
24
|
*/
|
|
25
25
|
export declare const isStandaloneMonthPattern: (pattern: string) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Does the pattern carry Excel's short `A/P` marker outside quoted literals,
|
|
28
|
+
* bracket directives and escapes? Returns the pattern with the marker
|
|
29
|
+
* upper-cased (Excel accepts `a/p` and renders it `A`/`P`; numfmt only
|
|
30
|
+
* tokenizes `A/P`) and its `[$-LCID]` tokens re-pointed at the companion
|
|
31
|
+
* `A`/`P` locale, or `undefined` when there is none.
|
|
32
|
+
*/
|
|
33
|
+
export declare const withShortAmPmMarker: (pattern: string) => string | undefined;
|
|
26
34
|
export declare const ssfFormat: (pattern: string, value: any, locale?: string) => string;
|
|
27
35
|
export declare const ssfFormatColor: (pattern: string, value: any) => string | undefined;
|
|
28
36
|
//# sourceMappingURL=formatter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formatter.d.ts","sourceRoot":"","sources":["../../formatter.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"formatter.d.ts","sourceRoot":"","sources":["../../formatter.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,GAAG,SAAS,GAAG,IAAI,KAAG,OASlE,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAC5B,SAAS,MAAM,GAAG,SAAS,GAAG,IAAI,KACjC,OAWF,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,GAAG,SAAS,GAAG,IAAI,KAAG,OAWlE,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,GAAG,SAAS,GAAG,IAAI,KAAG,OAOlE,CAAC;AAMF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,MAAM,KAAG,OA4B1D,CAAC;AAuHF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS,MAAM,KAAG,MAAM,GAAG,SA4C9D,CAAC;AAgBF,eAAO,MAAM,SAAS,GACpB,SAAS,MAAM,EACf,OAAO,GAAG,EACV,SAAS,MAAM,KACd,MAyCF,CAAC;AAmLF,eAAO,MAAM,cAAc,GACzB,SAAS,MAAM,EACf,OAAO,GAAG,KACT,MAAM,GAAG,SAoBX,CAAC"}
|