@rowsncolumns/utils 9.5.1 → 9.5.4
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/clipboard-html-sanitize.d.ts +15 -0
- package/dist/cjs/clipboard-html-sanitize.d.ts.map +1 -0
- package/dist/cjs/clipboard-html-sanitize.js +237 -0
- package/dist/cjs/condition-operand.d.ts +54 -0
- package/dist/cjs/condition-operand.d.ts.map +1 -0
- package/dist/cjs/condition-operand.js +223 -0
- package/dist/cjs/conditional-format.d.ts.map +1 -1
- package/dist/cjs/conditional-format.js +5 -59
- package/dist/cjs/data-validation.d.ts +9 -0
- package/dist/cjs/data-validation.d.ts.map +1 -1
- package/dist/cjs/data-validation.js +29 -60
- package/dist/cjs/date.d.ts +16 -0
- package/dist/cjs/date.d.ts.map +1 -1
- package/dist/cjs/date.js +33 -4
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/rich-text-html.d.ts +9 -0
- package/dist/cjs/rich-text-html.d.ts.map +1 -1
- package/dist/cjs/rich-text-html.js +52 -23
- package/dist/cjs/sheet-names.d.ts +60 -0
- package/dist/cjs/sheet-names.d.ts.map +1 -0
- package/dist/cjs/sheet-names.js +157 -0
- package/dist/cjs/sheet-protection.d.ts +9 -0
- package/dist/cjs/sheet-protection.d.ts.map +1 -0
- package/dist/cjs/sheet-protection.js +11 -0
- package/dist/cjs/string.d.ts +31 -0
- package/dist/cjs/string.d.ts.map +1 -1
- package/dist/cjs/string.js +73 -3
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/clipboard-html-sanitize.d.ts +15 -0
- package/dist/esm/clipboard-html-sanitize.d.ts.map +1 -0
- package/dist/esm/clipboard-html-sanitize.js +192 -0
- package/dist/esm/condition-operand.d.ts +54 -0
- package/dist/esm/condition-operand.d.ts.map +1 -0
- package/dist/esm/condition-operand.js +203 -0
- package/dist/esm/conditional-format.d.ts.map +1 -1
- package/dist/esm/conditional-format.js +5 -57
- package/dist/esm/data-validation.d.ts +9 -0
- package/dist/esm/data-validation.d.ts.map +1 -1
- package/dist/esm/data-validation.js +27 -57
- package/dist/esm/date.d.ts +16 -0
- package/dist/esm/date.d.ts.map +1 -1
- package/dist/esm/date.js +22 -1
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/rich-text-html.d.ts +9 -0
- package/dist/esm/rich-text-html.d.ts.map +1 -1
- package/dist/esm/rich-text-html.js +20 -4
- package/dist/esm/sheet-names.d.ts +60 -0
- package/dist/esm/sheet-names.d.ts.map +1 -0
- package/dist/esm/sheet-names.js +109 -0
- package/dist/esm/sheet-protection.d.ts +9 -0
- package/dist/esm/sheet-protection.d.ts.map +1 -0
- package/dist/esm/sheet-protection.js +8 -0
- package/dist/esm/string.d.ts +31 -0
- package/dist/esm/string.d.ts.map +1 -1
- package/dist/esm/string.js +62 -2
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { parseDate, parseTime } from "numfmt";
|
|
2
|
+
import { checkIfStringIsReference } from "./selection";
|
|
3
|
+
/**
|
|
4
|
+
* How a conditional-format / data-validation rule operand (`userEnteredValue`)
|
|
5
|
+
* becomes a formula fragment — the ONE place both rule builders and both xlsx
|
|
6
|
+
* exporters classify an operand, so the runtime and the exported file agree.
|
|
7
|
+
*
|
|
8
|
+
* Excel's rule dialog stores every operand as a formula. Typed text becomes a
|
|
9
|
+
* string literal, a typed number a number, a typed date its serial, `50%` the
|
|
10
|
+
* fraction `0.5`, and `=…` stays a formula. The `TEXT_*` rules ("Text that
|
|
11
|
+
* contains…", "begins with", cellIs equal `"5"`) take a string literal no
|
|
12
|
+
* matter what was typed: *begins with "1"* highlights `1abc` AND `123`, and
|
|
13
|
+
* `cellIs equal "5"` matches the text `5` but not the number 5.
|
|
14
|
+
*/
|
|
15
|
+
const NUMBER_LITERAL_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
16
|
+
const BOOLEAN_LITERAL_PATTERN = /^(TRUE|FALSE)$/i;
|
|
17
|
+
const PERCENT_LITERAL_PATTERN = /^([+-]?[\d.,\s ]+)%$/;
|
|
18
|
+
/** A complete formula string literal — `"abc"`, `"say ""hi"""`. */
|
|
19
|
+
const QUOTED_STRING_LITERAL_PATTERN = /^"(?:[^"]|"")*"$/;
|
|
20
|
+
/**
|
|
21
|
+
* A function call — `TODAY()`, `DATE(2024,1,1)`, `LEN($B$1)`, `_xlfn.XLOOKUP(…)` — which is how
|
|
22
|
+
* OOXML stores a formula bound (`<formula1>TODAY()</formula1>`, never with a leading `=`).
|
|
23
|
+
*/
|
|
24
|
+
const FORMULA_CALL_PATTERN = /^[A-Z_\\][\w.]*\([\s\S]*\)$/i;
|
|
25
|
+
/** Whole-column / whole-row spans (`A:A`, `$A:$C`, `1:1`), optionally sheet-qualified. */
|
|
26
|
+
const AXIS_REFERENCE_PATTERN = /^(?:[^!]+!)?(?:\$?[A-Z]{1,3}:\$?[A-Z]{1,3}|\$?\d+:\$?\d+)$/i;
|
|
27
|
+
const TEXT_CONDITION_TYPES = new Set([
|
|
28
|
+
"TEXT_EQ",
|
|
29
|
+
"TEXT_NOT_EQ",
|
|
30
|
+
"TEXT_CONTAINS",
|
|
31
|
+
"TEXT_NOT_CONTAINS",
|
|
32
|
+
"TEXT_STARTS_WITH",
|
|
33
|
+
"TEXT_ENDS_WITH",
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Whether a condition type compares TEXT — its operand is always a string
|
|
37
|
+
* literal in Excel, even when it looks like a number or a boolean.
|
|
38
|
+
*/
|
|
39
|
+
export const isTextConditionType = (type) => Boolean(type && TEXT_CONDITION_TYPES.has(type));
|
|
40
|
+
export const escapeConditionFormulaText = (value) => value.replace(/"/g, '""');
|
|
41
|
+
/**
|
|
42
|
+
* Parse a locale-formatted numeric string (e.g., "6,5" in fr-FR) into a JS
|
|
43
|
+
* number. Uses `Intl.NumberFormat` to detect decimal/group separators.
|
|
44
|
+
* Returns NaN on failure.
|
|
45
|
+
*/
|
|
46
|
+
const parseLocaleNumberLoose = (value, locale) => {
|
|
47
|
+
try {
|
|
48
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
|
49
|
+
const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
|
|
50
|
+
const group = parts.find((part) => part.type === "group")?.value ?? ",";
|
|
51
|
+
const cleaned = value.split(group).join("").replace(decimal, ".");
|
|
52
|
+
if (!cleaned.trim() || !NUMBER_LITERAL_PATTERN.test(cleaned.trim())) {
|
|
53
|
+
return NaN;
|
|
54
|
+
}
|
|
55
|
+
return Number(cleaned);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return NaN;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/** Shortest decimal text of a number rounded to Excel's 15 significant digits. */
|
|
62
|
+
const formatNumberOperand = (value) => String(Number(value.toPrecision(15)));
|
|
63
|
+
const parseNumberOperand = (text, locale) => {
|
|
64
|
+
if (NUMBER_LITERAL_PATTERN.test(text)) {
|
|
65
|
+
const value = Number(text);
|
|
66
|
+
return Number.isFinite(value) ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
if (locale) {
|
|
69
|
+
const value = parseLocaleNumberLoose(text, locale);
|
|
70
|
+
return Number.isFinite(value) ? value : undefined;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The formula fragment for an operand that is a typed LITERAL — a number
|
|
76
|
+
* (`5`, `1e3`, locale `6,5`), a percentage (`50%` → `0.5`), a boolean, or a
|
|
77
|
+
* date / time in the document locale (`2024-01-05`, `1/5/2024`, `10:30` → the
|
|
78
|
+
* serial). `undefined` for anything else (text, references, formulas).
|
|
79
|
+
*/
|
|
80
|
+
export const conditionOperandLiteral = (raw, locale) => {
|
|
81
|
+
if (!raw)
|
|
82
|
+
return undefined;
|
|
83
|
+
const trimmed = raw.trim();
|
|
84
|
+
if (!trimmed)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (BOOLEAN_LITERAL_PATTERN.test(trimmed)) {
|
|
87
|
+
return trimmed.toUpperCase();
|
|
88
|
+
}
|
|
89
|
+
if (NUMBER_LITERAL_PATTERN.test(trimmed)) {
|
|
90
|
+
// Keep the typed digits — `6.50` and `6.5` are the same number and the
|
|
91
|
+
// formula parser reads either.
|
|
92
|
+
return Number.isFinite(Number(trimmed)) ? trimmed : undefined;
|
|
93
|
+
}
|
|
94
|
+
const percent = PERCENT_LITERAL_PATTERN.exec(trimmed);
|
|
95
|
+
if (percent) {
|
|
96
|
+
const value = parseNumberOperand(percent[1].trim(), locale ?? "en-US");
|
|
97
|
+
if (value !== undefined) {
|
|
98
|
+
return formatNumberOperand(value / 100);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Dates (incl. date+time) and times, in the document locale's field order
|
|
102
|
+
// (`1/5/2024` is Jan 5 in en-US, May 1 in en-GB; `1.5.2024` is a de-DE
|
|
103
|
+
// date, not the grouped number 152024). numfmt's default locale is
|
|
104
|
+
// month-first, matching the app default.
|
|
105
|
+
try {
|
|
106
|
+
const dateOptions = locale ? { locale } : undefined;
|
|
107
|
+
const date = parseDate(trimmed, dateOptions);
|
|
108
|
+
if (date && typeof date.v === "number" && Number.isFinite(date.v)) {
|
|
109
|
+
return formatNumberOperand(date.v);
|
|
110
|
+
}
|
|
111
|
+
const time = parseTime(trimmed);
|
|
112
|
+
if (time && typeof time.v === "number" && Number.isFinite(time.v)) {
|
|
113
|
+
return formatNumberOperand(time.v);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// numfmt throws on malformed locale tags — the operand is then plain text.
|
|
118
|
+
}
|
|
119
|
+
if (locale) {
|
|
120
|
+
const value = parseLocaleNumberLoose(trimmed, locale);
|
|
121
|
+
if (Number.isFinite(value)) {
|
|
122
|
+
return formatNumberOperand(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Whether an operand (with any leading `=` already removed) is an A1
|
|
129
|
+
* reference — a cell, a range, a whole column/row, with `$` anchors and an
|
|
130
|
+
* optional (quoted) sheet qualifier: `B1`, `$B$1`, `B$1`, `Sheet1!$B$1`,
|
|
131
|
+
* `'My Sheet'!$B$1:$C$9`, `$A:$A`.
|
|
132
|
+
*/
|
|
133
|
+
export const isConditionOperandReference = (text) => checkIfStringIsReference(text) || AXIS_REFERENCE_PATTERN.test(text);
|
|
134
|
+
/**
|
|
135
|
+
* Whether an operand (with any leading `=` already removed) is a function
|
|
136
|
+
* call — the form OOXML stores a formula bound in (`TODAY()`,
|
|
137
|
+
* `DATE(2024,1,1)`, `LEN($B$1)`): evaluated as a formula, never quoted.
|
|
138
|
+
*/
|
|
139
|
+
export const isConditionOperandFormulaCall = (text) => FORMULA_CALL_PATTERN.test(text);
|
|
140
|
+
/**
|
|
141
|
+
* Normalize one rule operand into the formula fragment the rule builders
|
|
142
|
+
* splice into `=<cell><op><fragment>` (and the xlsx exporters write inside
|
|
143
|
+
* `<formula>` / `<formula1>` — never with a leading `=`).
|
|
144
|
+
*
|
|
145
|
+
* - `=…` → the formula text (leading `=` stripped).
|
|
146
|
+
* - `TEXT_*` types → ALWAYS a quoted string literal (`"1"`, `"TRUE"`).
|
|
147
|
+
* - A complete `"…"` string literal → as is.
|
|
148
|
+
* - An A1 reference (`$B$1`, `B$1`, `Sheet1!$B$1`, `'My Sheet'!$B$1`) → as is.
|
|
149
|
+
* - A function call (`TODAY()`, `DATE(2024,1,1)`, `LEN(A1)`) → as is.
|
|
150
|
+
* - A typed literal (number, `50%`, boolean, date/time in `locale`) → its
|
|
151
|
+
* numeric form (`0.5`, `45296`, `TRUE`).
|
|
152
|
+
* - Anything else → a quoted string literal (`"abc"`).
|
|
153
|
+
*
|
|
154
|
+
* `""` for an empty operand.
|
|
155
|
+
*/
|
|
156
|
+
export const normalizeConditionOperand = (raw, type, locale) => {
|
|
157
|
+
if (!raw)
|
|
158
|
+
return "";
|
|
159
|
+
const trimmed = raw.trim();
|
|
160
|
+
if (!trimmed)
|
|
161
|
+
return "";
|
|
162
|
+
if (trimmed.startsWith("=")) {
|
|
163
|
+
return trimmed.slice(1);
|
|
164
|
+
}
|
|
165
|
+
if (isTextConditionType(type)) {
|
|
166
|
+
return `"${escapeConditionFormulaText(trimmed)}"`;
|
|
167
|
+
}
|
|
168
|
+
if (QUOTED_STRING_LITERAL_PATTERN.test(trimmed)) {
|
|
169
|
+
return trimmed;
|
|
170
|
+
}
|
|
171
|
+
if (checkIfStringIsReference(trimmed) || isConditionOperandFormulaCall(trimmed)) {
|
|
172
|
+
return trimmed;
|
|
173
|
+
}
|
|
174
|
+
// Typed literals before whole-row spans: `10:30` is the time 10:30, not
|
|
175
|
+
// rows 10–30 (Excel's dialog reads it the way a cell entry would).
|
|
176
|
+
const literal = conditionOperandLiteral(trimmed, locale);
|
|
177
|
+
if (literal !== undefined) {
|
|
178
|
+
return literal;
|
|
179
|
+
}
|
|
180
|
+
if (isConditionOperandReference(trimmed)) {
|
|
181
|
+
return trimmed;
|
|
182
|
+
}
|
|
183
|
+
return `"${escapeConditionFormulaText(trimmed)}"`;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* The OOXML `<formula>` / `<formula1>` text for a number/date rule operand:
|
|
187
|
+
* the leading `=` is stripped (Excel never writes one), typed literals are
|
|
188
|
+
* normalized to what Excel itself stores (`50%` → `0.5`, `2024-01-05` → its
|
|
189
|
+
* serial `45296` rather than the arithmetic `2024-01-05` = 2022), and every
|
|
190
|
+
* other operand — references, defined names, function calls — is written
|
|
191
|
+
* verbatim.
|
|
192
|
+
*/
|
|
193
|
+
export const conditionOperandToFormulaText = (raw, locale) => {
|
|
194
|
+
if (!raw)
|
|
195
|
+
return "";
|
|
196
|
+
const trimmed = raw.trim();
|
|
197
|
+
if (!trimmed)
|
|
198
|
+
return "";
|
|
199
|
+
if (trimmed.startsWith("=")) {
|
|
200
|
+
return trimmed.slice(1);
|
|
201
|
+
}
|
|
202
|
+
return conditionOperandLiteral(trimmed, locale) ?? trimmed;
|
|
203
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conditional-format.d.ts","sourceRoot":"","sources":["../../conditional-format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAEjB,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"conditional-format.d.ts","sourceRoot":"","sources":["../../conditional-format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAEjB,MAAM,4BAA4B,CAAC;AAQpC;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CAC9D,CAAC;AAIF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,8BAA8B,GACzC,MAAM,yBAAyB,GAAG,SAAS,YAK5C,CAAC;AAEF,eAAO,MAAM,gCAAgC,GAC3C,MAAM,yBAAyB,GAAG,SAAS,EAC3C,aAAa,MAAM,EACnB,SAAS,MAAM,uBA8HhB,CAAC"}
|
|
@@ -1,58 +1,6 @@
|
|
|
1
|
+
import { escapeConditionFormulaText, normalizeConditionOperand, } from "./condition-operand";
|
|
1
2
|
import { stripDataValidationTextPrefix } from "./data-validation";
|
|
2
|
-
import { checkIfStringIsReference } from "./selection";
|
|
3
3
|
const FORMULA_PREDICATE_BASE_CELL_REF = "A1";
|
|
4
|
-
const NUMBER_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
|
5
|
-
const BOOLEAN_LITERAL_PATTERN = /^(TRUE|FALSE)$/i;
|
|
6
|
-
const escapeFormulaText = (value) => value.replace(/"/g, '""');
|
|
7
|
-
/**
|
|
8
|
-
* Parse a locale-formatted numeric string (e.g., "6,5" in fr-FR) into a JS
|
|
9
|
-
* number. Uses `Intl.NumberFormat` to detect decimal/group separators.
|
|
10
|
-
* Returns NaN on failure.
|
|
11
|
-
*/
|
|
12
|
-
const parseLocaleNumberLoose = (value, locale) => {
|
|
13
|
-
try {
|
|
14
|
-
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
|
15
|
-
const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
|
|
16
|
-
const group = parts.find((part) => part.type === "group")?.value ?? ",";
|
|
17
|
-
const cleaned = value.split(group).join("").replace(decimal, ".");
|
|
18
|
-
return Number(cleaned);
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
return NaN;
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* Normalize a condition value for use in a formula expression.
|
|
26
|
-
* Handles locale-specific number formats (e.g., "6,5" -> "6.5").
|
|
27
|
-
*/
|
|
28
|
-
const normalizeConditionValue = (raw, allowStringLiteral = false, locale) => {
|
|
29
|
-
if (!raw)
|
|
30
|
-
return "";
|
|
31
|
-
const trimmed = raw.trim();
|
|
32
|
-
if (!trimmed)
|
|
33
|
-
return "";
|
|
34
|
-
if (trimmed.startsWith("=")) {
|
|
35
|
-
return trimmed.slice(1);
|
|
36
|
-
}
|
|
37
|
-
if (checkIfStringIsReference(trimmed)) {
|
|
38
|
-
return trimmed;
|
|
39
|
-
}
|
|
40
|
-
if (NUMBER_LITERAL_PATTERN.test(trimmed) ||
|
|
41
|
-
BOOLEAN_LITERAL_PATTERN.test(trimmed)) {
|
|
42
|
-
return trimmed;
|
|
43
|
-
}
|
|
44
|
-
// Try parsing as locale-aware number
|
|
45
|
-
if (locale) {
|
|
46
|
-
const parsed = parseLocaleNumberLoose(trimmed, locale);
|
|
47
|
-
if (!isNaN(parsed)) {
|
|
48
|
-
return String(parsed);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (allowStringLiteral) {
|
|
52
|
-
return `"${escapeFormulaText(trimmed)}"`;
|
|
53
|
-
}
|
|
54
|
-
return trimmed;
|
|
55
|
-
};
|
|
56
4
|
/**
|
|
57
5
|
* Whether a CF rule is evaluated via the calculator (formula path) instead of
|
|
58
6
|
* the synchronous `checkBooleanRule` path used in
|
|
@@ -104,8 +52,8 @@ export const buildConditionalFormatExpression = (rule, baseCellRef, locale) => {
|
|
|
104
52
|
return undefined;
|
|
105
53
|
return trimmed.startsWith("=") ? trimmed : `=${trimmed}`;
|
|
106
54
|
}
|
|
107
|
-
const fromExpr =
|
|
108
|
-
const toExpr =
|
|
55
|
+
const fromExpr = normalizeConditionOperand(fromRaw, condition.type, locale);
|
|
56
|
+
const toExpr = normalizeConditionOperand(toRaw, condition.type, locale);
|
|
109
57
|
if (!fromExpr && !toExpr)
|
|
110
58
|
return undefined;
|
|
111
59
|
const comparisonOperatorMap = {
|
|
@@ -185,13 +133,13 @@ export const buildConditionalFormatExpression = (rule, baseCellRef, locale) => {
|
|
|
185
133
|
.flatMap((entry) => {
|
|
186
134
|
if (entry.startsWith("'")) {
|
|
187
135
|
const stripped = stripDataValidationTextPrefix(entry);
|
|
188
|
-
const textCondition = `${baseCellRef}="${
|
|
136
|
+
const textCondition = `${baseCellRef}="${escapeConditionFormulaText(stripped)}"`;
|
|
189
137
|
return stripped === ""
|
|
190
138
|
? [textCondition, `${baseCellRef}="'"`]
|
|
191
139
|
: [textCondition];
|
|
192
140
|
}
|
|
193
141
|
return entry
|
|
194
|
-
? [`${baseCellRef}=${
|
|
142
|
+
? [`${baseCellRef}=${normalizeConditionOperand(entry, condition.type, locale)}`]
|
|
195
143
|
: [];
|
|
196
144
|
})
|
|
197
145
|
.filter((entry) => Boolean(entry));
|
|
@@ -50,5 +50,14 @@ export declare const dataValidationOptionMatchesCellValue: (option: string | und
|
|
|
50
50
|
* builder from drifting.
|
|
51
51
|
*/
|
|
52
52
|
export declare const isDataValidationFormulaRule: (rule: DataValidationRule | undefined) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Excel's *Whole number* validation kind rejects any non-integer (`5.5` fails
|
|
55
|
+
* "whole between 1 and 10"). An imported `type="whole"` rule carries a plain
|
|
56
|
+
* `NUMBER_*` comparison as its `condition` (the bounds) — which alone accepts
|
|
57
|
+
* `5.5` — so when the rule's `xlsxSource.type` is `whole` the comparison is
|
|
58
|
+
* wrapped in the same integer guard `buildExcelValidationRule` composes for
|
|
59
|
+
* an authored Whole-number rule: `AND(ISNUMBER(x), x=INT(x), <comparison>)`.
|
|
60
|
+
* Rules of other kinds, and non-`NUMBER_*` conditions, are returned as built.
|
|
61
|
+
*/
|
|
53
62
|
export declare const buildDataValidationExpression: (rule: DataValidationRule | undefined, baseCellRef: string, locale?: string) => string | undefined;
|
|
54
63
|
//# sourceMappingURL=data-validation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"data-validation.d.ts","sourceRoot":"","sources":["../../data-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"data-validation.d.ts","sourceRoot":"","sources":["../../data-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;AASpC;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,MAAM,KAAG,MACd,CAAC;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,GAC/C,QAAQ,MAAM,GAAG,SAAS,GAAG,IAAI,EACjC,WAAW,MAAM,GAAG,SAAS,GAAG,IAAI,KACnC,OAKF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,2BAA2B,GACtC,MAAM,kBAAkB,GAAG,SAAS,YAKrC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,GACxC,MAAM,kBAAkB,GAAG,SAAS,EACpC,aAAa,MAAM,EACnB,SAAS,MAAM,uBAgBhB,CAAC"}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { escapeConditionFormulaText, normalizeConditionOperand, } from "./condition-operand";
|
|
2
2
|
const FORMULA_PREDICATE_BASE_CELL_REF = "A1";
|
|
3
|
-
const NUMBER_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
|
|
4
|
-
const BOOLEAN_LITERAL_PATTERN = /^(TRUE|FALSE)$/i;
|
|
5
|
-
const escapeFormulaText = (value) => value.replace(/"/g, '""');
|
|
6
3
|
/**
|
|
7
4
|
* A leading apostrophe in a data-validation / conditional-format list entry is
|
|
8
5
|
* Excel's text prefix — `'000s` is the text "000s" and a bare `'` is empty
|
|
@@ -22,55 +19,6 @@ export const dataValidationOptionMatchesCellValue = (option, cellValue) => {
|
|
|
22
19
|
const normalizedValue = rawValue === "'" ? "" : rawValue;
|
|
23
20
|
return normalizedOption.toLowerCase() === normalizedValue.toLowerCase();
|
|
24
21
|
};
|
|
25
|
-
/**
|
|
26
|
-
* Parse a locale-formatted numeric string (e.g., "6,5" in fr-FR) into a JS
|
|
27
|
-
* number. Uses `Intl.NumberFormat` to detect decimal/group separators.
|
|
28
|
-
* Returns NaN on failure.
|
|
29
|
-
*/
|
|
30
|
-
const parseLocaleNumberLoose = (value, locale) => {
|
|
31
|
-
try {
|
|
32
|
-
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
|
33
|
-
const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
|
|
34
|
-
const group = parts.find((part) => part.type === "group")?.value ?? ",";
|
|
35
|
-
const cleaned = value.split(group).join("").replace(decimal, ".");
|
|
36
|
-
return Number(cleaned);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return NaN;
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* Normalize a condition value for use in a formula expression.
|
|
44
|
-
* Handles locale-specific number formats (e.g., "6,5" -> "6.5").
|
|
45
|
-
*/
|
|
46
|
-
const normalizeConditionValue = (raw, allowStringLiteral = false, locale) => {
|
|
47
|
-
if (!raw)
|
|
48
|
-
return "";
|
|
49
|
-
const trimmed = raw.trim();
|
|
50
|
-
if (!trimmed)
|
|
51
|
-
return "";
|
|
52
|
-
if (trimmed.startsWith("=")) {
|
|
53
|
-
return trimmed.slice(1);
|
|
54
|
-
}
|
|
55
|
-
if (checkIfStringIsReference(trimmed)) {
|
|
56
|
-
return trimmed;
|
|
57
|
-
}
|
|
58
|
-
if (NUMBER_LITERAL_PATTERN.test(trimmed) ||
|
|
59
|
-
BOOLEAN_LITERAL_PATTERN.test(trimmed)) {
|
|
60
|
-
return trimmed;
|
|
61
|
-
}
|
|
62
|
-
// Try parsing as locale-aware number
|
|
63
|
-
if (locale) {
|
|
64
|
-
const parsed = parseLocaleNumberLoose(trimmed, locale);
|
|
65
|
-
if (!isNaN(parsed)) {
|
|
66
|
-
return String(parsed);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (allowStringLiteral) {
|
|
70
|
-
return `"${escapeFormulaText(trimmed)}"`;
|
|
71
|
-
}
|
|
72
|
-
return trimmed;
|
|
73
|
-
};
|
|
74
22
|
/**
|
|
75
23
|
* Whether a data validation rule is evaluated via the calculator (formula path)
|
|
76
24
|
* instead of the synchronous `checkBooleanRule` path used in
|
|
@@ -110,7 +58,27 @@ const normalizeConditionValue = (raw, allowStringLiteral = false, locale) => {
|
|
|
110
58
|
export const isDataValidationFormulaRule = (rule) => {
|
|
111
59
|
return Boolean(buildDataValidationExpression(rule, FORMULA_PREDICATE_BASE_CELL_REF));
|
|
112
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* Excel's *Whole number* validation kind rejects any non-integer (`5.5` fails
|
|
63
|
+
* "whole between 1 and 10"). An imported `type="whole"` rule carries a plain
|
|
64
|
+
* `NUMBER_*` comparison as its `condition` (the bounds) — which alone accepts
|
|
65
|
+
* `5.5` — so when the rule's `xlsxSource.type` is `whole` the comparison is
|
|
66
|
+
* wrapped in the same integer guard `buildExcelValidationRule` composes for
|
|
67
|
+
* an authored Whole-number rule: `AND(ISNUMBER(x), x=INT(x), <comparison>)`.
|
|
68
|
+
* Rules of other kinds, and non-`NUMBER_*` conditions, are returned as built.
|
|
69
|
+
*/
|
|
113
70
|
export const buildDataValidationExpression = (rule, baseCellRef, locale) => {
|
|
71
|
+
const expression = buildDataValidationConditionExpression(rule, baseCellRef, locale);
|
|
72
|
+
if (!expression)
|
|
73
|
+
return undefined;
|
|
74
|
+
const isWholeSource = rule?.xlsxSource?.type?.trim().toLowerCase() === "whole";
|
|
75
|
+
const isNumberCondition = Boolean(rule?.condition?.type?.startsWith("NUMBER_"));
|
|
76
|
+
if (!isWholeSource || !isNumberCondition)
|
|
77
|
+
return expression;
|
|
78
|
+
const body = expression.startsWith("=") ? expression.slice(1) : expression;
|
|
79
|
+
return `=AND(ISNUMBER(${baseCellRef}),${baseCellRef}=INT(${baseCellRef}),${body})`;
|
|
80
|
+
};
|
|
81
|
+
const buildDataValidationConditionExpression = (rule, baseCellRef, locale) => {
|
|
114
82
|
const condition = rule?.condition;
|
|
115
83
|
if (!condition?.type)
|
|
116
84
|
return undefined;
|
|
@@ -124,8 +92,8 @@ export const buildDataValidationExpression = (rule, baseCellRef, locale) => {
|
|
|
124
92
|
return undefined;
|
|
125
93
|
return trimmed.startsWith("=") ? trimmed : `=${trimmed}`;
|
|
126
94
|
}
|
|
127
|
-
const fromExpr =
|
|
128
|
-
const toExpr =
|
|
95
|
+
const fromExpr = normalizeConditionOperand(fromRaw, condition.type, locale);
|
|
96
|
+
const toExpr = normalizeConditionOperand(toRaw, condition.type, locale);
|
|
129
97
|
if (!fromExpr && !toExpr)
|
|
130
98
|
return undefined;
|
|
131
99
|
const comparisonOperatorMap = {
|
|
@@ -206,7 +174,7 @@ export const buildDataValidationExpression = (rule, baseCellRef, locale) => {
|
|
|
206
174
|
.flatMap((entry) => {
|
|
207
175
|
if (entry.startsWith("'")) {
|
|
208
176
|
const stripped = stripDataValidationTextPrefix(entry);
|
|
209
|
-
const textCondition = `${baseCellRef}="${
|
|
177
|
+
const textCondition = `${baseCellRef}="${escapeConditionFormulaText(stripped)}"`;
|
|
210
178
|
// The bare `'` entry means empty text, and a cell storing the lone
|
|
211
179
|
// text-prefix apostrophe is equivalent to a blank cell — accept it too.
|
|
212
180
|
return stripped === ""
|
|
@@ -214,7 +182,9 @@ export const buildDataValidationExpression = (rule, baseCellRef, locale) => {
|
|
|
214
182
|
: [textCondition];
|
|
215
183
|
}
|
|
216
184
|
return entry
|
|
217
|
-
? [
|
|
185
|
+
? [
|
|
186
|
+
`${baseCellRef}=${normalizeConditionOperand(entry, condition.type, locale)}`,
|
|
187
|
+
]
|
|
218
188
|
: [];
|
|
219
189
|
})
|
|
220
190
|
.filter((entry) => Boolean(entry));
|
package/dist/esm/date.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel's 1900 date system counts a day that never happened: serial 60 is
|
|
3
|
+
* 1900-02-29 (Lotus 1-2-3 compatibility). 1900-01-01 is 1, 1900-02-28 is 59,
|
|
4
|
+
* 1900-03-01 is 61. Google Sheets keeps the same numbering.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EXCEL_LEAP_BUG_SERIAL = 60;
|
|
7
|
+
/** The calendar day Excel's serial 60 stands for, which no `Date` can hold. */
|
|
8
|
+
export declare const isExcelLeapDay: (year: number, month: number, day: number) => boolean;
|
|
1
9
|
export type DateSerialMode = "local" | "excel-utc";
|
|
2
10
|
/**
|
|
3
11
|
* Convert date to serial with explicit time zone behavior.
|
|
@@ -6,6 +14,14 @@ export type DateSerialMode = "local" | "excel-utc";
|
|
|
6
14
|
*/
|
|
7
15
|
export declare const toSerialWithMode: (date: Date, use1904?: boolean, mode?: DateSerialMode) => number;
|
|
8
16
|
export declare const toSerial: (date: Date, use1904?: boolean) => number;
|
|
17
|
+
/**
|
|
18
|
+
* Serial for calendar parts the way a user typed them. Unlike
|
|
19
|
+
* `toSerial(new Date(y, m, d))`, this never goes through a `Date`, so the
|
|
20
|
+
* phantom 1900-02-29 stays serial 60 instead of rolling over to March 1
|
|
21
|
+
* (rowsncolumns/spreadsheet#1026). Every other date takes the same path as
|
|
22
|
+
* `toSerial` in "local" mode. `month` is 0-based like `Date`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const toSerialFromParts: (year: number, month: number, day: number, hour?: number, minute?: number, second?: number, use1904?: boolean) => number;
|
|
9
25
|
/**
|
|
10
26
|
* Convert serial to date
|
|
11
27
|
* @param serial
|
package/dist/esm/date.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"date.d.ts","sourceRoot":"","sources":["../../date.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"date.d.ts","sourceRoot":"","sources":["../../date.ts"],"names":[],"mappings":"AACA;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,+EAA+E;AAC/E,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,EAAE,OAAO,MAAM,EAAE,KAAK,MAAM,YAC3B,CAAC;AAE7C,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,WAAW,CAAC;AAEnD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,MAAM,IAAI,EACV,iBAAe,EACf,OAAM,cAAwB,WAwB/B,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,MAAM,IAAI,EAAE,iBAAe,WAEnD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,MAAM,EACZ,OAAO,MAAM,EACb,KAAK,MAAM,EACX,aAAQ,EACR,eAAU,EACV,eAAU,EACV,iBAAe,WAWhB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,MAAM,GAAI,QAAQ,MAAM,EAAE,iBAAe,SA+BrD,CAAC"}
|
package/dist/esm/date.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const DAY_TO_MS = 24 * 60 * 60 * 1000;
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Excel's 1900 date system counts a day that never happened: serial 60 is
|
|
4
|
+
* 1900-02-29 (Lotus 1-2-3 compatibility). 1900-01-01 is 1, 1900-02-28 is 59,
|
|
5
|
+
* 1900-03-01 is 61. Google Sheets keeps the same numbering.
|
|
6
|
+
*/
|
|
7
|
+
export const EXCEL_LEAP_BUG_SERIAL = 60;
|
|
8
|
+
/** The calendar day Excel's serial 60 stands for, which no `Date` can hold. */
|
|
9
|
+
export const isExcelLeapDay = (year, month, day) => year === 1900 && month === 1 && day === 29;
|
|
3
10
|
/**
|
|
4
11
|
* Convert date to serial with explicit time zone behavior.
|
|
5
12
|
* - local: interpret date components in local time (UI entry behavior).
|
|
@@ -23,6 +30,20 @@ export const toSerialWithMode = (date, use1904 = false, mode = "local") => {
|
|
|
23
30
|
export const toSerial = (date, use1904 = false) => {
|
|
24
31
|
return toSerialWithMode(date, use1904, "local");
|
|
25
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Serial for calendar parts the way a user typed them. Unlike
|
|
35
|
+
* `toSerial(new Date(y, m, d))`, this never goes through a `Date`, so the
|
|
36
|
+
* phantom 1900-02-29 stays serial 60 instead of rolling over to March 1
|
|
37
|
+
* (rowsncolumns/spreadsheet#1026). Every other date takes the same path as
|
|
38
|
+
* `toSerial` in "local" mode. `month` is 0-based like `Date`.
|
|
39
|
+
*/
|
|
40
|
+
export const toSerialFromParts = (year, month, day, hour = 0, minute = 0, second = 0, use1904 = false) => {
|
|
41
|
+
const timeFraction = (hour * 3600 + minute * 60 + second) / (24 * 60 * 60);
|
|
42
|
+
if (!use1904 && isExcelLeapDay(year, month, day)) {
|
|
43
|
+
return EXCEL_LEAP_BUG_SERIAL + timeFraction;
|
|
44
|
+
}
|
|
45
|
+
return toSerialWithMode(new Date(year, month, day, hour, minute, second, 0), use1904, "local");
|
|
46
|
+
};
|
|
26
47
|
/**
|
|
27
48
|
* Convert serial to date
|
|
28
49
|
* @param serial
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./colors";
|
|
|
18
18
|
export * from "./formula-refs";
|
|
19
19
|
export * from "./sort";
|
|
20
20
|
export * from "./table-styles";
|
|
21
|
+
export * from "./condition-operand";
|
|
21
22
|
export * from "./conditional-format";
|
|
22
23
|
export * from "./data-validation";
|
|
23
24
|
export * from "./content-hash";
|
|
@@ -26,8 +27,11 @@ export * from "./keyboard";
|
|
|
26
27
|
export * from "./sheet-span";
|
|
27
28
|
export * from "./formula-locale";
|
|
28
29
|
export * from "./table-names";
|
|
30
|
+
export * from "./sheet-names";
|
|
31
|
+
export * from "./sheet-protection";
|
|
29
32
|
export * from "./table-totals";
|
|
30
33
|
export * from "./sparkline-groups";
|
|
31
34
|
export * from "./rich-text-html";
|
|
35
|
+
export * from "./clipboard-html-sanitize";
|
|
32
36
|
export * from "./rich-text-links";
|
|
33
37
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/esm/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,SAAS,CAAC;AACxB,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,qBAAqB,CAAC;AACpC,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,QAAQ,CAAC;AACvB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,2BAA2B,CAAC;AAC1C,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,SAAS,CAAC;AACxB,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,qBAAqB,CAAC;AACpC,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,QAAQ,CAAC;AACvB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,2BAA2B,CAAC;AAC1C,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mBAAmB,CAAC"}
|
package/dist/esm/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./colors";
|
|
|
18
18
|
export * from "./formula-refs";
|
|
19
19
|
export * from "./sort";
|
|
20
20
|
export * from "./table-styles";
|
|
21
|
+
export * from "./condition-operand";
|
|
21
22
|
export * from "./conditional-format";
|
|
22
23
|
export * from "./data-validation";
|
|
23
24
|
export * from "./content-hash";
|
|
@@ -26,7 +27,10 @@ export * from "./keyboard";
|
|
|
26
27
|
export * from "./sheet-span";
|
|
27
28
|
export * from "./formula-locale";
|
|
28
29
|
export * from "./table-names";
|
|
30
|
+
export * from "./sheet-names";
|
|
31
|
+
export * from "./sheet-protection";
|
|
29
32
|
export * from "./table-totals";
|
|
30
33
|
export * from "./sparkline-groups";
|
|
31
34
|
export * from "./rich-text-html";
|
|
35
|
+
export * from "./clipboard-html-sanitize";
|
|
32
36
|
export * from "./rich-text-links";
|
|
@@ -52,6 +52,15 @@ export type ParsedRichText = {
|
|
|
52
52
|
* Parse an HTML clipboard payload into a hidden, mounted sandbox so
|
|
53
53
|
* `getComputedStyle` resolves the payload's own `<style>` rules (Excel's
|
|
54
54
|
* `.fontN` / `.xlNN` classes). Callers must `cleanup()` when done.
|
|
55
|
+
*
|
|
56
|
+
* The payload is untrusted. It is parsed with `DOMParser` (an inert document:
|
|
57
|
+
* no scripts run, no handlers fire, nothing loads), stripped by
|
|
58
|
+
* {@link sanitizeClipboardDocument} — scripts, frames, embeds, SVG/MathML,
|
|
59
|
+
* every `on*` handler, `javascript:`/`data:` links and network-reaching CSS —
|
|
60
|
+
* and only then are the surviving nodes imported into the live sandbox. The
|
|
61
|
+
* import is node-by-node (`importNode`), never a serialize + `innerHTML`
|
|
62
|
+
* round trip, so the parser cannot re-interpret the sanitized tree (mXSS).
|
|
63
|
+
* This sanitization is not optional and runs for every clipboard consumer.
|
|
55
64
|
*/
|
|
56
65
|
export declare const mountClipboardHtml: (clipboardValue: string) => {
|
|
57
66
|
root: HTMLDivElement;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rich-text-html.d.ts","sourceRoot":"","sources":["../../rich-text-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"rich-text-html.d.ts","sourceRoot":"","sources":["../../rich-text-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAO5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,aAAa,EAAE,GAAG,SAAS,CAAC;IAClC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,UAAU,EAAE,UAAU,CAAC;IACvB,yFAAyF;IACzF,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAgGF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,kBAAkB,GAAI,gBAAgB,MAAM;;;CA0BxD,CAAC;AAEF,eAAO,MAAM,aAAa,GACxB,OAAO,MAAM,GAAG,IAAI,GAAG,SAAS,KAC/B,MAAM,GAAG,SAkBX,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,eAAe,GAC1B,OAAO,MAAM,GAAG,IAAI,GAAG,SAAS,KAC/B,MAAM,GAAG,SAoBX,CAAC;AAiTF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAAI,MAAM,WAAW,KAAG,cA+DzD,CAAC;AAgBF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,MAAM,WAAW,KAChB,WAAW,GAAG,SAShB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,MAAM,MAAM,KACX,cAAc,GAAG,SAkBnB,CAAC"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { sanitizeClipboardDocument } from "./clipboard-html-sanitize";
|
|
1
2
|
import { colorStringToHex } from "./color";
|
|
2
3
|
import { canUseDOM } from "./dom";
|
|
4
|
+
import { isSafeHyperlinkUri } from "./string";
|
|
3
5
|
const BLOCK_TAGS = new Set([
|
|
4
6
|
"p",
|
|
5
7
|
"div",
|
|
@@ -86,16 +88,28 @@ const NON_COLORS = new Set([
|
|
|
86
88
|
* Parse an HTML clipboard payload into a hidden, mounted sandbox so
|
|
87
89
|
* `getComputedStyle` resolves the payload's own `<style>` rules (Excel's
|
|
88
90
|
* `.fontN` / `.xlNN` classes). Callers must `cleanup()` when done.
|
|
91
|
+
*
|
|
92
|
+
* The payload is untrusted. It is parsed with `DOMParser` (an inert document:
|
|
93
|
+
* no scripts run, no handlers fire, nothing loads), stripped by
|
|
94
|
+
* {@link sanitizeClipboardDocument} — scripts, frames, embeds, SVG/MathML,
|
|
95
|
+
* every `on*` handler, `javascript:`/`data:` links and network-reaching CSS —
|
|
96
|
+
* and only then are the surviving nodes imported into the live sandbox. The
|
|
97
|
+
* import is node-by-node (`importNode`), never a serialize + `innerHTML`
|
|
98
|
+
* round trip, so the parser cannot re-interpret the sanitized tree (mXSS).
|
|
99
|
+
* This sanitization is not optional and runs for every clipboard consumer.
|
|
89
100
|
*/
|
|
90
101
|
export const mountClipboardHtml = (clipboardValue) => {
|
|
91
102
|
const parser = new DOMParser();
|
|
92
|
-
const doc = parser.parseFromString(clipboardValue, "text/html");
|
|
103
|
+
const doc = sanitizeClipboardDocument(parser.parseFromString(clipboardValue, "text/html"));
|
|
93
104
|
const sandbox = document.createElement("div");
|
|
94
105
|
sandbox.style.cssText =
|
|
95
106
|
"position:fixed;left:-10000px;top:-10000px;width:0;height:0;overflow:hidden;opacity:0;pointer-events:none;font-size:10pt;";
|
|
96
|
-
|
|
107
|
+
const bodyChildren = doc.body ? Array.from(doc.body.childNodes) : [];
|
|
108
|
+
for (const child of bodyChildren) {
|
|
109
|
+
sandbox.appendChild(document.importNode(child, true));
|
|
110
|
+
}
|
|
97
111
|
const styles = doc.head?.querySelectorAll("style") ?? [];
|
|
98
|
-
styles.forEach((style) => sandbox.appendChild(
|
|
112
|
+
styles.forEach((style) => sandbox.appendChild(document.importNode(style, true)));
|
|
99
113
|
document.body.appendChild(sandbox);
|
|
100
114
|
return {
|
|
101
115
|
root: sandbox,
|
|
@@ -232,8 +246,10 @@ const readElementFormat = (element, inherited) => {
|
|
|
232
246
|
break;
|
|
233
247
|
}
|
|
234
248
|
case "a": {
|
|
249
|
+
// The sanitizer already drops unsafe hrefs from clipboard payloads;
|
|
250
|
+
// this converter also serves non-clipboard callers, so re-check here.
|
|
235
251
|
const href = element.getAttribute("href");
|
|
236
|
-
if (href && href.trim()) {
|
|
252
|
+
if (href && href.trim() && isSafeHyperlinkUri(href)) {
|
|
237
253
|
format.link = { uri: href.trim() };
|
|
238
254
|
}
|
|
239
255
|
break;
|