@cj-tech-master/excelts 9.5.5 → 9.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/browser/modules/excel/worksheet.d.ts +11 -0
  2. package/dist/browser/modules/excel/worksheet.js +13 -0
  3. package/dist/browser/modules/formula/integration/apply-writeback-plan.js +17 -3
  4. package/dist/browser/modules/formula/integration/workbook-adapter.js +20 -1
  5. package/dist/browser/modules/formula/integration/workbook-snapshot.d.ts +12 -0
  6. package/dist/browser/modules/formula/materialize/build-writeback-plan.js +47 -0
  7. package/dist/browser/modules/formula/materialize/types.d.ts +19 -3
  8. package/dist/browser/modules/formula/materialize/types.js +13 -3
  9. package/dist/browser/modules/pdf/builder/document-builder.js +2 -2
  10. package/dist/browser/modules/pdf/font/system-fonts.d.ts +24 -4
  11. package/dist/browser/modules/pdf/font/system-fonts.js +76 -32
  12. package/dist/browser/modules/pdf/render/pdf-exporter.js +6 -3
  13. package/dist/browser/modules/word/advanced/field-engine.js +151 -23
  14. package/dist/browser/modules/word/advanced/math-convert.js +2 -1
  15. package/dist/browser/modules/word/advanced/style-map.js +44 -6
  16. package/dist/browser/modules/word/convert/html/html-import.js +434 -71
  17. package/dist/browser/modules/word/convert/markdown/markdown-renderer.js +11 -3
  18. package/dist/browser/modules/word/layout/layout-full.js +4 -1
  19. package/dist/browser/modules/word/security/digital-signatures.js +160 -33
  20. package/dist/browser/modules/word/security/encryption.js +109 -9
  21. package/dist/cjs/modules/excel/worksheet.js +13 -0
  22. package/dist/cjs/modules/formula/integration/apply-writeback-plan.js +17 -3
  23. package/dist/cjs/modules/formula/integration/workbook-adapter.js +20 -1
  24. package/dist/cjs/modules/formula/materialize/build-writeback-plan.js +47 -0
  25. package/dist/cjs/modules/formula/materialize/types.js +13 -3
  26. package/dist/cjs/modules/pdf/builder/document-builder.js +1 -1
  27. package/dist/cjs/modules/pdf/font/system-fonts.js +77 -32
  28. package/dist/cjs/modules/pdf/render/pdf-exporter.js +5 -2
  29. package/dist/cjs/modules/word/advanced/field-engine.js +151 -23
  30. package/dist/cjs/modules/word/advanced/math-convert.js +2 -1
  31. package/dist/cjs/modules/word/advanced/style-map.js +44 -6
  32. package/dist/cjs/modules/word/convert/html/html-import.js +434 -71
  33. package/dist/cjs/modules/word/convert/markdown/markdown-renderer.js +11 -3
  34. package/dist/cjs/modules/word/layout/layout-full.js +4 -1
  35. package/dist/cjs/modules/word/security/digital-signatures.js +160 -33
  36. package/dist/cjs/modules/word/security/encryption.js +109 -9
  37. package/dist/esm/modules/excel/worksheet.js +13 -0
  38. package/dist/esm/modules/formula/integration/apply-writeback-plan.js +17 -3
  39. package/dist/esm/modules/formula/integration/workbook-adapter.js +20 -1
  40. package/dist/esm/modules/formula/materialize/build-writeback-plan.js +47 -0
  41. package/dist/esm/modules/formula/materialize/types.js +13 -3
  42. package/dist/esm/modules/pdf/builder/document-builder.js +2 -2
  43. package/dist/esm/modules/pdf/font/system-fonts.js +76 -32
  44. package/dist/esm/modules/pdf/render/pdf-exporter.js +6 -3
  45. package/dist/esm/modules/word/advanced/field-engine.js +151 -23
  46. package/dist/esm/modules/word/advanced/math-convert.js +2 -1
  47. package/dist/esm/modules/word/advanced/style-map.js +44 -6
  48. package/dist/esm/modules/word/convert/html/html-import.js +434 -71
  49. package/dist/esm/modules/word/convert/markdown/markdown-renderer.js +11 -3
  50. package/dist/esm/modules/word/layout/layout-full.js +4 -1
  51. package/dist/esm/modules/word/security/digital-signatures.js +160 -33
  52. package/dist/esm/modules/word/security/encryption.js +109 -9
  53. package/dist/iife/excelts.iife.js +40 -26
  54. package/dist/iife/excelts.iife.js.map +1 -1
  55. package/dist/iife/excelts.iife.min.js +3 -3
  56. package/dist/types/modules/excel/worksheet.d.ts +11 -0
  57. package/dist/types/modules/formula/integration/workbook-snapshot.d.ts +12 -0
  58. package/dist/types/modules/formula/materialize/types.d.ts +19 -3
  59. package/dist/types/modules/pdf/font/system-fonts.d.ts +24 -4
  60. package/package.json +1 -1
@@ -11,7 +11,12 @@
11
11
  * .ttc (TrueType Collection) files are supported — parseTtf() extracts
12
12
  * the first font from the collection automatically.
13
13
  *
14
- * Results are cached: the filesystem search runs only once per process.
14
+ * Discovery is exposed both as a generator
15
+ * ({@link iterateSystemFontCandidates}) for early-exit callers and as
16
+ * an array snapshot ({@link discoverSystemFontCandidates}) for tests
17
+ * and full enumeration. Once the full snapshot has been produced it is
18
+ * cached and replayed on subsequent calls; partial iterations rely on
19
+ * the OS page cache to make repeat reads of the same font files cheap.
15
20
  */
16
21
  import { fileExistsSync, readFileBytesSync, traverseDirectorySync } from "../../../utils/fs.js";
17
22
  // =============================================================================
@@ -88,75 +93,114 @@ const PREFERRED_FONTS = [
88
93
  // =============================================================================
89
94
  // Font Discovery
90
95
  // =============================================================================
96
+ // Cached, fully-populated candidate list. Set when the generator runs
97
+ // to completion via `discoverSystemFontCandidates()`, or when a test
98
+ // injects candidates via `_setCandidatesForTest`. Partial iterations
99
+ // (where the caller `break`s after a match) intentionally do not
100
+ // populate this cache — they rely on the OS page cache to keep repeat
101
+ // reads cheap.
91
102
  let _cachedCandidates;
92
103
  /**
93
- * Return all discoverable system font candidates, ordered by preference.
104
+ * Lazily yield discoverable system font candidates, in preference order.
94
105
  *
95
106
  * Each entry is the raw font file bytes of a `.ttf` or `.ttc` file.
96
107
  * The caller decides which candidate to use (e.g. by checking cmap coverage).
97
108
  *
98
- * Results are cached the filesystem scan runs only once per process.
109
+ * Iterating one candidate at a time lets callers `break` as soon as
110
+ * they find a match, avoiding the cost of recursively reading every
111
+ * font in every system font directory just to discard them.
99
112
  */
100
- export function discoverSystemFontCandidates() {
113
+ export function* iterateSystemFontCandidates() {
114
+ // Fast path: a previous call already produced the full snapshot.
101
115
  if (_cachedCandidates !== undefined) {
102
- return _cachedCandidates;
116
+ for (const c of _cachedCandidates) {
117
+ yield c;
118
+ }
119
+ return;
103
120
  }
104
121
  if (typeof process === "undefined" || !process.platform) {
105
- _cachedCandidates = [];
106
- return _cachedCandidates;
122
+ return;
107
123
  }
108
- const candidates = [];
109
- const seen = new Set(); // dedupe by path
124
+ const seen = new Set(); // dedupe by path within this iteration
110
125
  const dirs = getSystemFontDirs();
111
- // Strategy 1: Check preferred font filenames (in order)
126
+ // Strategy 1: Check preferred font filenames (in order). These stat
127
+ // calls are cheap and run regardless — they are the only path most
128
+ // callers need to traverse.
112
129
  for (const fontName of PREFERRED_FONTS) {
113
130
  for (const dir of dirs) {
114
131
  const fontPath = `${dir}/${fontName}`;
115
132
  if (seen.has(fontPath)) {
116
133
  continue;
117
134
  }
135
+ seen.add(fontPath);
118
136
  if (fileExistsSync(fontPath)) {
119
137
  const data = tryReadFont(fontPath);
120
138
  if (data) {
121
- candidates.push(data);
122
- seen.add(fontPath);
139
+ yield data;
123
140
  }
124
141
  }
125
142
  }
126
143
  }
127
- // Strategy 2: Scan directories for any .ttf/.ttc not already found
144
+ // Strategy 2: Recursively scan each directory for any other .ttf/.ttc.
145
+ // This is the expensive step (hundreds of MB on macOS / Windows), so
146
+ // it walks one directory at a time and yields candidates as it goes —
147
+ // a caller that found a match in Strategy 1 (or in an earlier
148
+ // directory here) never reaches further directories.
128
149
  const broadRe = /noto|unicode|cjk|yahei|heiti|gothic|sans|serif|ming|song|dejavu|liberation|droid|wqy/i;
129
150
  for (const dir of dirs) {
151
+ let entries;
130
152
  try {
131
- const entries = traverseDirectorySync(dir, { recursive: true, filter: e => !e.isDirectory });
132
- const fonts = entries.filter(e => /\.tt[cf]$/i.test(e.absolutePath) && !seen.has(e.absolutePath));
133
- // Broad-coverage names first, then large files
134
- const broad = fonts.filter(e => broadRe.test(e.absolutePath));
135
- const rest = fonts.filter(e => !broadRe.test(e.absolutePath) && e.size > 50000);
136
- for (const entry of [...broad, ...rest]) {
137
- if (seen.has(entry.absolutePath)) {
138
- continue;
139
- }
140
- const data = tryReadFont(entry.absolutePath);
141
- if (data) {
142
- candidates.push(data);
143
- seen.add(entry.absolutePath);
144
- }
145
- }
153
+ entries = traverseDirectorySync(dir, { recursive: true, filter: e => !e.isDirectory });
146
154
  }
147
155
  catch {
148
156
  // Directory doesn't exist or not readable
157
+ continue;
158
+ }
159
+ const fonts = entries.filter(e => /\.tt[cf]$/i.test(e.absolutePath) && !seen.has(e.absolutePath));
160
+ // Broad-coverage names first, then large files
161
+ const broad = fonts.filter(e => broadRe.test(e.absolutePath));
162
+ const rest = fonts.filter(e => !broadRe.test(e.absolutePath) && e.size > 50000);
163
+ for (const entry of [...broad, ...rest]) {
164
+ if (seen.has(entry.absolutePath)) {
165
+ continue;
166
+ }
167
+ seen.add(entry.absolutePath);
168
+ const data = tryReadFont(entry.absolutePath);
169
+ if (data) {
170
+ yield data;
171
+ }
149
172
  }
150
173
  }
151
- _cachedCandidates = candidates;
152
- return candidates;
174
+ }
175
+ /**
176
+ * Return all discoverable system font candidates, ordered by preference.
177
+ *
178
+ * Each entry is the raw font file bytes of a `.ttf` or `.ttc` file.
179
+ * The caller decides which candidate to use (e.g. by checking cmap coverage).
180
+ *
181
+ * The full snapshot is cached: once produced, repeated calls return the
182
+ * same array without touching the filesystem.
183
+ *
184
+ * Prefer {@link iterateSystemFontCandidates} when you only need the
185
+ * first candidate that satisfies a predicate: it avoids reading every
186
+ * font in every system directory just to discard them.
187
+ */
188
+ export function discoverSystemFontCandidates() {
189
+ if (_cachedCandidates !== undefined) {
190
+ return _cachedCandidates;
191
+ }
192
+ const all = [];
193
+ for (const candidate of iterateSystemFontCandidates()) {
194
+ all.push(candidate);
195
+ }
196
+ _cachedCandidates = all;
197
+ return all;
153
198
  }
154
199
  /**
155
200
  * Search for a system font suitable for Unicode rendering.
156
201
  *
157
202
  * Returns the raw font file bytes of the highest-priority candidate,
158
- * or `null` if no font was found. This is a convenience wrapper around
159
- * {@link discoverSystemFontCandidates}.
203
+ * or `null` if no font was found.
160
204
  */
161
205
  export function discoverSystemFont() {
162
206
  const candidates = discoverSystemFontCandidates();
@@ -15,7 +15,7 @@ import { PdfContentStream, isWinAnsiCodePoint } from "../core/pdf-stream.js";
15
15
  import { PdfWriter } from "../core/pdf-writer.js";
16
16
  import { PdfError, PdfRenderError } from "../errors.js";
17
17
  import { FontManager, resolvePdfFontName } from "../font/font-manager.js";
18
- import { discoverSystemFontCandidates } from "../font/system-fonts.js";
18
+ import { iterateSystemFontCandidates } from "../font/system-fonts.js";
19
19
  import { parseTtf } from "../font/ttf-parser.js";
20
20
  import { PageSizes, PdfCellType, isPdfChartsheet } from "../types.js";
21
21
  import { createChartSurface } from "./chart-surface.js";
@@ -58,8 +58,11 @@ function prepareExport(workbook, options) {
58
58
  // Collect non-WinAnsi code points from the document (single pass)
59
59
  const nonWinAnsi = collectNonWinAnsiCodePoints(sheets);
60
60
  if (nonWinAnsi.size > 0) {
61
- // Try system font candidates in preference order until one covers all chars
62
- for (const candidate of discoverSystemFontCandidates()) {
61
+ // Try system font candidates lazily in preference order until one
62
+ // covers all chars. Iterating instead of materializing the full
63
+ // candidate list lets us stop the moment a match is found, which
64
+ // avoids the cost of recursively scanning every system font dir.
65
+ for (const candidate of iterateSystemFontCandidates()) {
63
66
  try {
64
67
  const testTtf = parseTtf(candidate);
65
68
  const allCovered = [...nonWinAnsi].every(cp => testTtf.cmap.has(cp));
@@ -180,32 +180,160 @@ function parseSeqSwitches(args) {
180
180
  }
181
181
  /** Parse IF field: IF expr1 op expr2 "trueText" "falseText" */
182
182
  function parseIfField(args) {
183
- // Pattern: expr1 = expr2 "trueText" "falseText"
184
- // Supports both quoted and unquoted operands.
185
- // Two-character operators must come first or `<=` would match `<` only.
186
- const match = /^"?([^"=<>!]*?)"?\s*(<=|>=|<>|=|<|>)\s*"?([^"]*?)"?\s+"([^"]*)"\s+"([^"]*)"/.exec(args);
187
- if (match) {
188
- return {
189
- left: match[1].trim(),
190
- operator: match[2],
191
- right: match[3].trim(),
192
- trueText: match[4],
193
- falseText: match[5]
194
- };
195
- }
196
- // Simpler pattern without quotes on operands
197
- const simpleMatch = /^(\S+)\s*(<=|>=|<>|=|<|>)\s*(\S+)\s+"([^"]*)"\s+"([^"]*)"/.exec(args);
198
- if (simpleMatch) {
199
- return {
200
- left: simpleMatch[1],
201
- operator: simpleMatch[2],
202
- right: simpleMatch[3],
203
- trueText: simpleMatch[4],
204
- falseText: simpleMatch[5]
205
- };
183
+ // Hand-rolled parser used in place of the previous chained regex
184
+ // (`/^"?([^"=<>!]*?)"?\s*(<=|>=|<>|=|<|>)\s*"?([^"]*?)"?\s+"([^"]*)"\s+"([^"]*)"/`).
185
+ // CodeQL flagged that regex as polynomial-redos. The grammar is also
186
+ // permissive in ways the regex captured implicitly: real Word IF
187
+ // fields contain operands such as `MERGEFIELD foo` (with internal
188
+ // whitespace) and operands wrapped in quotes. The scanner below
189
+ // mirrors the regex's accepted shape:
190
+ //
191
+ // args := SP* leftOperand SP* op SP* rightOperand SP+ "trueText" SP+ "falseText" …
192
+ //
193
+ // where `leftOperand` runs up to the first comparison operator that
194
+ // is not inside a quoted span, and `rightOperand` runs up to the
195
+ // first `"` that begins the trueText literal.
196
+ // 1. Find the comparison operator outside any quoted span.
197
+ const opPos = findIfOperator(args, 0);
198
+ if (!opPos) {
199
+ return null;
200
+ }
201
+ // 2. Left operand: everything before the operator, with surrounding
202
+ // whitespace and outer quotes stripped.
203
+ const left = stripOuterQuotes(args.slice(0, opPos.start).trim());
204
+ // 3. Right operand: text between the operator and the first quoted
205
+ // literal, with surrounding whitespace and outer quotes stripped.
206
+ let cursor = opPos.next;
207
+ // Scan to the next `"` that is not the immediate value-quoted operand.
208
+ // We have to be careful: the right operand itself may be quoted, e.g.
209
+ // `1 = "bar" "y" "n"`. To match the previous regex we adopt: skip
210
+ // whitespace, optionally consume one quoted span as the right operand,
211
+ // otherwise consume up to the next whitespace+`"` boundary.
212
+ cursor = skipSpaces(args, cursor);
213
+ let right;
214
+ if (args.charCodeAt(cursor) === 0x22 /* '"' */) {
215
+ const close = args.indexOf('"', cursor + 1);
216
+ if (close < 0) {
217
+ return null;
218
+ }
219
+ right = args.slice(cursor + 1, close);
220
+ cursor = close + 1;
221
+ }
222
+ else {
223
+ // Read until the next `"` (which begins the trueText literal).
224
+ const nextQuote = args.indexOf('"', cursor);
225
+ if (nextQuote < 0) {
226
+ return null;
227
+ }
228
+ right = args.slice(cursor, nextQuote).trim();
229
+ cursor = nextQuote;
230
+ }
231
+ // 4. trueText (required quoted string).
232
+ cursor = skipSpaces(args, cursor);
233
+ const trueRead = readQuotedString(args, cursor);
234
+ if (!trueRead) {
235
+ return null;
236
+ }
237
+ cursor = skipSpaces(args, trueRead.next);
238
+ // 5. falseText (required quoted string).
239
+ const falseRead = readQuotedString(args, cursor);
240
+ if (!falseRead) {
241
+ return null;
242
+ }
243
+ return {
244
+ left,
245
+ operator: opPos.value,
246
+ right,
247
+ trueText: trueRead.value,
248
+ falseText: falseRead.value
249
+ };
250
+ }
251
+ /**
252
+ * Find the first IF-field comparison operator (`<=`, `>=`, `<>`, `=`,
253
+ * `<`, `>`) starting at `from`, skipping over any quoted (`"…"`) spans
254
+ * so that operators inside operand strings are not mistaken for the
255
+ * top-level comparator.
256
+ *
257
+ * Bare `!` is reported as "no operator" rather than absorbed into the
258
+ * preceding operand: the previous regex excluded `!` from the left
259
+ * operand character class, so `1 != 1 …` was rejected outright. We
260
+ * preserve that rejection here to avoid silently parsing `!=` (not a
261
+ * Word IF-field operator) as `=` with `!` glued to the left operand.
262
+ */
263
+ function findIfOperator(s, from) {
264
+ const n = s.length;
265
+ let i = from;
266
+ while (i < n) {
267
+ const c = s.charCodeAt(i);
268
+ if (c === 0x22 /* '"' */) {
269
+ // Skip quoted span.
270
+ const close = s.indexOf('"', i + 1);
271
+ if (close < 0) {
272
+ return null;
273
+ }
274
+ i = close + 1;
275
+ continue;
276
+ }
277
+ if (c === 0x21 /* '!' */) {
278
+ // Reject `!` outside quotes — matches the previous regex behaviour.
279
+ return null;
280
+ }
281
+ if (c === 0x3c /* '<' */) {
282
+ const next = s.charCodeAt(i + 1);
283
+ if (next === 0x3d) {
284
+ return { start: i, next: i + 2, value: "<=" };
285
+ }
286
+ if (next === 0x3e) {
287
+ return { start: i, next: i + 2, value: "<>" };
288
+ }
289
+ return { start: i, next: i + 1, value: "<" };
290
+ }
291
+ if (c === 0x3e /* '>' */) {
292
+ if (s.charCodeAt(i + 1) === 0x3d) {
293
+ return { start: i, next: i + 2, value: ">=" };
294
+ }
295
+ return { start: i, next: i + 1, value: ">" };
296
+ }
297
+ if (c === 0x3d /* '=' */) {
298
+ return { start: i, next: i + 1, value: "=" };
299
+ }
300
+ i++;
206
301
  }
207
302
  return null;
208
303
  }
304
+ /**
305
+ * Strip a single matched pair of outer quotes (`"…"`) from a trimmed
306
+ * operand. Mirrors the implicit `"?…"?` shape of the original regex.
307
+ */
308
+ function stripOuterQuotes(s) {
309
+ if (s.length >= 2 && s.charCodeAt(0) === 0x22 && s.charCodeAt(s.length - 1) === 0x22) {
310
+ return s.slice(1, -1);
311
+ }
312
+ return s;
313
+ }
314
+ function skipSpaces(s, from) {
315
+ const n = s.length;
316
+ let i = from;
317
+ while (i < n) {
318
+ const c = s.charCodeAt(i);
319
+ if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) {
320
+ break;
321
+ }
322
+ i++;
323
+ }
324
+ return i;
325
+ }
326
+ /** Read a quoted (`"…"`) string starting at `from`, or return null. */
327
+ function readQuotedString(s, from) {
328
+ if (s.charCodeAt(from) !== 0x22 /* '"' */) {
329
+ return null;
330
+ }
331
+ const close = s.indexOf('"', from + 1);
332
+ if (close < 0) {
333
+ return null;
334
+ }
335
+ return { value: s.slice(from + 1, close), next: close + 1 };
336
+ }
209
337
  /** Parse STYLEREF field to get the style name. */
210
338
  function parseStyleRef(args) {
211
339
  // STYLEREF "StyleName" or STYLEREF StyleName
@@ -405,7 +405,8 @@ function parseMMLTree(xml) {
405
405
  // forever (indexOf returning -1 used to set pos = 0, hanging the CPU).
406
406
  const end = xml.indexOf(">", pos);
407
407
  if (end === -1) {
408
- pos = xml.length;
408
+ // Malformed input — stop parsing. (`pos` is unused after the
409
+ // outer loop terminates, so no further assignment is needed.)
409
410
  break;
410
411
  }
411
412
  pos = end + 1;
@@ -293,14 +293,52 @@ function parseTarget(targetStr) {
293
293
  }
294
294
  const tagName = tagMatch[1];
295
295
  const className = tagMatch[2] ? tagMatch[2].substring(1).replace(/\./g, " ") : undefined;
296
- // Parse attributes [key=value]
296
+ // Parse attributes [key=value]. Implemented as a linear scan rather
297
+ // than a global regex so that adversarial mapping strings cannot
298
+ // trigger CodeQL's `js/polynomial-redos`. The regex form
299
+ // `/\[([^=]+)=([^\]]+)\]/g` exhibits backtracking on inputs like
300
+ // `[xxxxxxxxxxxxx`.
297
301
  let attributes;
298
- if (tagMatch[3]) {
302
+ const attrSection = tagMatch[3];
303
+ if (attrSection) {
299
304
  attributes = {};
300
- const attrRegex = /\[([^=]+)=([^\]]+)\]/g;
301
- let attrMatch;
302
- while ((attrMatch = attrRegex.exec(tagMatch[3])) !== null) {
303
- attributes[attrMatch[1]] = attrMatch[2].replace(/^['"]|['"]$/g, "");
305
+ const len = attrSection.length;
306
+ let i = 0;
307
+ while (i < len) {
308
+ if (attrSection.charCodeAt(i) !== 0x5b /* '[' */) {
309
+ i++;
310
+ continue;
311
+ }
312
+ const eq = attrSection.indexOf("=", i + 1);
313
+ const close = attrSection.indexOf("]", i + 1);
314
+ if (close < 0) {
315
+ break;
316
+ }
317
+ if (eq < 0 || eq > close) {
318
+ // No `=` inside this `[...]` — skip past it.
319
+ i = close + 1;
320
+ continue;
321
+ }
322
+ const key = attrSection.slice(i + 1, eq);
323
+ const rawValue = attrSection.slice(eq + 1, close);
324
+ // Strip a single leading and trailing quote (' or "). Two
325
+ // independent linear strips replace the previous
326
+ // `/^['"]|['"]$/g` regex.
327
+ let v = rawValue;
328
+ if (v.length >= 2) {
329
+ const first = v.charCodeAt(0);
330
+ if (first === 0x27 || first === 0x22) {
331
+ v = v.slice(1);
332
+ }
333
+ }
334
+ if (v.length >= 1) {
335
+ const last = v.charCodeAt(v.length - 1);
336
+ if (last === 0x27 || last === 0x22) {
337
+ v = v.slice(0, -1);
338
+ }
339
+ }
340
+ attributes[key] = v;
341
+ i = close + 1;
304
342
  }
305
343
  }
306
344
  return { tagName, className: className || undefined, attributes };