@rowsncolumns/utils 9.3.0 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cjs/index.d.ts +5 -0
  2. package/dist/cjs/index.d.ts.map +1 -1
  3. package/dist/cjs/index.js +5 -0
  4. package/dist/cjs/rich-text-html.d.ts +82 -0
  5. package/dist/cjs/rich-text-html.d.ts.map +1 -0
  6. package/dist/cjs/rich-text-html.js +601 -0
  7. package/dist/cjs/rich-text-links.d.ts +23 -0
  8. package/dist/cjs/rich-text-links.d.ts.map +1 -0
  9. package/dist/cjs/rich-text-links.js +48 -0
  10. package/dist/cjs/sparkline-groups.d.ts +102 -0
  11. package/dist/cjs/sparkline-groups.d.ts.map +1 -0
  12. package/dist/cjs/sparkline-groups.js +452 -0
  13. package/dist/cjs/string.d.ts +15 -0
  14. package/dist/cjs/string.d.ts.map +1 -1
  15. package/dist/cjs/string.js +46 -1
  16. package/dist/cjs/table-names.d.ts +55 -0
  17. package/dist/cjs/table-names.d.ts.map +1 -0
  18. package/dist/cjs/table-names.js +189 -0
  19. package/dist/cjs/table-styles.d.ts +9 -0
  20. package/dist/cjs/table-styles.d.ts.map +1 -1
  21. package/dist/cjs/table-styles.js +53 -1
  22. package/dist/cjs/table-totals.d.ts +31 -0
  23. package/dist/cjs/table-totals.d.ts.map +1 -0
  24. package/dist/cjs/table-totals.js +84 -0
  25. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  26. package/dist/esm/index.d.ts +5 -0
  27. package/dist/esm/index.d.ts.map +1 -1
  28. package/dist/esm/index.js +5 -0
  29. package/dist/esm/rich-text-html.d.ts +82 -0
  30. package/dist/esm/rich-text-html.d.ts.map +1 -0
  31. package/dist/esm/rich-text-html.js +520 -0
  32. package/dist/esm/rich-text-links.d.ts +23 -0
  33. package/dist/esm/rich-text-links.d.ts.map +1 -0
  34. package/dist/esm/rich-text-links.js +31 -0
  35. package/dist/esm/sparkline-groups.d.ts +102 -0
  36. package/dist/esm/sparkline-groups.d.ts.map +1 -0
  37. package/dist/esm/sparkline-groups.js +387 -0
  38. package/dist/esm/string.d.ts +15 -0
  39. package/dist/esm/string.d.ts.map +1 -1
  40. package/dist/esm/string.js +43 -0
  41. package/dist/esm/table-names.d.ts +55 -0
  42. package/dist/esm/table-names.d.ts.map +1 -0
  43. package/dist/esm/table-names.js +129 -0
  44. package/dist/esm/table-styles.d.ts +9 -0
  45. package/dist/esm/table-styles.d.ts.map +1 -1
  46. package/dist/esm/table-styles.js +34 -1
  47. package/dist/esm/table-totals.d.ts +31 -0
  48. package/dist/esm/table-totals.d.ts.map +1 -0
  49. package/dist/esm/table-totals.js +68 -0
  50. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  51. package/package.json +5 -3
@@ -0,0 +1,520 @@
1
+ import { colorStringToHex } from "./color";
2
+ import { canUseDOM } from "./dom";
3
+ const BLOCK_TAGS = new Set([
4
+ "p",
5
+ "div",
6
+ "li",
7
+ "ul",
8
+ "ol",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "pre",
16
+ "tr",
17
+ "blockquote",
18
+ ]);
19
+ const SKIPPED_TAGS = new Set([
20
+ "style",
21
+ "script",
22
+ "meta",
23
+ "head",
24
+ "title",
25
+ "link",
26
+ "col",
27
+ "colgroup",
28
+ "template",
29
+ "noscript",
30
+ ]);
31
+ // `<font size="N">` → points (HTML 4 size keywords).
32
+ const FONT_SIZE_KEYWORDS = {
33
+ 1: 8,
34
+ 2: 10,
35
+ 3: 12,
36
+ 4: 14,
37
+ 5: 18,
38
+ 6: 24,
39
+ 7: 36,
40
+ };
41
+ // CSS named colors Excel / Word actually emit (`color:red`, `<font color=red>`).
42
+ // `getComputedStyle` in a browser already resolves names to `rgb()`, this
43
+ // table covers inline keywords and jsdom (which reports the declared value).
44
+ const NAMED_COLORS = {
45
+ black: "#000000",
46
+ white: "#FFFFFF",
47
+ red: "#FF0000",
48
+ green: "#008000",
49
+ lime: "#00FF00",
50
+ blue: "#0000FF",
51
+ yellow: "#FFFF00",
52
+ orange: "#FFA500",
53
+ purple: "#800080",
54
+ gray: "#808080",
55
+ grey: "#808080",
56
+ silver: "#C0C0C0",
57
+ maroon: "#800000",
58
+ navy: "#000080",
59
+ teal: "#008080",
60
+ olive: "#808000",
61
+ aqua: "#00FFFF",
62
+ cyan: "#00FFFF",
63
+ fuchsia: "#FF00FF",
64
+ magenta: "#FF00FF",
65
+ brown: "#A52A2A",
66
+ pink: "#FFC0CB",
67
+ gold: "#FFD700",
68
+ indigo: "#4B0082",
69
+ violet: "#EE82EE",
70
+ darkred: "#8B0000",
71
+ darkgreen: "#006400",
72
+ darkblue: "#00008B",
73
+ };
74
+ // Values that mean "no explicit color" — Excel's `windowtext`, CSS keywords.
75
+ const NON_COLORS = new Set([
76
+ "windowtext",
77
+ "inherit",
78
+ "initial",
79
+ "unset",
80
+ "currentcolor",
81
+ "transparent",
82
+ "auto",
83
+ "",
84
+ ]);
85
+ /**
86
+ * Parse an HTML clipboard payload into a hidden, mounted sandbox so
87
+ * `getComputedStyle` resolves the payload's own `<style>` rules (Excel's
88
+ * `.fontN` / `.xlNN` classes). Callers must `cleanup()` when done.
89
+ */
90
+ export const mountClipboardHtml = (clipboardValue) => {
91
+ const parser = new DOMParser();
92
+ const doc = parser.parseFromString(clipboardValue, "text/html");
93
+ const sandbox = document.createElement("div");
94
+ sandbox.style.cssText =
95
+ "position:fixed;left:-10000px;top:-10000px;width:0;height:0;overflow:hidden;opacity:0;pointer-events:none;font-size:10pt;";
96
+ sandbox.innerHTML = doc.body?.innerHTML ?? "";
97
+ const styles = doc.head?.querySelectorAll("style") ?? [];
98
+ styles.forEach((style) => sandbox.appendChild(style.cloneNode(true)));
99
+ document.body.appendChild(sandbox);
100
+ return {
101
+ root: sandbox,
102
+ cleanup: () => sandbox.remove(),
103
+ };
104
+ };
105
+ export const cssColorToHex = (value) => {
106
+ if (!value) {
107
+ return undefined;
108
+ }
109
+ const trimmed = value.trim();
110
+ const lower = trimmed.toLowerCase();
111
+ if (NON_COLORS.has(lower)) {
112
+ return undefined;
113
+ }
114
+ // `rgba(0, 0, 0, 0)` is the transparent default many engines report.
115
+ if (/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(lower)) {
116
+ return undefined;
117
+ }
118
+ const hex = colorStringToHex(trimmed);
119
+ if (hex) {
120
+ return hex.toUpperCase();
121
+ }
122
+ return NAMED_COLORS[lower];
123
+ };
124
+ /** CSS font-size → points. `pt` verbatim, `px` at 96dpi, `em`/`rem` on a 16px base. */
125
+ export const cssFontSizeToPt = (value) => {
126
+ if (!value) {
127
+ return undefined;
128
+ }
129
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(pt|px|rem|em)?$/i);
130
+ if (!match) {
131
+ return undefined;
132
+ }
133
+ const numeric = parseFloat(match[1]);
134
+ if (!Number.isFinite(numeric) || numeric <= 0) {
135
+ return undefined;
136
+ }
137
+ const unit = (match[2] ?? "px").toLowerCase();
138
+ const pt = unit === "pt"
139
+ ? numeric
140
+ : unit === "px"
141
+ ? numeric * 0.75
142
+ : numeric * 16 * 0.75;
143
+ return Number(pt.toFixed(1));
144
+ };
145
+ const firstFontFamily = (value) => {
146
+ if (!value) {
147
+ return undefined;
148
+ }
149
+ const family = value
150
+ .split(",")[0]
151
+ .trim()
152
+ .replace(/^["']|["']$/g, "")
153
+ .trim();
154
+ if (!family || /^(inherit|initial|unset)$/i.test(family)) {
155
+ return undefined;
156
+ }
157
+ return family;
158
+ };
159
+ const safeComputedStyle = (element) => {
160
+ if (!canUseDOM || typeof getComputedStyle !== "function") {
161
+ return null;
162
+ }
163
+ try {
164
+ return getComputedStyle(element);
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ };
170
+ const readWeight = (value) => {
171
+ if (!value) {
172
+ return undefined;
173
+ }
174
+ const lower = value.trim().toLowerCase();
175
+ if (lower === "bold" || lower === "bolder") {
176
+ return true;
177
+ }
178
+ if (lower === "normal" || lower === "lighter") {
179
+ return false;
180
+ }
181
+ const numeric = Number(lower);
182
+ if (Number.isFinite(numeric)) {
183
+ return numeric >= 600;
184
+ }
185
+ return undefined;
186
+ };
187
+ const normalizeWhitespace = (text) => text.replace(/[\n\r]+/g, " ").replace(/\s{2,}/g, " ");
188
+ /**
189
+ * The formatting an element applies on top of `inherited`: tag semantics
190
+ * (`<b>`, `<font color>`, `<a href>`, …) + inline `style` + (except for
191
+ * anchors) the cascaded computed style.
192
+ */
193
+ const readElementFormat = (element, inherited) => {
194
+ const format = { ...inherited };
195
+ const tag = element.tagName.toLowerCase();
196
+ let attrColor;
197
+ let attrFace;
198
+ let attrSize;
199
+ switch (tag) {
200
+ case "b":
201
+ case "strong":
202
+ format.bold = true;
203
+ break;
204
+ case "i":
205
+ case "em":
206
+ format.italic = true;
207
+ break;
208
+ case "u":
209
+ format.underline = true;
210
+ break;
211
+ case "s":
212
+ case "strike":
213
+ case "del":
214
+ format.strikethrough = true;
215
+ break;
216
+ case "sup":
217
+ format.baselineOffset = "superscript";
218
+ break;
219
+ case "sub":
220
+ format.baselineOffset = "subscript";
221
+ break;
222
+ case "font": {
223
+ // Presentational attributes. They rank between the inline style and
224
+ // the cascaded computed style below: a browser folds them into the
225
+ // computed value, jsdom reports the inherited one instead.
226
+ attrColor = cssColorToHex(element.getAttribute("color"));
227
+ attrFace = firstFontFamily(element.getAttribute("face"));
228
+ const size = parseInt(element.getAttribute("size") ?? "", 10);
229
+ if (!Number.isNaN(size) && FONT_SIZE_KEYWORDS[size]) {
230
+ attrSize = FONT_SIZE_KEYWORDS[size];
231
+ }
232
+ break;
233
+ }
234
+ case "a": {
235
+ const href = element.getAttribute("href");
236
+ if (href && href.trim()) {
237
+ format.link = { uri: href.trim() };
238
+ }
239
+ break;
240
+ }
241
+ }
242
+ const inline = element.style;
243
+ const computed = tag === "a" ? null : safeComputedStyle(element);
244
+ const pick = (inlineValue, computedValue) => (inlineValue && inlineValue.trim() ? inlineValue : computedValue);
245
+ const weight = readWeight(pick(inline.fontWeight, computed?.fontWeight));
246
+ if (weight !== undefined) {
247
+ format.bold = weight;
248
+ }
249
+ const fontStyle = pick(inline.fontStyle, computed?.fontStyle);
250
+ if (fontStyle) {
251
+ const lower = fontStyle.trim().toLowerCase();
252
+ if (lower === "italic" || lower === "oblique") {
253
+ format.italic = true;
254
+ }
255
+ else if (lower === "normal") {
256
+ format.italic = false;
257
+ }
258
+ }
259
+ // `text-decoration` is not inherited in CSS but propagates visually, so a
260
+ // parent's underline stays on (inherited state) unless a child says `none`.
261
+ const decoration = pick(inline.textDecoration || inline.textDecorationLine, computed?.textDecorationLine || computed?.textDecoration);
262
+ if (decoration) {
263
+ const lower = decoration.toLowerCase();
264
+ if (lower.includes("underline")) {
265
+ format.underline = true;
266
+ }
267
+ if (lower.includes("line-through")) {
268
+ format.strikethrough = true;
269
+ }
270
+ if (/^\s*none\s*$/.test(lower)) {
271
+ format.underline = false;
272
+ format.strikethrough = false;
273
+ }
274
+ }
275
+ const verticalAlign = pick(inline.verticalAlign, computed?.verticalAlign)?.toLowerCase();
276
+ if (verticalAlign === "super") {
277
+ format.baselineOffset = "superscript";
278
+ }
279
+ else if (verticalAlign === "sub") {
280
+ format.baselineOffset = "subscript";
281
+ }
282
+ else if (verticalAlign === "baseline" && tag !== "sup" && tag !== "sub") {
283
+ format.baselineOffset = null;
284
+ }
285
+ const color = cssColorToHex(inline.color) ?? attrColor ?? cssColorToHex(computed?.color);
286
+ if (color) {
287
+ format.color = color;
288
+ }
289
+ const fontSize = cssFontSizeToPt(inline.fontSize) ??
290
+ attrSize ??
291
+ cssFontSizeToPt(computed?.fontSize);
292
+ if (fontSize !== undefined) {
293
+ format.fontSize = fontSize;
294
+ }
295
+ const fontFamily = firstFontFamily(inline.fontFamily) ??
296
+ attrFace ??
297
+ firstFontFamily(computed?.fontFamily);
298
+ if (fontFamily) {
299
+ format.fontFamily = fontFamily;
300
+ }
301
+ return format;
302
+ };
303
+ const isBlockElement = (element) => {
304
+ const tag = element.tagName.toLowerCase();
305
+ if (BLOCK_TAGS.has(tag)) {
306
+ return true;
307
+ }
308
+ const display = element.style.display?.toLowerCase();
309
+ return display === "block" || display === "list-item";
310
+ };
311
+ const collectSegments = (node, inherited, segments) => {
312
+ if (node.nodeType === Node.TEXT_NODE) {
313
+ const text = normalizeWhitespace(node.textContent ?? "");
314
+ if (text) {
315
+ segments.push({ text, format: inherited });
316
+ }
317
+ return;
318
+ }
319
+ if (node.nodeType !== Node.ELEMENT_NODE) {
320
+ return;
321
+ }
322
+ const element = node;
323
+ const tag = element.tagName.toLowerCase();
324
+ if (SKIPPED_TAGS.has(tag)) {
325
+ return;
326
+ }
327
+ if (tag === "br") {
328
+ segments.push({ text: "\n", format: inherited });
329
+ return;
330
+ }
331
+ const block = isBlockElement(element);
332
+ if (block && segments.length && !endsWithNewline(segments)) {
333
+ segments.push({ text: "\n", format: inherited });
334
+ }
335
+ const format = readElementFormat(element, inherited);
336
+ for (const child of Array.from(element.childNodes)) {
337
+ collectSegments(child, format, segments);
338
+ }
339
+ if (block && segments.length && !endsWithNewline(segments)) {
340
+ segments.push({ text: "\n", format: inherited });
341
+ }
342
+ };
343
+ const endsWithNewline = (segments) => {
344
+ const last = segments[segments.length - 1];
345
+ return !!last && last.text.endsWith("\n");
346
+ };
347
+ const TEXT_KEYS = [
348
+ "bold",
349
+ "italic",
350
+ "underline",
351
+ "strikethrough",
352
+ "baselineOffset",
353
+ "color",
354
+ "fontSize",
355
+ "fontFamily",
356
+ ];
357
+ /** The properties of `format` that differ from `base` (the run's own contribution). */
358
+ const diffFormat = (format, base) => {
359
+ const diff = {};
360
+ for (const key of TEXT_KEYS) {
361
+ const value = format[key];
362
+ const baseValue = base[key];
363
+ const normalizedValue = value ?? undefined;
364
+ const normalizedBase = baseValue ?? undefined;
365
+ if (normalizedValue === normalizedBase) {
366
+ continue;
367
+ }
368
+ if (normalizedValue === undefined || normalizedValue === false) {
369
+ // A run switching OFF a base property (bold cell, normal run).
370
+ if (normalizedBase) {
371
+ diff[key] =
372
+ key === "baselineOffset" ? null : false;
373
+ }
374
+ continue;
375
+ }
376
+ // Black on an unstyled base is the default text color, not a run color.
377
+ if (key === "color" &&
378
+ normalizedBase === undefined &&
379
+ typeof normalizedValue === "string" &&
380
+ normalizedValue.toUpperCase() === "#000000") {
381
+ continue;
382
+ }
383
+ diff[key] = normalizedValue;
384
+ }
385
+ return diff;
386
+ };
387
+ const sameFormat = (a, b) => {
388
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
389
+ for (const key of keys) {
390
+ const av = a[key];
391
+ const bv = b[key];
392
+ if (key === "link") {
393
+ if (av?.uri !== bv?.uri) {
394
+ return false;
395
+ }
396
+ continue;
397
+ }
398
+ if (av !== bv) {
399
+ return false;
400
+ }
401
+ }
402
+ return true;
403
+ };
404
+ const isEmptyFormat = (format) => Object.keys(format).length === 0;
405
+ /**
406
+ * Convert a mounted element (a clipboard `<td>`, or the sandbox wrapper for
407
+ * table-less HTML) into the engine's rich-text shape. See the module doc.
408
+ */
409
+ export const htmlElementToRichText = (root) => {
410
+ const baseFormat = normalizeBase(readElementFormat(root, {}));
411
+ const segments = [];
412
+ for (const child of Array.from(root.childNodes)) {
413
+ collectSegments(child, baseFormat, segments);
414
+ }
415
+ // Trim outer whitespace (leading/trailing spaces and line breaks) so the
416
+ // text matches `extractTextContent`, shifting the segments with it.
417
+ const joined = segments.map((s) => s.text).join("");
418
+ const leading = joined.length - joined.trimStart().length;
419
+ const text = joined.trim();
420
+ const runs = [];
421
+ const links = [];
422
+ let cursor = -leading;
423
+ for (const segment of segments) {
424
+ const start = Math.max(0, cursor);
425
+ const end = Math.min(text.length, cursor + segment.text.length);
426
+ cursor += segment.text.length;
427
+ if (end <= start) {
428
+ continue;
429
+ }
430
+ const format = diffFormat(segment.format, baseFormat);
431
+ const uri = segment.format.link?.uri;
432
+ if (uri) {
433
+ format.link = { uri };
434
+ const previous = links[links.length - 1];
435
+ if (previous && previous.uri === uri && previous.endIndex === start) {
436
+ previous.endIndex = end;
437
+ }
438
+ else {
439
+ links.push({ startIndex: start, endIndex: end, uri });
440
+ }
441
+ }
442
+ if (isEmptyFormat(format)) {
443
+ continue;
444
+ }
445
+ const previous = runs[runs.length - 1];
446
+ if (previous &&
447
+ previous.nodeType === "text" &&
448
+ previous.endIndex === start &&
449
+ sameFormat(previous.format, format)) {
450
+ previous.endIndex = end;
451
+ continue;
452
+ }
453
+ runs.push({ startIndex: start, endIndex: end, format, nodeType: "text" });
454
+ }
455
+ const wholeTextLink = links.find((link) => link.startIndex === 0 && link.endIndex === text.length);
456
+ const hyperlink = wholeTextLink?.uri ?? links[0]?.uri;
457
+ return {
458
+ text,
459
+ runs: runs.length ? runs : undefined,
460
+ links,
461
+ hyperlink,
462
+ baseFormat,
463
+ isRich: runs.length > 0 || links.length > 0,
464
+ };
465
+ };
466
+ // The base drops the segment-only `link` key and `false`/`null` values — a
467
+ // container can't be "not bold" more than the default already is.
468
+ const normalizeBase = (format) => {
469
+ const base = {};
470
+ for (const key of TEXT_KEYS) {
471
+ const value = format[key];
472
+ if (value === undefined || value === null || value === false) {
473
+ continue;
474
+ }
475
+ base[key] = value;
476
+ }
477
+ return base;
478
+ };
479
+ /**
480
+ * The element a single-cell clipboard payload's text lives in: the lone
481
+ * `<td>`/`<th>` of a one-cell table (Excel / Google Sheets), or the mounted
482
+ * wrapper for table-less HTML. `undefined` for a multi-cell table — that is a
483
+ * grid paste, not a rich-text one.
484
+ */
485
+ export const findSingleClipboardCell = (root) => {
486
+ const cells = root.querySelectorAll("td,th");
487
+ if (cells.length > 1) {
488
+ return undefined;
489
+ }
490
+ if (cells.length === 1) {
491
+ return cells[0];
492
+ }
493
+ return root;
494
+ };
495
+ /**
496
+ * One-shot helper for the editor targets: mount the clipboard HTML, convert
497
+ * the single cell it carries, clean up. `undefined` when the payload is a
498
+ * multi-cell table or carries no formatting/links worth keeping (the caller
499
+ * then falls back to its plain-text handling).
500
+ */
501
+ export const clipboardHtmlToRichText = (html) => {
502
+ if (!canUseDOM || !html?.trim()) {
503
+ return undefined;
504
+ }
505
+ const { root, cleanup } = mountClipboardHtml(html);
506
+ try {
507
+ const cell = findSingleClipboardCell(root);
508
+ if (!cell) {
509
+ return undefined;
510
+ }
511
+ const parsed = htmlElementToRichText(cell);
512
+ if (!parsed.text || !parsed.isRich) {
513
+ return undefined;
514
+ }
515
+ return parsed;
516
+ }
517
+ finally {
518
+ cleanup();
519
+ }
520
+ };
@@ -0,0 +1,23 @@
1
+ import type { TextFormatRun } from "@rowsncolumns/common-types";
2
+ /**
3
+ * Reconcile a committed string's run-level links (`TextFormatRun.format.link`)
4
+ * with the cell-level `hyperlink`. Excel keeps ONE link per cell, painted and
5
+ * followed at the cell level; Google Sheets keeps several inside the text.
6
+ * Both engines apply this on commit (`SheetCell.setStringValue`, rnc-store
7
+ * `apply_set_value_rich_text`) so a rich paste lands the same way whether it
8
+ * came through the grid, the formula bar or the in-cell editor:
9
+ *
10
+ * - a cell without a hyperlink takes the FIRST run link as its hyperlink;
11
+ * - a run link equal to the cell hyperlink that spans the WHOLE text is
12
+ * redundant (that is exactly what the cell hyperlink says) and is stripped
13
+ * — the run itself goes when nothing else is left on it;
14
+ * - every other run link stays as data (the multi-link cell keeps them all).
15
+ */
16
+ export type ResolvedRunLinks = {
17
+ runs: TextFormatRun[] | undefined | null;
18
+ hyperlink: string | undefined;
19
+ /** True when `runs` or `hyperlink` differ from the input. */
20
+ changed: boolean;
21
+ };
22
+ export declare const promoteRunLinkToHyperlink: (text: string, runs: TextFormatRun[] | undefined | null, hyperlink: string | undefined | null) => ResolvedRunLinks;
23
+ //# sourceMappingURL=rich-text-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-text-links.d.ts","sourceRoot":"","sources":["../../rich-text-links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,aAAa,EAAE,GAAG,SAAS,GAAG,IAAI,CAAC;IACzC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,eAAO,MAAM,yBAAyB,GACpC,MAAM,MAAM,EACZ,MAAM,aAAa,EAAE,GAAG,SAAS,GAAG,IAAI,EACxC,WAAW,MAAM,GAAG,SAAS,GAAG,IAAI,KACnC,gBAkCF,CAAC"}
@@ -0,0 +1,31 @@
1
+ export const promoteRunLinkToHyperlink = (text, runs, hyperlink) => {
2
+ if (!runs || runs.length === 0) {
3
+ return { runs, hyperlink: hyperlink ?? undefined, changed: false };
4
+ }
5
+ const firstLink = runs.find((run) => run.nodeType !== "mention" && run.format?.link?.uri);
6
+ if (!firstLink || firstLink.nodeType === "mention") {
7
+ return { runs, hyperlink: hyperlink ?? undefined, changed: false };
8
+ }
9
+ const resolvedHyperlink = hyperlink || firstLink.format.link.uri;
10
+ let changed = resolvedHyperlink !== (hyperlink ?? undefined);
11
+ const stripped = [];
12
+ for (const run of runs) {
13
+ if (run.nodeType === "mention" ||
14
+ run.format?.link?.uri !== resolvedHyperlink ||
15
+ run.startIndex !== 0 ||
16
+ run.endIndex < text.length) {
17
+ stripped.push(run);
18
+ continue;
19
+ }
20
+ changed = true;
21
+ const { link: _link, ...rest } = run.format;
22
+ if (Object.keys(rest).length > 0) {
23
+ stripped.push({ ...run, format: rest });
24
+ }
25
+ }
26
+ return {
27
+ runs: changed ? (stripped.length ? stripped : undefined) : runs,
28
+ hyperlink: resolvedHyperlink,
29
+ changed,
30
+ };
31
+ };
@@ -0,0 +1,102 @@
1
+ import type { CellInterface, GridRange, SparkLineResultOptions } from "@rowsncolumns/common-types";
2
+ /**
3
+ * Excel sparkline groups (Insert → Sparklines → Line / Column / Win-Loss) on top of the
4
+ * `=SPARKLINE(dataRange, {options})` cell formula — the shape the XLSX importers of BOTH engines
5
+ * produce for an `<x14:sparklineGroup>` (one formula per `<x14:sparkline>`, the group's attributes
6
+ * as the option literal) and the shape the exporters fold back into a group (cells sharing one option
7
+ * fingerprint share one `<x14:sparklineGroup>`). A group therefore has no separate document object:
8
+ * it is the connected run of location cells whose SPARKLINE formulas carry identical options. That
9
+ * keeps live recalc, reference shifting on insert/delete and the round-trip as ordinary formula
10
+ * behavior on both engines.
11
+ */
12
+ export type SparklineGroupType = "line" | "column" | "winloss";
13
+ /** One sparkline of a group: the cell it paints in and the data range it charts. */
14
+ export type SparklineGroupMember = {
15
+ cell: CellInterface;
16
+ /** As written in the formula (optionally sheet-qualified, e.g. `Data!A2:E2`). */
17
+ dataRange: string;
18
+ };
19
+ /** The group options (everything but `charttype`, which is the group's `type`). */
20
+ export type SparklineGroupOptions = Omit<SparkLineResultOptions, "charttype">;
21
+ export type SparklineGroup = {
22
+ sheetId: number;
23
+ type: SparklineGroupType;
24
+ options: SparklineGroupOptions;
25
+ /** In location order (top → bottom, then left → right). */
26
+ members: SparklineGroupMember[];
27
+ };
28
+ export type ParsedSparklineFormula = {
29
+ dataRange: string;
30
+ options: SparkLineResultOptions;
31
+ };
32
+ export declare const isSparklineFormula: (formula: unknown) => formula is string;
33
+ /**
34
+ * `=SPARKLINE(A1:E1,{"charttype","column","color","#FF0000"})` → its data range and options with the
35
+ * keys canonicalized to `SparkLineResultOptions` (both option-literal shapes and the legacy
36
+ * `highpoint`/… spellings are accepted). `null` when the formula is not a SPARKLINE call.
37
+ */
38
+ export declare const parseSparklineFormula: (formula: string) => ParsedSparklineFormula | null;
39
+ /**
40
+ * The inverse of {@link parseSparklineFormula}: `=SPARKLINE(range)` when no option is set, else
41
+ * `=SPARKLINE(range,{"k",v,…})` in the importers' emission order. Unset, `false` and `line`
42
+ * (the default type) options are omitted.
43
+ */
44
+ export declare const buildSparklineFormula: (dataRange: string, options?: SparkLineResultOptions) => string;
45
+ export declare const sparklineTypeFromOptions: (options: SparkLineResultOptions | undefined) => SparklineGroupType;
46
+ /** Group identity: two SPARKLINE cells belong to one group when this string matches. */
47
+ export declare const sparklineOptionsFingerprint: (options: SparkLineResultOptions) => string;
48
+ export type ResolveSparklineGroupArgs = {
49
+ sheetId: number;
50
+ cell: CellInterface;
51
+ /** The user-entered formula of a cell (`undefined` for a non-formula cell). */
52
+ getFormula: (sheetId: number, rowIndex: number, columnIndex: number) => string | undefined;
53
+ /** Upper bound on the cells walked (a runaway sheet-wide fill would freeze the UI). */
54
+ maxCells?: number;
55
+ };
56
+ /**
57
+ * The sparkline group containing `cell`: the 4-connected run of SPARKLINE cells around it whose
58
+ * options match the cell's. `undefined` when the cell holds no SPARKLINE formula.
59
+ */
60
+ export declare const resolveSparklineGroup: ({ sheetId, cell, getFormula, maxCells, }: ResolveSparklineGroupArgs) => SparklineGroup | undefined;
61
+ export type ParsedRangeAddress = {
62
+ /** The sheet name as typed (quotes removed), `undefined` for a local reference. */
63
+ sheetName?: string;
64
+ range: GridRange;
65
+ };
66
+ /** `'Q1 Data'!A1:E5` / `Sheet1!A1` / `$A$1:$E$5` → its sheet name and grid range. */
67
+ export declare const parseRangeAddress: (address: string) => ParsedRangeAddress | null;
68
+ /** A grid range → `A1:E5` (a single cell → `A1`), optionally sheet-qualified. */
69
+ export declare const rangeToA1: (range: GridRange, sheetName?: string) => string;
70
+ export type PlanSparklineGroupArgs = {
71
+ /** Excel's "Data Range" (may be sheet-qualified). */
72
+ dataRange: string;
73
+ /** Excel's "Location Range" — a single row or column of cells on the group's sheet. */
74
+ locationRange: string;
75
+ };
76
+ export type SparklineGroupPlan = {
77
+ members: SparklineGroupMember[];
78
+ error?: undefined;
79
+ } | {
80
+ members?: undefined;
81
+ error: string;
82
+ };
83
+ /**
84
+ * Excel's Create Sparklines rule: one sparkline per location cell, each charting one row (or one
85
+ * column) of the data range. A single location cell charts the whole data range; otherwise the data
86
+ * range must span exactly as many rows, or exactly as many columns, as the location has cells.
87
+ */
88
+ export declare const planSparklineGroup: ({ dataRange, locationRange, }: PlanSparklineGroupArgs) => SparklineGroupPlan;
89
+ /**
90
+ * The location Excel's dialog is seeded with for a data selection: the column just right of the
91
+ * data (one sparkline per data row), or the cell right of a single-row selection.
92
+ */
93
+ export declare const defaultSparklineLocation: (data: GridRange) => GridRange;
94
+ /** The bounding range of a group's location cells (its members are a single row or column run). */
95
+ export declare const sparklineGroupLocation: (group: SparklineGroup) => GridRange;
96
+ /**
97
+ * The group's "Data Range" as Excel's Edit Group dialog shows it: the union of the members' ranges
98
+ * when they tile one rectangle by rows or columns; otherwise `undefined` (members chart unrelated
99
+ * ranges — Excel then shows the individual ranges only).
100
+ */
101
+ export declare const sparklineGroupDataRange: (group: SparklineGroup) => string | undefined;
102
+ //# sourceMappingURL=sparkline-groups.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sparkline-groups.d.ts","sourceRoot":"","sources":["../../sparkline-groups.ts"],"names":[],"mappings":"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"}