@opengeni/artifact-tool 0.2.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 +190 -0
- package/README.md +104 -0
- package/dist/bounded-zip.d.ts +28 -0
- package/dist/chunk-4BSENMAE.js +39 -0
- package/dist/chunk-4BSENMAE.js.map +1 -0
- package/dist/chunk-4FRFNNBM.js +276 -0
- package/dist/chunk-4FRFNNBM.js.map +1 -0
- package/dist/chunk-5XIA6KHZ.js +49 -0
- package/dist/chunk-5XIA6KHZ.js.map +1 -0
- package/dist/chunk-66CT2ZRP.js +2229 -0
- package/dist/chunk-66CT2ZRP.js.map +1 -0
- package/dist/chunk-72SYDU4O.js +319 -0
- package/dist/chunk-72SYDU4O.js.map +1 -0
- package/dist/chunk-B2ZJRZDA.js +279 -0
- package/dist/chunk-B2ZJRZDA.js.map +1 -0
- package/dist/chunk-DCTLTNPW.js +2458 -0
- package/dist/chunk-DCTLTNPW.js.map +1 -0
- package/dist/chunk-FGZM4FQ6.js +473 -0
- package/dist/chunk-FGZM4FQ6.js.map +1 -0
- package/dist/chunk-HTJBLBDE.js +591 -0
- package/dist/chunk-HTJBLBDE.js.map +1 -0
- package/dist/chunk-IYFXZQ34.js +5306 -0
- package/dist/chunk-IYFXZQ34.js.map +1 -0
- package/dist/chunk-J6N7IR44.js +172 -0
- package/dist/chunk-J6N7IR44.js.map +1 -0
- package/dist/chunk-N2IHNWBS.js +1002 -0
- package/dist/chunk-N2IHNWBS.js.map +1 -0
- package/dist/chunk-NAG44K67.js +549 -0
- package/dist/chunk-NAG44K67.js.map +1 -0
- package/dist/chunk-O24KTEGP.js +33 -0
- package/dist/chunk-O24KTEGP.js.map +1 -0
- package/dist/chunk-PI2AZVGK.js +2074 -0
- package/dist/chunk-PI2AZVGK.js.map +1 -0
- package/dist/chunk-Q2FFHEY7.js +40 -0
- package/dist/chunk-Q2FFHEY7.js.map +1 -0
- package/dist/chunk-RBGQADXA.js +57 -0
- package/dist/chunk-RBGQADXA.js.map +1 -0
- package/dist/chunk-U5G2BKM2.js +613 -0
- package/dist/chunk-U5G2BKM2.js.map +1 -0
- package/dist/chunk-VOJHMSM3.js +39 -0
- package/dist/chunk-VOJHMSM3.js.map +1 -0
- package/dist/chunk-VUNV25KB.js +16 -0
- package/dist/chunk-VUNV25KB.js.map +1 -0
- package/dist/chunk-XIFUNVBO.js +101 -0
- package/dist/chunk-XIFUNVBO.js.map +1 -0
- package/dist/chunk-ZCJYRJGB.js +489 -0
- package/dist/chunk-ZCJYRJGB.js.map +1 -0
- package/dist/chunk-ZZXPEE4S.js +2260 -0
- package/dist/chunk-ZZXPEE4S.js.map +1 -0
- package/dist/document-docx-api.d.ts +49 -0
- package/dist/document-docx-codec.d.ts +9 -0
- package/dist/document-docx-codec.js +4765 -0
- package/dist/document-docx-codec.js.map +1 -0
- package/dist/document-docx-import.d.ts +261 -0
- package/dist/document-docx-packer.d.ts +18 -0
- package/dist/document-docx-state.d.ts +12 -0
- package/dist/document-render.d.ts +3 -0
- package/dist/document-render.js +492 -0
- package/dist/document-render.js.map +1 -0
- package/dist/document.d.ts +432 -0
- package/dist/document.js +37 -0
- package/dist/document.js.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/file-blob.d.ts +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +186 -0
- package/dist/index.js.map +1 -0
- package/dist/inspection.d.ts +12 -0
- package/dist/kernel.d.ts +101 -0
- package/dist/lib-GQLPACFK.js +9746 -0
- package/dist/lib-GQLPACFK.js.map +1 -0
- package/dist/materializer-cli-entry.d.ts +2 -0
- package/dist/materializer-cli-entry.js +29 -0
- package/dist/materializer-cli-entry.js.map +1 -0
- package/dist/materializer-cli.d.ts +24 -0
- package/dist/materializer-cli.js +24 -0
- package/dist/materializer-cli.js.map +1 -0
- package/dist/native-raster.d.ts +10 -0
- package/dist/native.d.ts +129 -0
- package/dist/native.js +31 -0
- package/dist/native.js.map +1 -0
- package/dist/office-import.d.ts +48 -0
- package/dist/office-import.js +154 -0
- package/dist/office-import.js.map +1 -0
- package/dist/presentation-pptx-api.d.ts +60 -0
- package/dist/presentation-pptx-import.d.ts +5 -0
- package/dist/presentation-pptx-state-digest-765CZZED.js +10 -0
- package/dist/presentation-pptx-state-digest-765CZZED.js.map +1 -0
- package/dist/presentation-pptx-state-digest.d.ts +3 -0
- package/dist/presentation-pptx-state.d.ts +4 -0
- package/dist/presentation-pptx-xml.d.ts +29 -0
- package/dist/presentation-pptx.d.ts +5 -0
- package/dist/presentation-pptx.js +2365 -0
- package/dist/presentation-pptx.js.map +1 -0
- package/dist/presentation-render.d.ts +29 -0
- package/dist/presentation-render.js +375 -0
- package/dist/presentation-render.js.map +1 -0
- package/dist/presentation.d.ts +565 -0
- package/dist/presentation.js +71 -0
- package/dist/presentation.js.map +1 -0
- package/dist/production-composite.d.ts +68 -0
- package/dist/production-document.d.ts +2 -0
- package/dist/production-document.js +176 -0
- package/dist/production-document.js.map +1 -0
- package/dist/production-native-codecs.d.ts +94 -0
- package/dist/production-presentation.d.ts +2 -0
- package/dist/production-presentation.js +176 -0
- package/dist/production-presentation.js.map +1 -0
- package/dist/production-sha256.d.ts +2 -0
- package/dist/production-spreadsheet-native.d.ts +12 -0
- package/dist/production-spreadsheet.d.ts +2 -0
- package/dist/production-spreadsheet.js +176 -0
- package/dist/production-spreadsheet.js.map +1 -0
- package/dist/production.d.ts +96 -0
- package/dist/raster-image.d.ts +34 -0
- package/dist/reference.d.ts +17 -0
- package/dist/reference.js +574 -0
- package/dist/reference.js.map +1 -0
- package/dist/runtime-cli-entry.d.ts +2 -0
- package/dist/runtime-cli-entry.js +21 -0
- package/dist/runtime-cli-entry.js.map +1 -0
- package/dist/runtime-cli.d.ts +76 -0
- package/dist/runtime-cli.js +26 -0
- package/dist/runtime-cli.js.map +1 -0
- package/dist/runtime-development.d.ts +73 -0
- package/dist/runtime-development.js +30 -0
- package/dist/runtime-development.js.map +1 -0
- package/dist/runtime-receipt.d.ts +19 -0
- package/dist/runtime.d.ts +355 -0
- package/dist/runtime.js +42 -0
- package/dist/runtime.js.map +1 -0
- package/dist/snapshot.d.ts +26 -0
- package/dist/snapshot.js +61 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/spreadsheet-address.d.ts +18 -0
- package/dist/spreadsheet-file.d.ts +17 -0
- package/dist/spreadsheet-formula.d.ts +67 -0
- package/dist/spreadsheet-image.d.ts +17 -0
- package/dist/spreadsheet-render.d.ts +5 -0
- package/dist/spreadsheet-render.js +752 -0
- package/dist/spreadsheet-render.js.map +1 -0
- package/dist/spreadsheet-types.d.ts +155 -0
- package/dist/spreadsheet-xlsx-api.d.ts +88 -0
- package/dist/spreadsheet-xlsx-codec.d.ts +16 -0
- package/dist/spreadsheet-xlsx-codec.js +16 -0
- package/dist/spreadsheet-xlsx-codec.js.map +1 -0
- package/dist/spreadsheet-xlsx-state.d.ts +16 -0
- package/dist/spreadsheet.d.ts +724 -0
- package/dist/spreadsheet.js +67 -0
- package/dist/spreadsheet.js.map +1 -0
- package/package.json +129 -0
- package/src/bounded-zip.ts +450 -0
- package/src/document-docx-api.ts +64 -0
- package/src/document-docx-codec.ts +2253 -0
- package/src/document-docx-import.ts +3814 -0
- package/src/document-docx-packer.ts +72 -0
- package/src/document-docx-state.ts +59 -0
- package/src/document-render.ts +615 -0
- package/src/document.ts +2800 -0
- package/src/errors.ts +45 -0
- package/src/file-blob.ts +43 -0
- package/src/index.ts +4 -0
- package/src/inspection.ts +50 -0
- package/src/kernel.ts +525 -0
- package/src/materializer-cli-entry.ts +11 -0
- package/src/materializer-cli.ts +743 -0
- package/src/native-raster.ts +141 -0
- package/src/native.ts +390 -0
- package/src/office-import.ts +243 -0
- package/src/presentation-pptx-api.ts +107 -0
- package/src/presentation-pptx-import.ts +2065 -0
- package/src/presentation-pptx-state-digest.ts +46 -0
- package/src/presentation-pptx-state.ts +18 -0
- package/src/presentation-pptx-xml.ts +473 -0
- package/src/presentation-pptx.ts +447 -0
- package/src/presentation-render.ts +532 -0
- package/src/presentation.ts +2599 -0
- package/src/production-composite.ts +805 -0
- package/src/production-document.ts +2 -0
- package/src/production-native-codecs.ts +1141 -0
- package/src/production-presentation.ts +2 -0
- package/src/production-sha256.ts +150 -0
- package/src/production-spreadsheet-native.ts +686 -0
- package/src/production-spreadsheet.ts +2 -0
- package/src/production.ts +1022 -0
- package/src/raster-image.ts +586 -0
- package/src/reference.ts +17 -0
- package/src/runtime-cli-entry.ts +13 -0
- package/src/runtime-cli.ts +670 -0
- package/src/runtime-development.ts +689 -0
- package/src/runtime-receipt.ts +169 -0
- package/src/runtime.ts +1463 -0
- package/src/snapshot.ts +88 -0
- package/src/spreadsheet-address.ts +90 -0
- package/src/spreadsheet-file.ts +71 -0
- package/src/spreadsheet-formula.ts +1212 -0
- package/src/spreadsheet-image.ts +242 -0
- package/src/spreadsheet-render.ts +985 -0
- package/src/spreadsheet-types.ts +171 -0
- package/src/spreadsheet-xlsx-api.ts +123 -0
- package/src/spreadsheet-xlsx-codec.ts +3063 -0
- package/src/spreadsheet-xlsx-state.ts +191 -0
- package/src/spreadsheet.ts +5195 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import { FileBlob } from "./file-blob";
|
|
2
|
+
import { ArtifactLimitError, UnsupportedArtifactFeatureError } from "./errors";
|
|
3
|
+
import { formatCellAddress, parseRangeAddress, type RangeAddress } from "./spreadsheet-address";
|
|
4
|
+
import { spreadsheetImageSource } from "./spreadsheet-image";
|
|
5
|
+
import { rasterizeSvgToPng } from "./native-raster";
|
|
6
|
+
import type { Workbook, Worksheet } from "./spreadsheet";
|
|
7
|
+
import type { CellFormat, FormulaResult, RenderSpreadsheetOptions } from "./spreadsheet-types";
|
|
8
|
+
|
|
9
|
+
const MAX_RENDER_CELLS = 250_000;
|
|
10
|
+
const MAX_RENDER_DIMENSION = 16_384;
|
|
11
|
+
const MAX_RENDER_SPARKLINE_POINTS = 250_000;
|
|
12
|
+
const DEFAULT_BACKGROUND = "#ffffff";
|
|
13
|
+
const MAX_NUMBER_FORMAT_DECIMALS = 100;
|
|
14
|
+
const numberFormatters = new Map<string, Intl.NumberFormat>();
|
|
15
|
+
|
|
16
|
+
/** Render the canonical workbook model without requiring Excel or LibreOffice. */
|
|
17
|
+
export async function renderWorkbook(
|
|
18
|
+
workbook: Workbook,
|
|
19
|
+
options: RenderSpreadsheetOptions = {},
|
|
20
|
+
): Promise<FileBlob> {
|
|
21
|
+
const format = options.format ?? "png";
|
|
22
|
+
if (format !== "png" && format !== "svg") {
|
|
23
|
+
throw new Error("Spreadsheet render format must be png or svg");
|
|
24
|
+
}
|
|
25
|
+
if (format === "png" && (typeof process === "undefined" || !process.versions?.node)) {
|
|
26
|
+
throw new UnsupportedArtifactFeatureError("render", "Spreadsheet PNG rasterization", "browser");
|
|
27
|
+
}
|
|
28
|
+
const worksheet = options.sheetName
|
|
29
|
+
? workbook.worksheets.getItem(options.sheetName)
|
|
30
|
+
: workbook.worksheets.getActiveWorksheet();
|
|
31
|
+
const address = renderAddress(worksheet, options);
|
|
32
|
+
const scale = options.scale ?? 1;
|
|
33
|
+
if (!Number.isFinite(scale) || scale <= 0 || scale > 8) {
|
|
34
|
+
throw new Error("Spreadsheet render scale must be greater than 0 and at most 8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const svg = renderSvg(workbook, worksheet, address, {
|
|
38
|
+
background: options.background ?? DEFAULT_BACKGROUND,
|
|
39
|
+
scale,
|
|
40
|
+
});
|
|
41
|
+
if (format === "svg") {
|
|
42
|
+
return new FileBlob([svg], {
|
|
43
|
+
type: "image/svg+xml",
|
|
44
|
+
name: `${safeFileStem(worksheet.name)}.svg`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rendered = await rasterizeSvgToPng(svg, {
|
|
49
|
+
background: options.background ?? DEFAULT_BACKGROUND,
|
|
50
|
+
});
|
|
51
|
+
return FileBlob.fromBytes(rendered, {
|
|
52
|
+
type: "image/png",
|
|
53
|
+
name: `${safeFileStem(worksheet.name)}.png`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderAddress(worksheet: Worksheet, options: RenderSpreadsheetOptions): RangeAddress {
|
|
58
|
+
if (options.range) return parseRangeAddress(options.range);
|
|
59
|
+
let used = worksheet.usedRangeAddress();
|
|
60
|
+
for (const chart of worksheet.charts.items) {
|
|
61
|
+
if (chart.position)
|
|
62
|
+
used = unionRanges(used, drawingRange(chart.position.from, chart.position.to));
|
|
63
|
+
}
|
|
64
|
+
for (const sparkline of worksheet.sparklineGroups.items) {
|
|
65
|
+
used = unionRanges(used, sparkline.targetRange.address);
|
|
66
|
+
}
|
|
67
|
+
for (const image of worksheet.images.items) {
|
|
68
|
+
const { from, extent } = image.config.anchor;
|
|
69
|
+
const approximate = {
|
|
70
|
+
row: from.row,
|
|
71
|
+
col: from.col,
|
|
72
|
+
rowCount: Math.max(1, Math.ceil(extent.heightPx / worksheet.rowHeight(from.row))),
|
|
73
|
+
colCount: Math.max(1, Math.ceil(extent.widthPx / worksheet.columnWidth(from.col))),
|
|
74
|
+
};
|
|
75
|
+
used = unionRanges(used, approximate);
|
|
76
|
+
}
|
|
77
|
+
return used ?? { row: 0, col: 0, rowCount: 1, colCount: 1 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderSvg(
|
|
81
|
+
workbook: Workbook,
|
|
82
|
+
worksheet: Worksheet,
|
|
83
|
+
address: RangeAddress,
|
|
84
|
+
options: { background: string; scale: number },
|
|
85
|
+
): string {
|
|
86
|
+
const cellCount = address.rowCount * address.colCount;
|
|
87
|
+
if (cellCount > MAX_RENDER_CELLS) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Spreadsheet render range has ${cellCount.toLocaleString()} cells; maximum is ${MAX_RENDER_CELLS.toLocaleString()}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const columns = cumulativeSizes(address.colCount, (index) =>
|
|
94
|
+
worksheet.columnWidth(address.col + index),
|
|
95
|
+
);
|
|
96
|
+
const rows = cumulativeSizes(address.rowCount, (index) =>
|
|
97
|
+
worksheet.rowHeight(address.row + index),
|
|
98
|
+
);
|
|
99
|
+
const logicalWidth = columns.at(-1) ?? 0;
|
|
100
|
+
const logicalHeight = rows.at(-1) ?? 0;
|
|
101
|
+
const width = logicalWidth * options.scale;
|
|
102
|
+
const height = logicalHeight * options.scale;
|
|
103
|
+
if (width > MAX_RENDER_DIMENSION || height > MAX_RENDER_DIMENSION) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Spreadsheet render is ${Math.ceil(width)}x${Math.ceil(height)}px; each dimension must be at most ${MAX_RENDER_DIMENSION}px`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const definitions: string[] = [];
|
|
110
|
+
const elements: string[] = [];
|
|
111
|
+
elements.push(
|
|
112
|
+
`<rect x="0" y="0" width="${number(width)}" height="${number(height)}" fill="${escapeAttribute(normalizeColor(options.background, DEFAULT_BACKGROUND))}"/>`,
|
|
113
|
+
);
|
|
114
|
+
elements.push(`<g transform="scale(${number(options.scale)})">`);
|
|
115
|
+
|
|
116
|
+
if (worksheet.showGridLines) {
|
|
117
|
+
const grid = gridPath(columns, rows);
|
|
118
|
+
if (grid) {
|
|
119
|
+
elements.push(
|
|
120
|
+
`<path d="${grid}" fill="none" stroke="#d9dde3" stroke-width="1" vector-effect="non-scaling-stroke" shape-rendering="crispEdges"/>`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const merges = worksheet.mergeRegions().filter((merge) => rangesOverlap(merge, address));
|
|
126
|
+
const entries = [...worksheet.cellEntries()]
|
|
127
|
+
.filter(({ row, col }) => contains(address, row, col))
|
|
128
|
+
.sort((a, b) => a.row - b.row || a.col - b.col);
|
|
129
|
+
const mergeIndexByCell = indexMerges(merges, address);
|
|
130
|
+
const storedCellIndexes = new Set(
|
|
131
|
+
entries.map(({ row, col }) => cellIndexInRange(address, row, col)),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
for (const { row, col, data } of entries) {
|
|
135
|
+
const mergeIndex = mergeIndexByCell[cellIndexInRange(address, row, col)] ?? -1;
|
|
136
|
+
const merge = mergeIndex < 0 ? undefined : merges[mergeIndex];
|
|
137
|
+
if (merge && (merge.row !== row || merge.col !== col)) continue;
|
|
138
|
+
const bounds = cellBounds(
|
|
139
|
+
merge ? intersect(merge, address) : { row, col, rowCount: 1, colCount: 1 },
|
|
140
|
+
address,
|
|
141
|
+
columns,
|
|
142
|
+
rows,
|
|
143
|
+
);
|
|
144
|
+
if (!bounds) continue;
|
|
145
|
+
renderCell(
|
|
146
|
+
elements,
|
|
147
|
+
definitions,
|
|
148
|
+
bounds,
|
|
149
|
+
data.format,
|
|
150
|
+
workbook.valueAt(worksheet, { row, col }),
|
|
151
|
+
`cell-${row}-${col}`,
|
|
152
|
+
options.background,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A merge can exist without a stored top-left cell. Paint it over internal gridlines.
|
|
157
|
+
for (const merge of merges) {
|
|
158
|
+
const topLeftIndex = cellIndexInRange(address, merge.row, merge.col);
|
|
159
|
+
if (topLeftIndex >= 0 && storedCellIndexes.has(topLeftIndex)) continue;
|
|
160
|
+
const bounds = cellBounds(intersect(merge, address), address, columns, rows);
|
|
161
|
+
if (!bounds) continue;
|
|
162
|
+
elements.push(
|
|
163
|
+
`<rect x="${number(bounds.x)}" y="${number(bounds.y)}" width="${number(bounds.width)}" height="${number(bounds.height)}" fill="${escapeAttribute(normalizeColor(options.background, DEFAULT_BACKGROUND))}" stroke="#d9dde3"/>`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
renderSparklines(elements, worksheet, address, columns, rows);
|
|
168
|
+
renderDrawings(elements, worksheet, address, columns, rows);
|
|
169
|
+
|
|
170
|
+
elements.push("</g>");
|
|
171
|
+
return [
|
|
172
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${number(width)}" height="${number(height)}" viewBox="0 0 ${number(width)} ${number(height)}">`,
|
|
173
|
+
definitions.length > 0 ? `<defs>${definitions.join("")}</defs>` : "",
|
|
174
|
+
...elements,
|
|
175
|
+
"</svg>",
|
|
176
|
+
].join("");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Merges are non-overlapping canonical model objects. Index only their visible
|
|
181
|
+
* intersections, whose aggregate area cannot exceed the already-bounded render
|
|
182
|
+
* range. This keeps lookup linear in visible cells instead of cells × merges.
|
|
183
|
+
*/
|
|
184
|
+
function indexMerges(merges: readonly RangeAddress[], viewport: RangeAddress): Int32Array {
|
|
185
|
+
const indexes = new Int32Array(viewport.rowCount * viewport.colCount);
|
|
186
|
+
indexes.fill(-1);
|
|
187
|
+
for (let mergeIndex = 0; mergeIndex < merges.length; mergeIndex += 1) {
|
|
188
|
+
const visible = intersect(merges[mergeIndex]!, viewport);
|
|
189
|
+
const firstColumn = visible.col - viewport.col;
|
|
190
|
+
for (let row = visible.row; row < visible.row + visible.rowCount; row += 1) {
|
|
191
|
+
const start = (row - viewport.row) * viewport.colCount + firstColumn;
|
|
192
|
+
indexes.fill(mergeIndex, start, start + visible.colCount);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return indexes;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function cellIndexInRange(range: RangeAddress, row: number, col: number): number {
|
|
199
|
+
if (!contains(range, row, col)) return -1;
|
|
200
|
+
return (row - range.row) * range.colCount + col - range.col;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type Bounds = { x: number; y: number; width: number; height: number };
|
|
204
|
+
|
|
205
|
+
function renderCell(
|
|
206
|
+
elements: string[],
|
|
207
|
+
definitions: string[],
|
|
208
|
+
bounds: Bounds,
|
|
209
|
+
format: CellFormat,
|
|
210
|
+
value: FormulaResult,
|
|
211
|
+
id: string,
|
|
212
|
+
background: string,
|
|
213
|
+
): void {
|
|
214
|
+
const fill = normalizeColor(format.fill, background);
|
|
215
|
+
const border = borderMarkup(bounds, format);
|
|
216
|
+
if (format.fill || border) {
|
|
217
|
+
elements.push(
|
|
218
|
+
`<rect x="${number(bounds.x)}" y="${number(bounds.y)}" width="${number(bounds.width)}" height="${number(bounds.height)}" fill="${escapeAttribute(fill)}"/>`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (border) elements.push(border);
|
|
222
|
+
|
|
223
|
+
const text = displayValue(value, format.numberFormat);
|
|
224
|
+
if (!text) return;
|
|
225
|
+
const fontSize = clamp(format.font?.size ?? 12, 6, 72);
|
|
226
|
+
const padding = 6;
|
|
227
|
+
const contentWidth = Math.max(1, bounds.width - padding * 2);
|
|
228
|
+
const lines = format.wrapText
|
|
229
|
+
? wrapLines(text, contentWidth, fontSize)
|
|
230
|
+
: [text.replaceAll(/\r?\n/g, " ")];
|
|
231
|
+
const lineHeight = fontSize * 1.25;
|
|
232
|
+
const textHeight = lines.length * lineHeight;
|
|
233
|
+
const vertical = format.verticalAlignment ?? "center";
|
|
234
|
+
const startY =
|
|
235
|
+
vertical === "top"
|
|
236
|
+
? bounds.y + padding + fontSize
|
|
237
|
+
: vertical === "bottom"
|
|
238
|
+
? bounds.y + bounds.height - padding - textHeight + fontSize
|
|
239
|
+
: bounds.y + (bounds.height - textHeight) / 2 + fontSize;
|
|
240
|
+
const horizontal = format.horizontalAlignment ?? inferredAlignment(value);
|
|
241
|
+
const anchor = horizontal === "center" ? "middle" : horizontal === "right" ? "end" : "start";
|
|
242
|
+
const x =
|
|
243
|
+
horizontal === "center"
|
|
244
|
+
? bounds.x + bounds.width / 2
|
|
245
|
+
: horizontal === "right"
|
|
246
|
+
? bounds.x + bounds.width - padding
|
|
247
|
+
: bounds.x + padding;
|
|
248
|
+
const clipId = `clip-${id}`;
|
|
249
|
+
definitions.push(
|
|
250
|
+
`<clipPath id="${clipId}"><rect x="${number(bounds.x + 1)}" y="${number(bounds.y + 1)}" width="${number(Math.max(0, bounds.width - 2))}" height="${number(Math.max(0, bounds.height - 2))}"/></clipPath>`,
|
|
251
|
+
);
|
|
252
|
+
const decoration = format.font?.underline ? ' text-decoration="underline"' : "";
|
|
253
|
+
const tspans = lines
|
|
254
|
+
.map(
|
|
255
|
+
(line, index) =>
|
|
256
|
+
`<tspan x="${number(x)}" y="${number(startY + index * lineHeight)}">${escapeText(line)}</tspan>`,
|
|
257
|
+
)
|
|
258
|
+
.join("");
|
|
259
|
+
elements.push(
|
|
260
|
+
`<text clip-path="url(#${clipId})" text-anchor="${anchor}" font-family="${escapeAttribute(format.font?.name ?? "Arial, sans-serif")}" font-size="${number(fontSize)}" font-weight="${format.font?.bold ? "700" : "400"}" font-style="${format.font?.italic ? "italic" : "normal"}" fill="${escapeAttribute(normalizeColor(format.font?.color, "#111827"))}"${decoration}>${tspans}</text>`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function borderMarkup(bounds: Bounds, format: CellFormat): string {
|
|
265
|
+
const borders = format.borders;
|
|
266
|
+
if (!borders || borders.preset === "none") return "";
|
|
267
|
+
const fallback: import("./spreadsheet-types").BorderConfig | undefined =
|
|
268
|
+
borders.style || borders.preset
|
|
269
|
+
? {
|
|
270
|
+
...(borders.style ? { style: borders.style } : {}),
|
|
271
|
+
...(borders.color ? { color: borders.color } : {}),
|
|
272
|
+
}
|
|
273
|
+
: undefined;
|
|
274
|
+
const sides = {
|
|
275
|
+
top:
|
|
276
|
+
borders.top ??
|
|
277
|
+
(borders.preset === "all" || borders.preset === "outside" ? fallback : undefined),
|
|
278
|
+
right:
|
|
279
|
+
borders.right ??
|
|
280
|
+
(borders.preset === "all" || borders.preset === "outside" ? fallback : undefined),
|
|
281
|
+
bottom:
|
|
282
|
+
borders.bottom ??
|
|
283
|
+
(borders.preset === "all" || borders.preset === "outside" || borders.preset === "doubleBottom"
|
|
284
|
+
? fallback
|
|
285
|
+
: undefined),
|
|
286
|
+
left:
|
|
287
|
+
borders.left ??
|
|
288
|
+
(borders.preset === "all" || borders.preset === "outside" ? fallback : undefined),
|
|
289
|
+
};
|
|
290
|
+
const commands: string[] = [];
|
|
291
|
+
const lines: string[] = [];
|
|
292
|
+
const append = (
|
|
293
|
+
side: keyof typeof sides,
|
|
294
|
+
x1: number,
|
|
295
|
+
y1: number,
|
|
296
|
+
x2: number,
|
|
297
|
+
y2: number,
|
|
298
|
+
): void => {
|
|
299
|
+
const config = sides[side];
|
|
300
|
+
if (!config) return;
|
|
301
|
+
const weight = clamp(config.weight ?? (config.style === "thick" ? 3 : 1), 0.5, 8);
|
|
302
|
+
commands.push(`M${number(x1)} ${number(y1)}L${number(x2)} ${number(y2)}`);
|
|
303
|
+
lines.push(
|
|
304
|
+
`<path d="${commands.pop()}" fill="none" stroke="${escapeAttribute(normalizeColor(config.color, borders.color ?? "#4b5563"))}" stroke-width="${number(weight)}" vector-effect="non-scaling-stroke"/>`,
|
|
305
|
+
);
|
|
306
|
+
if (config.style === "double" || (side === "bottom" && borders.preset === "doubleBottom")) {
|
|
307
|
+
const offset = 3;
|
|
308
|
+
const vertical = x1 === x2;
|
|
309
|
+
lines.push(
|
|
310
|
+
`<path d="M${number(x1 + (vertical ? offset : 0))} ${number(y1 + (vertical ? 0 : -offset))}L${number(x2 + (vertical ? offset : 0))} ${number(y2 + (vertical ? 0 : -offset))}" fill="none" stroke="${escapeAttribute(normalizeColor(config.color, borders.color ?? "#4b5563"))}" stroke-width="1" vector-effect="non-scaling-stroke"/>`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
append("top", bounds.x, bounds.y, bounds.x + bounds.width, bounds.y);
|
|
315
|
+
append(
|
|
316
|
+
"right",
|
|
317
|
+
bounds.x + bounds.width,
|
|
318
|
+
bounds.y,
|
|
319
|
+
bounds.x + bounds.width,
|
|
320
|
+
bounds.y + bounds.height,
|
|
321
|
+
);
|
|
322
|
+
append(
|
|
323
|
+
"bottom",
|
|
324
|
+
bounds.x,
|
|
325
|
+
bounds.y + bounds.height,
|
|
326
|
+
bounds.x + bounds.width,
|
|
327
|
+
bounds.y + bounds.height,
|
|
328
|
+
);
|
|
329
|
+
append("left", bounds.x, bounds.y, bounds.x, bounds.y + bounds.height);
|
|
330
|
+
return lines.join("");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function gridPath(columns: readonly number[], rows: readonly number[]): string {
|
|
334
|
+
const width = columns.at(-1) ?? 0;
|
|
335
|
+
const height = rows.at(-1) ?? 0;
|
|
336
|
+
const commands: string[] = [];
|
|
337
|
+
for (const x of columns) commands.push(`M${number(x)} 0V${number(height)}`);
|
|
338
|
+
for (const y of rows) commands.push(`M0 ${number(y)}H${number(width)}`);
|
|
339
|
+
return commands.join("");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function cumulativeSizes(count: number, sizeAt: (index: number) => number): number[] {
|
|
343
|
+
const values = new Array<number>(count + 1);
|
|
344
|
+
values[0] = 0;
|
|
345
|
+
for (let index = 0; index < count; index += 1) {
|
|
346
|
+
values[index + 1] = values[index]! + clamp(sizeAt(index), 1, MAX_RENDER_DIMENSION);
|
|
347
|
+
}
|
|
348
|
+
return values;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function cellBounds(
|
|
352
|
+
cell: RangeAddress,
|
|
353
|
+
viewport: RangeAddress,
|
|
354
|
+
columns: readonly number[],
|
|
355
|
+
rows: readonly number[],
|
|
356
|
+
): Bounds | null {
|
|
357
|
+
const relativeCol = cell.col - viewport.col;
|
|
358
|
+
const relativeRow = cell.row - viewport.row;
|
|
359
|
+
const endCol = relativeCol + cell.colCount;
|
|
360
|
+
const endRow = relativeRow + cell.rowCount;
|
|
361
|
+
const x = columns[relativeCol];
|
|
362
|
+
const y = rows[relativeRow];
|
|
363
|
+
const endX = columns[endCol];
|
|
364
|
+
const endY = rows[endRow];
|
|
365
|
+
if (x === undefined || y === undefined || endX === undefined || endY === undefined) return null;
|
|
366
|
+
return { x, y, width: endX - x, height: endY - y };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function displayValue(value: FormulaResult, numberFormat?: string): string {
|
|
370
|
+
if (value === null) return "";
|
|
371
|
+
if (value instanceof Date) {
|
|
372
|
+
if (numberFormat?.includes("h") || numberFormat?.includes("s")) return value.toISOString();
|
|
373
|
+
return value.toISOString().slice(0, 10);
|
|
374
|
+
}
|
|
375
|
+
if (typeof value === "number") {
|
|
376
|
+
const decimals = decimalPlaces(numberFormat);
|
|
377
|
+
const useGrouping = numberFormat?.includes(",") ?? false;
|
|
378
|
+
const formatted = (numericValue: number): string =>
|
|
379
|
+
formatNumber(numericValue, decimals, useGrouping);
|
|
380
|
+
if (numberFormat?.includes("%")) return `${formatted(value * 100)}%`;
|
|
381
|
+
if (numberFormat?.includes("$")) return `$${formatted(value)}`;
|
|
382
|
+
if (numberFormat?.includes("€")) return `€${formatted(value)}`;
|
|
383
|
+
if (numberFormat?.includes("£")) return `£${formatted(value)}`;
|
|
384
|
+
return formatted(value);
|
|
385
|
+
}
|
|
386
|
+
return String(value);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function formatNumber(value: number, decimals?: number, useGrouping = false): string {
|
|
390
|
+
if (!Number.isFinite(value)) return String(value);
|
|
391
|
+
const normalizedDecimals =
|
|
392
|
+
decimals === undefined
|
|
393
|
+
? undefined
|
|
394
|
+
: Math.max(0, Math.min(MAX_NUMBER_FORMAT_DECIMALS, Math.floor(decimals)));
|
|
395
|
+
const key = `${normalizedDecimals ?? "auto"}:${useGrouping ? 1 : 0}`;
|
|
396
|
+
let formatter = numberFormatters.get(key);
|
|
397
|
+
if (!formatter) {
|
|
398
|
+
formatter = new Intl.NumberFormat("en-US", {
|
|
399
|
+
...(normalizedDecimals === undefined
|
|
400
|
+
? { maximumFractionDigits: 12 }
|
|
401
|
+
: {
|
|
402
|
+
minimumFractionDigits: normalizedDecimals,
|
|
403
|
+
maximumFractionDigits: normalizedDecimals,
|
|
404
|
+
}),
|
|
405
|
+
useGrouping,
|
|
406
|
+
});
|
|
407
|
+
numberFormatters.set(key, formatter);
|
|
408
|
+
}
|
|
409
|
+
return formatter.format(value);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function decimalPlaces(numberFormat: string | undefined): number | undefined {
|
|
413
|
+
if (!numberFormat) return undefined;
|
|
414
|
+
const decimal = /\.([0#]+)/.exec(numberFormat);
|
|
415
|
+
if (!decimal) return 0;
|
|
416
|
+
return decimal[1]?.length ?? 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function renderDrawings(
|
|
420
|
+
elements: string[],
|
|
421
|
+
worksheet: Worksheet,
|
|
422
|
+
viewport: RangeAddress,
|
|
423
|
+
columns: readonly number[],
|
|
424
|
+
rows: readonly number[],
|
|
425
|
+
): void {
|
|
426
|
+
for (const image of worksheet.images.items) {
|
|
427
|
+
const { from, extent } = image.config.anchor;
|
|
428
|
+
if (!contains(viewport, from.row, from.col)) continue;
|
|
429
|
+
const x = columns[from.col - viewport.col];
|
|
430
|
+
const y = rows[from.row - viewport.row];
|
|
431
|
+
if (x === undefined || y === undefined) continue;
|
|
432
|
+
const source = spreadsheetImageSource(image.config);
|
|
433
|
+
elements.push(
|
|
434
|
+
`<image x="${number(x)}" y="${number(y)}" width="${number(extent.widthPx)}" height="${number(extent.heightPx)}" href="${escapeAttribute(source)}" preserveAspectRatio="xMidYMid meet">${image.config.alt ? `<title>${escapeText(image.config.alt)}</title>` : ""}</image>`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const chart of worksheet.charts.items) {
|
|
439
|
+
if (!chart.position) continue;
|
|
440
|
+
const range = drawingRange(chart.position.from, chart.position.to);
|
|
441
|
+
if (!rangesOverlap(range, viewport)) continue;
|
|
442
|
+
const visible = intersect(range, viewport);
|
|
443
|
+
const bounds = cellBounds(visible, viewport, columns, rows);
|
|
444
|
+
if (!bounds || bounds.width < 40 || bounds.height < 40) continue;
|
|
445
|
+
renderChart(elements, bounds, chart);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderSparklines(
|
|
450
|
+
elements: string[],
|
|
451
|
+
worksheet: Worksheet,
|
|
452
|
+
viewport: RangeAddress,
|
|
453
|
+
columns: readonly number[],
|
|
454
|
+
rows: readonly number[],
|
|
455
|
+
): void {
|
|
456
|
+
let renderedPoints = 0;
|
|
457
|
+
for (const group of worksheet.sparklineGroups.items) {
|
|
458
|
+
const visible = intersect(group.targetRange.address, viewport);
|
|
459
|
+
if (visible.rowCount <= 0 || visible.colCount <= 0) continue;
|
|
460
|
+
const groupPoints =
|
|
461
|
+
visible.rowCount * visible.colCount * group.resourceUsage.pointsPerSparkline;
|
|
462
|
+
renderedPoints += groupPoints;
|
|
463
|
+
if (renderedPoints > MAX_RENDER_SPARKLINE_POINTS) {
|
|
464
|
+
throw new ArtifactLimitError(
|
|
465
|
+
"spreadsheet rendered sparkline points",
|
|
466
|
+
renderedPoints,
|
|
467
|
+
MAX_RENDER_SPARKLINE_POINTS,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const dateAxis = group.dateAxisValues();
|
|
471
|
+
const axis = group.axis;
|
|
472
|
+
const markers = group.markers;
|
|
473
|
+
const colors = {
|
|
474
|
+
series: normalizeColor(group.seriesColor, "#2563eb"),
|
|
475
|
+
negative: normalizeColor(group.negativeColor, "#dc2626"),
|
|
476
|
+
axis: normalizeColor(group.axisColor, "#64748b"),
|
|
477
|
+
markers: normalizeColor(group.markersColor, group.seriesColor ?? "#2563eb"),
|
|
478
|
+
first: normalizeColor(group.firstMarkerColor, group.seriesColor ?? "#2563eb"),
|
|
479
|
+
last: normalizeColor(group.lastMarkerColor, group.seriesColor ?? "#2563eb"),
|
|
480
|
+
high: normalizeColor(group.highMarkerColor, "#16a34a"),
|
|
481
|
+
low: normalizeColor(group.lowMarkerColor, "#dc2626"),
|
|
482
|
+
};
|
|
483
|
+
for (let row = visible.row; row < visible.row + visible.rowCount; row += 1) {
|
|
484
|
+
for (let col = visible.col; col < visible.col + visible.colCount; col += 1) {
|
|
485
|
+
const bounds = cellBounds({ row, col, rowCount: 1, colCount: 1 }, viewport, columns, rows);
|
|
486
|
+
if (!bounds || bounds.width < 8 || bounds.height < 8) continue;
|
|
487
|
+
const values = group.valuesForTargetCell(row, col);
|
|
488
|
+
if (!values.some((value) => value !== null)) continue;
|
|
489
|
+
const plot = {
|
|
490
|
+
x: bounds.x + 4,
|
|
491
|
+
y: bounds.y + 4,
|
|
492
|
+
width: Math.max(1, bounds.width - 8),
|
|
493
|
+
height: Math.max(1, bounds.height - 8),
|
|
494
|
+
};
|
|
495
|
+
elements.push(
|
|
496
|
+
`<g data-opengeni-sparkline="${escapeAttribute(group.id)}" data-target="${escapeAttribute(formatCellAddress({ row, col }))}">`,
|
|
497
|
+
);
|
|
498
|
+
renderSparkline(elements, plot, values, dateAxis, group.type, {
|
|
499
|
+
axis,
|
|
500
|
+
markers,
|
|
501
|
+
colors,
|
|
502
|
+
lineWeight: group.lineWeight,
|
|
503
|
+
connectEmpty: group.displayEmptyCellsAs === 2,
|
|
504
|
+
});
|
|
505
|
+
elements.push("</g>");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderSparkline(
|
|
512
|
+
elements: string[],
|
|
513
|
+
plot: Bounds,
|
|
514
|
+
inputValues: readonly (number | null)[],
|
|
515
|
+
dateAxis: readonly (number | null)[] | null,
|
|
516
|
+
type: "line" | "column" | "stacked",
|
|
517
|
+
options: {
|
|
518
|
+
axis: Readonly<{
|
|
519
|
+
showAxis?: boolean;
|
|
520
|
+
manualMin?: number;
|
|
521
|
+
manualMax?: number;
|
|
522
|
+
rightToLeft?: boolean;
|
|
523
|
+
}>;
|
|
524
|
+
markers: Readonly<{
|
|
525
|
+
show: boolean;
|
|
526
|
+
high: boolean;
|
|
527
|
+
low: boolean;
|
|
528
|
+
first: boolean;
|
|
529
|
+
last: boolean;
|
|
530
|
+
negative: boolean;
|
|
531
|
+
}>;
|
|
532
|
+
colors: {
|
|
533
|
+
series: string;
|
|
534
|
+
negative: string;
|
|
535
|
+
axis: string;
|
|
536
|
+
markers: string;
|
|
537
|
+
first: string;
|
|
538
|
+
last: string;
|
|
539
|
+
high: string;
|
|
540
|
+
low: string;
|
|
541
|
+
};
|
|
542
|
+
lineWeight: number;
|
|
543
|
+
connectEmpty: boolean;
|
|
544
|
+
},
|
|
545
|
+
): void {
|
|
546
|
+
const values = options.axis.rightToLeft ? [...inputValues].reverse() : [...inputValues];
|
|
547
|
+
const dates = options.axis.rightToLeft && dateAxis ? [...dateAxis].reverse() : dateAxis;
|
|
548
|
+
const numeric = numericSparklineExtent(values);
|
|
549
|
+
if (numeric.count === 0) return;
|
|
550
|
+
|
|
551
|
+
if (type === "stacked") {
|
|
552
|
+
renderStackedSparkline(elements, plot, values, options);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const includeZero = type === "column" || options.axis.showAxis;
|
|
557
|
+
let minimum = options.axis.manualMin ?? (includeZero ? Math.min(0, numeric.min) : numeric.min);
|
|
558
|
+
let maximum = options.axis.manualMax ?? (includeZero ? Math.max(0, numeric.max) : numeric.max);
|
|
559
|
+
if (minimum >= maximum) {
|
|
560
|
+
const padding = Math.max(1, Math.abs(minimum) * 0.05);
|
|
561
|
+
minimum -= padding;
|
|
562
|
+
maximum += padding;
|
|
563
|
+
}
|
|
564
|
+
const span = maximum - minimum;
|
|
565
|
+
const baseline = plot.y + ((maximum - clamp(0, minimum, maximum)) / span) * plot.height;
|
|
566
|
+
if (options.axis.showAxis && minimum <= 0 && maximum >= 0) {
|
|
567
|
+
elements.push(
|
|
568
|
+
`<path d="M${number(plot.x)} ${number(baseline)}H${number(plot.x + plot.width)}" fill="none" stroke="${escapeAttribute(options.colors.axis)}" stroke-width="1" vector-effect="non-scaling-stroke"/>`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (type === "column") {
|
|
573
|
+
const slot = plot.width / Math.max(1, values.length);
|
|
574
|
+
const width = Math.max(1, slot * 0.62);
|
|
575
|
+
values.forEach((value, index) => {
|
|
576
|
+
if (value === null) return;
|
|
577
|
+
const y = plot.y + ((maximum - clamp(value, minimum, maximum)) / span) * plot.height;
|
|
578
|
+
const top = Math.min(y, baseline);
|
|
579
|
+
const height = Math.max(0.75, Math.abs(baseline - y));
|
|
580
|
+
elements.push(
|
|
581
|
+
`<rect x="${number(plot.x + index * slot + (slot - width) / 2)}" y="${number(top)}" width="${number(width)}" height="${number(height)}" fill="${escapeAttribute(value < 0 ? options.colors.negative : options.colors.series)}"/>`,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const xPositions = sparklineXPositions(values.length, dates, plot);
|
|
588
|
+
const points = values.map((value, index) =>
|
|
589
|
+
value === null
|
|
590
|
+
? null
|
|
591
|
+
: {
|
|
592
|
+
x: xPositions[index]!,
|
|
593
|
+
y: plot.y + ((maximum - clamp(value, minimum, maximum)) / span) * plot.height,
|
|
594
|
+
value,
|
|
595
|
+
index,
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
const segments: Array<Array<NonNullable<(typeof points)[number]>>> = [];
|
|
599
|
+
let segment: Array<NonNullable<(typeof points)[number]>> = [];
|
|
600
|
+
for (const point of points) {
|
|
601
|
+
if (point) {
|
|
602
|
+
segment.push(point);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (!options.connectEmpty && segment.length > 0) {
|
|
606
|
+
segments.push(segment);
|
|
607
|
+
segment = [];
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (segment.length > 0) segments.push(segment);
|
|
611
|
+
for (const line of segments) {
|
|
612
|
+
if (line.length === 1) {
|
|
613
|
+
elements.push(
|
|
614
|
+
`<circle cx="${number(line[0]!.x)}" cy="${number(line[0]!.y)}" r="${number(Math.max(1, options.lineWeight))}" fill="${escapeAttribute(options.colors.series)}"/>`,
|
|
615
|
+
);
|
|
616
|
+
} else {
|
|
617
|
+
elements.push(
|
|
618
|
+
`<polyline points="${line.map((point) => `${number(point.x)},${number(point.y)}`).join(" ")}" fill="none" stroke="${escapeAttribute(options.colors.series)}" stroke-width="${number(options.lineWeight)}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const firstIndex = points.findIndex((point) => point !== null);
|
|
624
|
+
let lastIndex = -1;
|
|
625
|
+
for (let index = points.length - 1; index >= 0; index -= 1) {
|
|
626
|
+
if (points[index] !== null) {
|
|
627
|
+
lastIndex = index;
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const high = numeric.max;
|
|
632
|
+
const low = numeric.min;
|
|
633
|
+
for (const point of points) {
|
|
634
|
+
if (!point) continue;
|
|
635
|
+
const special =
|
|
636
|
+
options.markers.show ||
|
|
637
|
+
(options.markers.first && point.index === firstIndex) ||
|
|
638
|
+
(options.markers.last && point.index === lastIndex) ||
|
|
639
|
+
(options.markers.high && point.value === high) ||
|
|
640
|
+
(options.markers.low && point.value === low) ||
|
|
641
|
+
(options.markers.negative && point.value < 0);
|
|
642
|
+
if (!special) continue;
|
|
643
|
+
const color =
|
|
644
|
+
options.markers.negative && point.value < 0
|
|
645
|
+
? options.colors.negative
|
|
646
|
+
: options.markers.high && point.value === high
|
|
647
|
+
? options.colors.high
|
|
648
|
+
: options.markers.low && point.value === low
|
|
649
|
+
? options.colors.low
|
|
650
|
+
: options.markers.first && point.index === firstIndex
|
|
651
|
+
? options.colors.first
|
|
652
|
+
: options.markers.last && point.index === lastIndex
|
|
653
|
+
? options.colors.last
|
|
654
|
+
: options.colors.markers;
|
|
655
|
+
elements.push(
|
|
656
|
+
`<circle cx="${number(point.x)}" cy="${number(point.y)}" r="${number(Math.max(1.5, options.lineWeight + 0.5))}" fill="${escapeAttribute(color)}"/>`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function renderStackedSparkline(
|
|
662
|
+
elements: string[],
|
|
663
|
+
plot: Bounds,
|
|
664
|
+
values: readonly (number | null)[],
|
|
665
|
+
options: {
|
|
666
|
+
colors: { series: string; negative: string; axis: string };
|
|
667
|
+
axis: Readonly<{ showAxis?: boolean }>;
|
|
668
|
+
},
|
|
669
|
+
): void {
|
|
670
|
+
const baseline = plot.y + plot.height / 2;
|
|
671
|
+
if (options.axis.showAxis) {
|
|
672
|
+
elements.push(
|
|
673
|
+
`<path d="M${number(plot.x)} ${number(baseline)}H${number(plot.x + plot.width)}" fill="none" stroke="${escapeAttribute(options.colors.axis)}" stroke-width="1" vector-effect="non-scaling-stroke"/>`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const slot = plot.width / Math.max(1, values.length);
|
|
677
|
+
const width = Math.max(1, slot * 0.62);
|
|
678
|
+
const height = Math.max(1, plot.height * 0.42);
|
|
679
|
+
values.forEach((value, index) => {
|
|
680
|
+
if (value === null || value === 0) return;
|
|
681
|
+
elements.push(
|
|
682
|
+
`<rect x="${number(plot.x + index * slot + (slot - width) / 2)}" y="${number(value > 0 ? baseline - height : baseline)}" width="${number(width)}" height="${number(height)}" fill="${escapeAttribute(value < 0 ? options.colors.negative : options.colors.series)}"/>`,
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function sparklineXPositions(
|
|
688
|
+
count: number,
|
|
689
|
+
dateAxis: readonly (number | null)[] | null,
|
|
690
|
+
plot: Bounds,
|
|
691
|
+
): number[] {
|
|
692
|
+
if (count === 1) return [plot.x + plot.width / 2];
|
|
693
|
+
if (
|
|
694
|
+
dateAxis &&
|
|
695
|
+
dateAxis.length === count &&
|
|
696
|
+
dateAxis.every((value): value is number => value !== null)
|
|
697
|
+
) {
|
|
698
|
+
const extent = numericSparklineExtent(dateAxis);
|
|
699
|
+
const minimum = extent.min;
|
|
700
|
+
const maximum = extent.max;
|
|
701
|
+
if (minimum < maximum) {
|
|
702
|
+
return dateAxis.map(
|
|
703
|
+
(value) => plot.x + ((value - minimum) / (maximum - minimum)) * plot.width,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return Array.from(
|
|
708
|
+
{ length: count },
|
|
709
|
+
(_, index) => plot.x + (index / Math.max(1, count - 1)) * plot.width,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function numericSparklineExtent(values: readonly (number | null)[]): {
|
|
714
|
+
min: number;
|
|
715
|
+
max: number;
|
|
716
|
+
count: number;
|
|
717
|
+
} {
|
|
718
|
+
let min = Number.POSITIVE_INFINITY;
|
|
719
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
720
|
+
let count = 0;
|
|
721
|
+
for (const value of values) {
|
|
722
|
+
if (value === null) continue;
|
|
723
|
+
min = Math.min(min, value);
|
|
724
|
+
max = Math.max(max, value);
|
|
725
|
+
count += 1;
|
|
726
|
+
}
|
|
727
|
+
return { min, max, count };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function renderChart(
|
|
731
|
+
elements: string[],
|
|
732
|
+
bounds: Bounds,
|
|
733
|
+
chart: Worksheet["charts"]["items"][number],
|
|
734
|
+
): void {
|
|
735
|
+
const titleHeight = chart.title ? 30 : 12;
|
|
736
|
+
const plot = {
|
|
737
|
+
x: bounds.x + 40,
|
|
738
|
+
y: bounds.y + titleHeight + 8,
|
|
739
|
+
width: Math.max(1, bounds.width - 56),
|
|
740
|
+
height: Math.max(1, bounds.height - titleHeight - 34),
|
|
741
|
+
};
|
|
742
|
+
elements.push(
|
|
743
|
+
`<g data-opengeni-chart="${escapeAttribute(chart.id)}"><rect x="${number(bounds.x)}" y="${number(bounds.y)}" width="${number(bounds.width)}" height="${number(bounds.height)}" rx="6" fill="#ffffff" stroke="#cbd5e1"/>`,
|
|
744
|
+
);
|
|
745
|
+
if (chart.title) {
|
|
746
|
+
elements.push(
|
|
747
|
+
`<text x="${number(bounds.x + bounds.width / 2)}" y="${number(bounds.y + 20)}" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" font-weight="700" fill="#0f172a">${escapeText(chart.title)}</text>`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const series = chart.series.items.filter((item) => item.values && item.values.length > 0);
|
|
752
|
+
const values = series.flatMap((item) => item.values ?? []);
|
|
753
|
+
if (series.length === 0 || values.length === 0) {
|
|
754
|
+
elements.push(
|
|
755
|
+
`<text x="${number(bounds.x + bounds.width / 2)}" y="${number(bounds.y + bounds.height / 2)}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#64748b">${escapeText(chart.type)} chart</text></g>`,
|
|
756
|
+
);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (chart.type === "pie" || chart.type === "doughnut") {
|
|
761
|
+
renderPie(elements, plot, series[0]!.values ?? [], chart.type === "doughnut");
|
|
762
|
+
} else if (
|
|
763
|
+
chart.type === "bar" ||
|
|
764
|
+
chart.type === "histogram" ||
|
|
765
|
+
chart.type === "waterfall" ||
|
|
766
|
+
chart.type === "funnel"
|
|
767
|
+
) {
|
|
768
|
+
renderBars(elements, plot, series);
|
|
769
|
+
} else {
|
|
770
|
+
renderLines(elements, plot, series, chart.type === "area");
|
|
771
|
+
}
|
|
772
|
+
elements.push("</g>");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function renderBars(
|
|
776
|
+
elements: string[],
|
|
777
|
+
plot: Bounds,
|
|
778
|
+
series: Worksheet["charts"]["items"][number]["series"]["items"],
|
|
779
|
+
): void {
|
|
780
|
+
const values = series.flatMap((item) => item.values ?? []);
|
|
781
|
+
const maximum = Math.max(0, ...values);
|
|
782
|
+
const minimum = Math.min(0, ...values);
|
|
783
|
+
const span = maximum - minimum || 1;
|
|
784
|
+
const categories = Math.max(...series.map((item) => item.values?.length ?? 0));
|
|
785
|
+
const groupWidth = plot.width / Math.max(1, categories);
|
|
786
|
+
const barWidth = Math.max(1, (groupWidth * 0.72) / series.length);
|
|
787
|
+
const baseline = plot.y + (maximum / span) * plot.height;
|
|
788
|
+
elements.push(
|
|
789
|
+
`<path d="M${number(plot.x)} ${number(baseline)}H${number(plot.x + plot.width)}" stroke="#94a3b8" fill="none"/>`,
|
|
790
|
+
);
|
|
791
|
+
series.forEach((item, seriesIndex) => {
|
|
792
|
+
(item.values ?? []).forEach((value, categoryIndex) => {
|
|
793
|
+
const height = Math.abs(value / span) * plot.height;
|
|
794
|
+
const x = plot.x + categoryIndex * groupWidth + groupWidth * 0.14 + seriesIndex * barWidth;
|
|
795
|
+
const y = value >= 0 ? baseline - height : baseline;
|
|
796
|
+
elements.push(
|
|
797
|
+
`<rect x="${number(x)}" y="${number(y)}" width="${number(Math.max(1, barWidth - 1))}" height="${number(height)}" fill="${escapeAttribute(chartColor(item.fill, seriesIndex))}"/>`,
|
|
798
|
+
);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function renderLines(
|
|
804
|
+
elements: string[],
|
|
805
|
+
plot: Bounds,
|
|
806
|
+
series: Worksheet["charts"]["items"][number]["series"]["items"],
|
|
807
|
+
area: boolean,
|
|
808
|
+
): void {
|
|
809
|
+
const all = series.flatMap((item) => item.values ?? []);
|
|
810
|
+
const maximum = Math.max(...all);
|
|
811
|
+
const minimum = Math.min(...all);
|
|
812
|
+
const span = maximum - minimum || 1;
|
|
813
|
+
series.forEach((item, seriesIndex) => {
|
|
814
|
+
const values = item.values ?? [];
|
|
815
|
+
const points = values.map((value, index) => {
|
|
816
|
+
const x = plot.x + (index / Math.max(1, values.length - 1)) * plot.width;
|
|
817
|
+
const y = plot.y + ((maximum - value) / span) * plot.height;
|
|
818
|
+
return { x, y };
|
|
819
|
+
});
|
|
820
|
+
if (points.length === 0) return;
|
|
821
|
+
const color = chartColor(item.fill, seriesIndex);
|
|
822
|
+
const pointText = points.map((point) => `${number(point.x)},${number(point.y)}`).join(" ");
|
|
823
|
+
if (area) {
|
|
824
|
+
elements.push(
|
|
825
|
+
`<polygon points="${number(points[0]!.x)},${number(plot.y + plot.height)} ${pointText} ${number(points.at(-1)!.x)},${number(plot.y + plot.height)}" fill="${escapeAttribute(color)}" fill-opacity="0.22"/>`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
elements.push(
|
|
829
|
+
`<polyline points="${pointText}" fill="none" stroke="${escapeAttribute(color)}" stroke-width="2"/>`,
|
|
830
|
+
);
|
|
831
|
+
for (const point of points) {
|
|
832
|
+
elements.push(
|
|
833
|
+
`<circle cx="${number(point.x)}" cy="${number(point.y)}" r="2.5" fill="${escapeAttribute(color)}"/>`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function renderPie(
|
|
840
|
+
elements: string[],
|
|
841
|
+
plot: Bounds,
|
|
842
|
+
values: readonly number[],
|
|
843
|
+
doughnut: boolean,
|
|
844
|
+
): void {
|
|
845
|
+
const positive = values.map((value) => Math.max(0, value));
|
|
846
|
+
const total = positive.reduce((sum, value) => sum + value, 0);
|
|
847
|
+
if (total <= 0) return;
|
|
848
|
+
const radius = Math.max(1, Math.min(plot.width, plot.height) / 2);
|
|
849
|
+
const centerX = plot.x + plot.width / 2;
|
|
850
|
+
const centerY = plot.y + plot.height / 2;
|
|
851
|
+
let angle = -Math.PI / 2;
|
|
852
|
+
positive.forEach((value, index) => {
|
|
853
|
+
if (value <= 0) return;
|
|
854
|
+
const next = angle + (value / total) * Math.PI * 2;
|
|
855
|
+
const start = polar(centerX, centerY, radius, angle);
|
|
856
|
+
const end = polar(centerX, centerY, radius, next);
|
|
857
|
+
const large = next - angle > Math.PI ? 1 : 0;
|
|
858
|
+
elements.push(
|
|
859
|
+
`<path d="M${number(centerX)} ${number(centerY)}L${number(start.x)} ${number(start.y)}A${number(radius)} ${number(radius)} 0 ${large} 1 ${number(end.x)} ${number(end.y)}Z" fill="${chartColor(undefined, index)}"/>`,
|
|
860
|
+
);
|
|
861
|
+
angle = next;
|
|
862
|
+
});
|
|
863
|
+
if (doughnut) {
|
|
864
|
+
elements.push(
|
|
865
|
+
`<circle cx="${number(centerX)}" cy="${number(centerY)}" r="${number(radius * 0.52)}" fill="#ffffff"/>`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function polar(x: number, y: number, radius: number, angle: number): { x: number; y: number } {
|
|
871
|
+
return { x: x + Math.cos(angle) * radius, y: y + Math.sin(angle) * radius };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function chartColor(value: string | undefined, index: number): string {
|
|
875
|
+
if (value) return normalizeColor(value, "#2563eb");
|
|
876
|
+
return ["#2563eb", "#0d9488", "#f59e0b", "#dc2626", "#7c3aed", "#0891b2"][index % 6]!;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function drawingRange(from: RangeAddress, to: RangeAddress): RangeAddress {
|
|
880
|
+
return {
|
|
881
|
+
row: from.row,
|
|
882
|
+
col: from.col,
|
|
883
|
+
rowCount: Math.max(1, to.row + to.rowCount - from.row),
|
|
884
|
+
colCount: Math.max(1, to.col + to.colCount - from.col),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function unionRanges(a: RangeAddress | null, b: RangeAddress): RangeAddress {
|
|
889
|
+
if (!a) return { ...b };
|
|
890
|
+
const row = Math.min(a.row, b.row);
|
|
891
|
+
const col = Math.min(a.col, b.col);
|
|
892
|
+
const endRow = Math.max(a.row + a.rowCount, b.row + b.rowCount);
|
|
893
|
+
const endCol = Math.max(a.col + a.colCount, b.col + b.colCount);
|
|
894
|
+
return { row, col, rowCount: endRow - row, colCount: endCol - col };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function wrapLines(value: string, width: number, fontSize: number): string[] {
|
|
898
|
+
const maxCharacters = Math.max(1, Math.floor(width / Math.max(1, fontSize * 0.56)));
|
|
899
|
+
const lines: string[] = [];
|
|
900
|
+
for (const explicitLine of value.split(/\r?\n/)) {
|
|
901
|
+
const words = explicitLine.split(/\s+/).filter(Boolean);
|
|
902
|
+
if (words.length === 0) {
|
|
903
|
+
lines.push("");
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
let line = "";
|
|
907
|
+
for (const word of words) {
|
|
908
|
+
if (word.length > maxCharacters) {
|
|
909
|
+
if (line) lines.push(line);
|
|
910
|
+
for (let index = 0; index < word.length; index += maxCharacters) {
|
|
911
|
+
lines.push(word.slice(index, index + maxCharacters));
|
|
912
|
+
}
|
|
913
|
+
line = "";
|
|
914
|
+
} else if (!line) {
|
|
915
|
+
line = word;
|
|
916
|
+
} else if (line.length + word.length + 1 <= maxCharacters) {
|
|
917
|
+
line += ` ${word}`;
|
|
918
|
+
} else {
|
|
919
|
+
lines.push(line);
|
|
920
|
+
line = word;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (line) lines.push(line);
|
|
924
|
+
}
|
|
925
|
+
return lines.slice(0, 512);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function intersect(a: RangeAddress, b: RangeAddress): RangeAddress {
|
|
929
|
+
const row = Math.max(a.row, b.row);
|
|
930
|
+
const col = Math.max(a.col, b.col);
|
|
931
|
+
const endRow = Math.min(a.row + a.rowCount, b.row + b.rowCount);
|
|
932
|
+
const endCol = Math.min(a.col + a.colCount, b.col + b.colCount);
|
|
933
|
+
return { row, col, rowCount: endRow - row, colCount: endCol - col };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function rangesOverlap(a: RangeAddress, b: RangeAddress): boolean {
|
|
937
|
+
return (
|
|
938
|
+
a.row < b.row + b.rowCount &&
|
|
939
|
+
b.row < a.row + a.rowCount &&
|
|
940
|
+
a.col < b.col + b.colCount &&
|
|
941
|
+
b.col < a.col + a.colCount
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function contains(address: RangeAddress, row: number, col: number): boolean {
|
|
946
|
+
return (
|
|
947
|
+
row >= address.row &&
|
|
948
|
+
col >= address.col &&
|
|
949
|
+
row < address.row + address.rowCount &&
|
|
950
|
+
col < address.col + address.colCount
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function inferredAlignment(value: FormulaResult): "left" | "right" {
|
|
955
|
+
return typeof value === "number" || value instanceof Date ? "right" : "left";
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function normalizeColor(color: string | undefined, fallback: string): string {
|
|
959
|
+
if (!color) return fallback;
|
|
960
|
+
if (/^#[0-9a-f]{6}$/i.test(color) || /^#[0-9a-f]{3}$/i.test(color)) return color;
|
|
961
|
+
if (/^[0-9a-f]{6}$/i.test(color)) return `#${color}`;
|
|
962
|
+
if (/^[0-9a-f]{8}$/i.test(color)) return `#${color.slice(2)}`;
|
|
963
|
+
if (/^(?:rgb|hsl)a?\(/i.test(color) || /^[a-z]+$/i.test(color)) return color;
|
|
964
|
+
return fallback;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function safeFileStem(name: string): string {
|
|
968
|
+
return name.replaceAll(/[^A-Za-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "") || "sheet";
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function escapeText(value: string): string {
|
|
972
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function escapeAttribute(value: string): string {
|
|
976
|
+
return escapeText(value).replaceAll('"', """);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function number(value: number): string {
|
|
980
|
+
return Number(value.toFixed(3)).toString();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function clamp(value: number, min: number, max: number): number {
|
|
984
|
+
return Math.min(max, Math.max(min, value));
|
|
985
|
+
}
|