@niicojs/excel 0.3.1 → 0.3.2
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/LICENSE +20 -20
- package/README.md +585 -585
- package/dist/index.cjs +740 -392
- package/dist/index.d.cts +18 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +740 -392
- package/package.json +3 -3
- package/src/cell.ts +14 -0
- package/src/index.ts +45 -45
- package/src/pivot-cache.ts +300 -300
- package/src/pivot-table.ts +684 -684
- package/src/range.ts +154 -154
- package/src/shared-strings.ts +178 -178
- package/src/styles.ts +819 -819
- package/src/table.ts +386 -386
- package/src/types.ts +16 -10
- package/src/utils/address.ts +121 -121
- package/src/utils/format.ts +356 -0
- package/src/utils/xml.ts +140 -140
- package/src/workbook.ts +1406 -1390
- package/src/worksheet.ts +85 -84
package/src/types.ts
CHANGED
|
@@ -305,19 +305,25 @@ export interface SheetToJsonConfig {
|
|
|
305
305
|
*/
|
|
306
306
|
endCol?: number;
|
|
307
307
|
|
|
308
|
-
/**
|
|
309
|
-
* If true, stop reading when an empty row is encountered. Defaults to true.
|
|
310
|
-
*/
|
|
311
|
-
stopOnEmptyRow?: boolean;
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* How to serialize Date values. Defaults to 'jsDate'.
|
|
315
|
-
*/
|
|
316
|
-
dateHandling?: DateHandling;
|
|
317
|
-
|
|
308
|
+
/**
|
|
309
|
+
* If true, stop reading when an empty row is encountered. Defaults to true.
|
|
310
|
+
*/
|
|
311
|
+
stopOnEmptyRow?: boolean;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* How to serialize Date values. Defaults to 'jsDate'.
|
|
315
|
+
*/
|
|
316
|
+
dateHandling?: DateHandling;
|
|
317
|
+
|
|
318
318
|
/**
|
|
319
319
|
* If true, return formatted text (as displayed in Excel) instead of raw values.
|
|
320
320
|
* All values will be returned as strings. Defaults to false.
|
|
321
321
|
*/
|
|
322
322
|
asText?: boolean;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Locale to use for formatting when asText is true.
|
|
326
|
+
* Defaults to the workbook locale.
|
|
327
|
+
*/
|
|
328
|
+
locale?: string;
|
|
323
329
|
}
|
package/src/utils/address.ts
CHANGED
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
import type { CellAddress, RangeAddress } from '../types';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Converts a column index (0-based) to Excel column letters (A, B, ..., Z, AA, AB, ...)
|
|
5
|
-
* @param col - 0-based column index
|
|
6
|
-
* @returns Column letter(s)
|
|
7
|
-
*/
|
|
8
|
-
export const colToLetter = (col: number): string => {
|
|
9
|
-
let result = '';
|
|
10
|
-
let n = col;
|
|
11
|
-
while (n >= 0) {
|
|
12
|
-
result = String.fromCharCode((n % 26) + 65) + result;
|
|
13
|
-
n = Math.floor(n / 26) - 1;
|
|
14
|
-
}
|
|
15
|
-
return result;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Converts Excel column letters to a 0-based column index
|
|
20
|
-
* @param letters - Column letter(s) like 'A', 'B', 'AA'
|
|
21
|
-
* @returns 0-based column index
|
|
22
|
-
*/
|
|
23
|
-
export const letterToCol = (letters: string): number => {
|
|
24
|
-
const upper = letters.toUpperCase();
|
|
25
|
-
let col = 0;
|
|
26
|
-
for (let i = 0; i < upper.length; i++) {
|
|
27
|
-
col = col * 26 + (upper.charCodeAt(i) - 64);
|
|
28
|
-
}
|
|
29
|
-
return col - 1;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Parses an Excel cell address (e.g., 'A1', '$B$2') to row/col indices
|
|
34
|
-
* @param address - Cell address string
|
|
35
|
-
* @returns CellAddress with 0-based row and col
|
|
36
|
-
*/
|
|
37
|
-
export const parseAddress = (address: string): CellAddress => {
|
|
38
|
-
// Remove $ signs for absolute references
|
|
39
|
-
const clean = address.replace(/\$/g, '');
|
|
40
|
-
const match = clean.match(/^([A-Z]+)(\d+)$/i);
|
|
41
|
-
if (!match) {
|
|
42
|
-
throw new Error(`Invalid cell address: ${address}`);
|
|
43
|
-
}
|
|
44
|
-
const rowNumber = +match[2];
|
|
45
|
-
if (rowNumber <= 0) throw new Error(`Invalid cell address: ${address}`);
|
|
46
|
-
|
|
47
|
-
const col = letterToCol(match[1].toUpperCase());
|
|
48
|
-
const row = rowNumber - 1; // Convert to 0-based
|
|
49
|
-
return { row, col };
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Converts row/col indices to an Excel cell address
|
|
54
|
-
* @param row - 0-based row index
|
|
55
|
-
* @param col - 0-based column index
|
|
56
|
-
* @returns Cell address string like 'A1'
|
|
57
|
-
*/
|
|
58
|
-
export const toAddress = (row: number, col: number): string => {
|
|
59
|
-
return `${colToLetter(col)}${row + 1}`;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Parses an Excel range (e.g., 'A1:B10') to start/end addresses
|
|
64
|
-
* @param range - Range string
|
|
65
|
-
* @returns RangeAddress with start and end
|
|
66
|
-
*/
|
|
67
|
-
export const parseRange = (range: string): RangeAddress => {
|
|
68
|
-
const parts = range.split(':');
|
|
69
|
-
if (parts.length === 1) {
|
|
70
|
-
// Single cell range
|
|
71
|
-
const addr = parseAddress(parts[0]);
|
|
72
|
-
return { start: addr, end: addr };
|
|
73
|
-
}
|
|
74
|
-
if (parts.length !== 2) {
|
|
75
|
-
throw new Error(`Invalid range: ${range}`);
|
|
76
|
-
}
|
|
77
|
-
return {
|
|
78
|
-
start: parseAddress(parts[0]),
|
|
79
|
-
end: parseAddress(parts[1]),
|
|
80
|
-
};
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Converts a RangeAddress to a range string
|
|
85
|
-
* @param range - RangeAddress object
|
|
86
|
-
* @returns Range string like 'A1:B10'
|
|
87
|
-
*/
|
|
88
|
-
export const toRange = (range: RangeAddress): string => {
|
|
89
|
-
const start = toAddress(range.start.row, range.start.col);
|
|
90
|
-
const end = toAddress(range.end.row, range.end.col);
|
|
91
|
-
if (start === end) {
|
|
92
|
-
return start;
|
|
93
|
-
}
|
|
94
|
-
return `${start}:${end}`;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Normalizes a range so start is always top-left and end is bottom-right
|
|
99
|
-
*/
|
|
100
|
-
export const normalizeRange = (range: RangeAddress): RangeAddress => {
|
|
101
|
-
return {
|
|
102
|
-
start: {
|
|
103
|
-
row: Math.min(range.start.row, range.end.row),
|
|
104
|
-
col: Math.min(range.start.col, range.end.col),
|
|
105
|
-
},
|
|
106
|
-
end: {
|
|
107
|
-
row: Math.max(range.start.row, range.end.row),
|
|
108
|
-
col: Math.max(range.start.col, range.end.col),
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Checks if an address is within a range
|
|
115
|
-
*/
|
|
116
|
-
export const isInRange = (addr: CellAddress, range: RangeAddress): boolean => {
|
|
117
|
-
const norm = normalizeRange(range);
|
|
118
|
-
return (
|
|
119
|
-
addr.row >= norm.start.row && addr.row <= norm.end.row && addr.col >= norm.start.col && addr.col <= norm.end.col
|
|
120
|
-
);
|
|
121
|
-
};
|
|
1
|
+
import type { CellAddress, RangeAddress } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts a column index (0-based) to Excel column letters (A, B, ..., Z, AA, AB, ...)
|
|
5
|
+
* @param col - 0-based column index
|
|
6
|
+
* @returns Column letter(s)
|
|
7
|
+
*/
|
|
8
|
+
export const colToLetter = (col: number): string => {
|
|
9
|
+
let result = '';
|
|
10
|
+
let n = col;
|
|
11
|
+
while (n >= 0) {
|
|
12
|
+
result = String.fromCharCode((n % 26) + 65) + result;
|
|
13
|
+
n = Math.floor(n / 26) - 1;
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts Excel column letters to a 0-based column index
|
|
20
|
+
* @param letters - Column letter(s) like 'A', 'B', 'AA'
|
|
21
|
+
* @returns 0-based column index
|
|
22
|
+
*/
|
|
23
|
+
export const letterToCol = (letters: string): number => {
|
|
24
|
+
const upper = letters.toUpperCase();
|
|
25
|
+
let col = 0;
|
|
26
|
+
for (let i = 0; i < upper.length; i++) {
|
|
27
|
+
col = col * 26 + (upper.charCodeAt(i) - 64);
|
|
28
|
+
}
|
|
29
|
+
return col - 1;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parses an Excel cell address (e.g., 'A1', '$B$2') to row/col indices
|
|
34
|
+
* @param address - Cell address string
|
|
35
|
+
* @returns CellAddress with 0-based row and col
|
|
36
|
+
*/
|
|
37
|
+
export const parseAddress = (address: string): CellAddress => {
|
|
38
|
+
// Remove $ signs for absolute references
|
|
39
|
+
const clean = address.replace(/\$/g, '');
|
|
40
|
+
const match = clean.match(/^([A-Z]+)(\d+)$/i);
|
|
41
|
+
if (!match) {
|
|
42
|
+
throw new Error(`Invalid cell address: ${address}`);
|
|
43
|
+
}
|
|
44
|
+
const rowNumber = +match[2];
|
|
45
|
+
if (rowNumber <= 0) throw new Error(`Invalid cell address: ${address}`);
|
|
46
|
+
|
|
47
|
+
const col = letterToCol(match[1].toUpperCase());
|
|
48
|
+
const row = rowNumber - 1; // Convert to 0-based
|
|
49
|
+
return { row, col };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Converts row/col indices to an Excel cell address
|
|
54
|
+
* @param row - 0-based row index
|
|
55
|
+
* @param col - 0-based column index
|
|
56
|
+
* @returns Cell address string like 'A1'
|
|
57
|
+
*/
|
|
58
|
+
export const toAddress = (row: number, col: number): string => {
|
|
59
|
+
return `${colToLetter(col)}${row + 1}`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parses an Excel range (e.g., 'A1:B10') to start/end addresses
|
|
64
|
+
* @param range - Range string
|
|
65
|
+
* @returns RangeAddress with start and end
|
|
66
|
+
*/
|
|
67
|
+
export const parseRange = (range: string): RangeAddress => {
|
|
68
|
+
const parts = range.split(':');
|
|
69
|
+
if (parts.length === 1) {
|
|
70
|
+
// Single cell range
|
|
71
|
+
const addr = parseAddress(parts[0]);
|
|
72
|
+
return { start: addr, end: addr };
|
|
73
|
+
}
|
|
74
|
+
if (parts.length !== 2) {
|
|
75
|
+
throw new Error(`Invalid range: ${range}`);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
start: parseAddress(parts[0]),
|
|
79
|
+
end: parseAddress(parts[1]),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Converts a RangeAddress to a range string
|
|
85
|
+
* @param range - RangeAddress object
|
|
86
|
+
* @returns Range string like 'A1:B10'
|
|
87
|
+
*/
|
|
88
|
+
export const toRange = (range: RangeAddress): string => {
|
|
89
|
+
const start = toAddress(range.start.row, range.start.col);
|
|
90
|
+
const end = toAddress(range.end.row, range.end.col);
|
|
91
|
+
if (start === end) {
|
|
92
|
+
return start;
|
|
93
|
+
}
|
|
94
|
+
return `${start}:${end}`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalizes a range so start is always top-left and end is bottom-right
|
|
99
|
+
*/
|
|
100
|
+
export const normalizeRange = (range: RangeAddress): RangeAddress => {
|
|
101
|
+
return {
|
|
102
|
+
start: {
|
|
103
|
+
row: Math.min(range.start.row, range.end.row),
|
|
104
|
+
col: Math.min(range.start.col, range.end.col),
|
|
105
|
+
},
|
|
106
|
+
end: {
|
|
107
|
+
row: Math.max(range.start.row, range.end.row),
|
|
108
|
+
col: Math.max(range.start.col, range.end.col),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks if an address is within a range
|
|
115
|
+
*/
|
|
116
|
+
export const isInRange = (addr: CellAddress, range: RangeAddress): boolean => {
|
|
117
|
+
const norm = normalizeRange(range);
|
|
118
|
+
return (
|
|
119
|
+
addr.row >= norm.start.row && addr.row <= norm.end.row && addr.col >= norm.start.col && addr.col <= norm.end.col
|
|
120
|
+
);
|
|
121
|
+
};
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { CellStyle } from '../types';
|
|
2
|
+
|
|
3
|
+
type NumberFormatInfo = {
|
|
4
|
+
fractionDigits: number;
|
|
5
|
+
percent: boolean;
|
|
6
|
+
useGrouping: boolean;
|
|
7
|
+
literalPrefix?: string;
|
|
8
|
+
literalSuffix?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type LocaleNumberInfo = {
|
|
12
|
+
decimal: string;
|
|
13
|
+
group: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LOCALE = 'fr-FR';
|
|
17
|
+
|
|
18
|
+
const formatPartsCache = new Map<string, Intl.NumberFormatPart[]>();
|
|
19
|
+
|
|
20
|
+
const getLocaleNumberInfo = (locale: string): LocaleNumberInfo => {
|
|
21
|
+
const cacheKey = `num-info:${locale}`;
|
|
22
|
+
const cached = formatPartsCache.get(cacheKey);
|
|
23
|
+
if (cached) {
|
|
24
|
+
const decimal = cached.find((p) => p.type === 'decimal')?.value ?? ',';
|
|
25
|
+
const group = cached.find((p) => p.type === 'group')?.value ?? ' ';
|
|
26
|
+
return { decimal, group };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
|
|
30
|
+
formatPartsCache.set(cacheKey, parts);
|
|
31
|
+
const decimal = parts.find((p) => p.type === 'decimal')?.value ?? ',';
|
|
32
|
+
const group = parts.find((p) => p.type === 'group')?.value ?? ' ';
|
|
33
|
+
return { decimal, group };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const normalizeSpaces = (value: string): string => {
|
|
37
|
+
return value.replace(/[\u00a0\u202f]/g, ' ');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const splitFormatSections = (format: string): string[] => {
|
|
41
|
+
const sections = [] as string[];
|
|
42
|
+
let current = '';
|
|
43
|
+
let inQuote = false;
|
|
44
|
+
for (let i = 0; i < format.length; i++) {
|
|
45
|
+
const ch = format[i];
|
|
46
|
+
if (ch === '"') {
|
|
47
|
+
inQuote = !inQuote;
|
|
48
|
+
current += ch;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === ';' && !inQuote) {
|
|
52
|
+
sections.push(current);
|
|
53
|
+
current = '';
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
current += ch;
|
|
57
|
+
}
|
|
58
|
+
sections.push(current);
|
|
59
|
+
return sections;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const extractFormatLiterals = (format: string): { cleaned: string; prefix: string; suffix: string } => {
|
|
63
|
+
let prefix = '';
|
|
64
|
+
let suffix = '';
|
|
65
|
+
let cleaned = '';
|
|
66
|
+
let inQuote = false;
|
|
67
|
+
let sawPlaceholder = false;
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < format.length; i++) {
|
|
70
|
+
const ch = format[i];
|
|
71
|
+
if (ch === '"') {
|
|
72
|
+
inQuote = !inQuote;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (inQuote) {
|
|
76
|
+
if (!sawPlaceholder) {
|
|
77
|
+
prefix += ch;
|
|
78
|
+
} else {
|
|
79
|
+
suffix += ch;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === '\\' && i + 1 < format.length) {
|
|
84
|
+
const escaped = format[i + 1];
|
|
85
|
+
if (!sawPlaceholder) {
|
|
86
|
+
prefix += escaped;
|
|
87
|
+
} else {
|
|
88
|
+
suffix += escaped;
|
|
89
|
+
}
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === '_' || ch === '*') {
|
|
94
|
+
if (i + 1 < format.length) {
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === '[') {
|
|
100
|
+
const end = format.indexOf(']', i + 1);
|
|
101
|
+
if (end !== -1) {
|
|
102
|
+
const content = format.slice(i + 1, end);
|
|
103
|
+
const currencyMatch = content.match(/[$€]/);
|
|
104
|
+
if (currencyMatch) {
|
|
105
|
+
if (!sawPlaceholder) {
|
|
106
|
+
prefix += currencyMatch[0];
|
|
107
|
+
} else {
|
|
108
|
+
suffix += currencyMatch[0];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
i = end;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (ch === '%') {
|
|
117
|
+
if (!sawPlaceholder) {
|
|
118
|
+
prefix += ch;
|
|
119
|
+
} else {
|
|
120
|
+
suffix += ch;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (ch === '0' || ch === '#' || ch === '?' || ch === '.' || ch === ',') {
|
|
126
|
+
sawPlaceholder = true;
|
|
127
|
+
cleaned += ch;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!sawPlaceholder) {
|
|
132
|
+
prefix += ch;
|
|
133
|
+
} else {
|
|
134
|
+
suffix += ch;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { cleaned, prefix, suffix };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const parseNumberFormat = (format: string): NumberFormatInfo | null => {
|
|
142
|
+
const trimmed = format.trim();
|
|
143
|
+
if (!trimmed) return null;
|
|
144
|
+
|
|
145
|
+
const { cleaned, prefix, suffix } = extractFormatLiterals(trimmed);
|
|
146
|
+
const lower = cleaned.toLowerCase();
|
|
147
|
+
if (!/[0#?]/.test(lower)) return null;
|
|
148
|
+
|
|
149
|
+
const percent = /%/.test(trimmed);
|
|
150
|
+
|
|
151
|
+
const section = lower;
|
|
152
|
+
const lastDot = section.lastIndexOf('.');
|
|
153
|
+
const lastComma = section.lastIndexOf(',');
|
|
154
|
+
let decimalSeparator: '.' | ',' | null = null;
|
|
155
|
+
|
|
156
|
+
if (lastDot >= 0 && lastComma >= 0) {
|
|
157
|
+
decimalSeparator = lastDot > lastComma ? '.' : ',';
|
|
158
|
+
} else if (lastDot >= 0 || lastComma >= 0) {
|
|
159
|
+
const candidate = lastDot >= 0 ? '.' : ',';
|
|
160
|
+
const index = lastDot >= 0 ? lastDot : lastComma;
|
|
161
|
+
const fractionSection = section.slice(index + 1);
|
|
162
|
+
const fractionDigitsCandidate = fractionSection.replace(/[^0#?]/g, '').length;
|
|
163
|
+
if (fractionDigitsCandidate > 0) {
|
|
164
|
+
if (fractionDigitsCandidate === 3) {
|
|
165
|
+
decimalSeparator = null;
|
|
166
|
+
} else {
|
|
167
|
+
decimalSeparator = candidate;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let decimalIndex = decimalSeparator === '.' ? lastDot : decimalSeparator === ',' ? lastComma : -1;
|
|
173
|
+
if (decimalIndex >= 0) {
|
|
174
|
+
const fractionSection = section.slice(decimalIndex + 1);
|
|
175
|
+
if (!/[0#?]/.test(fractionSection)) {
|
|
176
|
+
decimalIndex = -1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const decimalSection = decimalIndex >= 0 ? section.slice(decimalIndex + 1) : '';
|
|
180
|
+
const fractionDigits = decimalSection.replace(/[^0#?]/g, '').length;
|
|
181
|
+
const integerSection = decimalIndex >= 0 ? section.slice(0, decimalIndex) : section;
|
|
182
|
+
const useGrouping = /[,.\s\u00a0\u202f]/.test(integerSection) && integerSection.length > 0;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
fractionDigits,
|
|
186
|
+
percent,
|
|
187
|
+
useGrouping,
|
|
188
|
+
literalPrefix: prefix || undefined,
|
|
189
|
+
literalSuffix: suffix || undefined,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const formatNumber = (value: number, info: NumberFormatInfo, locale: string): string => {
|
|
194
|
+
const adjusted = info.percent ? value * 100 : value;
|
|
195
|
+
const { decimal, group } = getLocaleNumberInfo(locale);
|
|
196
|
+
const absValue = Math.abs(adjusted);
|
|
197
|
+
|
|
198
|
+
const fixed = absValue.toFixed(info.fractionDigits);
|
|
199
|
+
const [integerPart, fractionPart] = fixed.split('.');
|
|
200
|
+
const grouped = info.useGrouping ? integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, group) : integerPart;
|
|
201
|
+
const fraction = info.fractionDigits > 0 ? `${decimal}${fractionPart}` : '';
|
|
202
|
+
const signed = adjusted < 0 ? '-' : '';
|
|
203
|
+
|
|
204
|
+
let result = `${signed}${grouped}${fraction}`;
|
|
205
|
+
|
|
206
|
+
if (info.literalPrefix) {
|
|
207
|
+
result = `${info.literalPrefix}${result}`;
|
|
208
|
+
}
|
|
209
|
+
if (info.literalSuffix) {
|
|
210
|
+
result = `${result}${info.literalSuffix}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return normalizeSpaces(result);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const padNumber = (value: number, length: number): string => {
|
|
217
|
+
const str = String(value);
|
|
218
|
+
return str.length >= length ? str : `${'0'.repeat(length - str.length)}${str}`;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const formatDatePart = (value: Date, token: string, locale: string): string => {
|
|
222
|
+
switch (token) {
|
|
223
|
+
case 'yyyy':
|
|
224
|
+
return String(value.getFullYear());
|
|
225
|
+
case 'yy':
|
|
226
|
+
return padNumber(value.getFullYear() % 100, 2);
|
|
227
|
+
case 'mmmm':
|
|
228
|
+
return value.toLocaleString(locale, { month: 'long' });
|
|
229
|
+
case 'mmm':
|
|
230
|
+
return value.toLocaleString(locale, { month: 'short' });
|
|
231
|
+
case 'mm':
|
|
232
|
+
return padNumber(value.getMonth() + 1, 2);
|
|
233
|
+
case 'm':
|
|
234
|
+
return String(value.getMonth() + 1);
|
|
235
|
+
case 'dd':
|
|
236
|
+
return padNumber(value.getDate(), 2);
|
|
237
|
+
case 'd':
|
|
238
|
+
return String(value.getDate());
|
|
239
|
+
case 'hh': {
|
|
240
|
+
const hours = value.getHours();
|
|
241
|
+
return padNumber(hours, 2);
|
|
242
|
+
}
|
|
243
|
+
case 'h':
|
|
244
|
+
return String(value.getHours());
|
|
245
|
+
case 'min2':
|
|
246
|
+
return padNumber(value.getMinutes(), 2);
|
|
247
|
+
case 'min1':
|
|
248
|
+
return String(value.getMinutes());
|
|
249
|
+
case 'ss':
|
|
250
|
+
return padNumber(value.getSeconds(), 2);
|
|
251
|
+
case 's':
|
|
252
|
+
return String(value.getSeconds());
|
|
253
|
+
default:
|
|
254
|
+
return token;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const tokenizeDateFormat = (format: string): string[] => {
|
|
259
|
+
const tokens: string[] = [];
|
|
260
|
+
let i = 0;
|
|
261
|
+
while (i < format.length) {
|
|
262
|
+
const ch = format[i];
|
|
263
|
+
if (ch === '"') {
|
|
264
|
+
let literal = '';
|
|
265
|
+
i++;
|
|
266
|
+
while (i < format.length && format[i] !== '"') {
|
|
267
|
+
literal += format[i];
|
|
268
|
+
i++;
|
|
269
|
+
}
|
|
270
|
+
i++;
|
|
271
|
+
if (literal) tokens.push(literal);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (ch === '\\' && i + 1 < format.length) {
|
|
275
|
+
tokens.push(format[i + 1]);
|
|
276
|
+
i += 2;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (ch === '[') {
|
|
280
|
+
const end = format.indexOf(']', i + 1);
|
|
281
|
+
if (end !== -1) {
|
|
282
|
+
i = end + 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lower = format.slice(i).toLowerCase();
|
|
288
|
+
const match = ['yyyy', 'yy', 'mmmm', 'mmm', 'mm', 'm', 'dd', 'd', 'hh', 'h', 'ss', 's'].find((t) =>
|
|
289
|
+
lower.startsWith(t),
|
|
290
|
+
);
|
|
291
|
+
if (match) {
|
|
292
|
+
if (match === 'm' || match === 'mm') {
|
|
293
|
+
let j = i - 1;
|
|
294
|
+
let previousChar = '';
|
|
295
|
+
while (j >= 0 && previousChar === '') {
|
|
296
|
+
const candidate = format[j];
|
|
297
|
+
if (candidate && candidate !== ' ') {
|
|
298
|
+
previousChar = candidate;
|
|
299
|
+
}
|
|
300
|
+
j--;
|
|
301
|
+
}
|
|
302
|
+
const isMinute = previousChar === 'h' || previousChar === 'H' || previousChar === ':';
|
|
303
|
+
if (isMinute) {
|
|
304
|
+
tokens.push(match === 'mm' ? 'min2' : 'min1');
|
|
305
|
+
i += match.length;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
tokens.push(match);
|
|
311
|
+
i += match.length;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
tokens.push(ch);
|
|
316
|
+
i++;
|
|
317
|
+
}
|
|
318
|
+
return tokens;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const isDateFormat = (format: string): boolean => {
|
|
322
|
+
const lowered = format.toLowerCase();
|
|
323
|
+
return /[ymdhss]/.test(lowered);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const formatDate = (value: Date, format: string, locale: string): string => {
|
|
327
|
+
const tokens = tokenizeDateFormat(format);
|
|
328
|
+
return tokens.map((token) => formatDatePart(value, token, locale)).join('');
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export const formatCellValue = (
|
|
332
|
+
value: number | Date,
|
|
333
|
+
style: CellStyle | undefined,
|
|
334
|
+
locale?: string,
|
|
335
|
+
): string | null => {
|
|
336
|
+
const numberFormat = style?.numberFormat;
|
|
337
|
+
if (!numberFormat) return null;
|
|
338
|
+
|
|
339
|
+
const normalizedLocale = locale || DEFAULT_LOCALE;
|
|
340
|
+
const sections = splitFormatSections(numberFormat);
|
|
341
|
+
const hasNegativeSection = sections.length > 1;
|
|
342
|
+
const section = value instanceof Date ? sections[0] : value < 0 ? sections[1] ?? sections[0] : sections[0];
|
|
343
|
+
|
|
344
|
+
if (value instanceof Date && isDateFormat(section)) {
|
|
345
|
+
return formatDate(value, section, normalizedLocale);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (typeof value === 'number') {
|
|
349
|
+
const info = parseNumberFormat(section);
|
|
350
|
+
if (!info) return null;
|
|
351
|
+
const numericValue = value < 0 && hasNegativeSection ? Math.abs(value) : value;
|
|
352
|
+
return formatNumber(numericValue, info, normalizedLocale);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return null;
|
|
356
|
+
};
|