@opendata-ai/openchart-vanilla 2.12.1 → 2.13.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.
- package/dist/index.d.ts +38 -15
- package/dist/index.js +140 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/export.test.ts +70 -5
- package/src/export.ts +291 -21
- package/src/index.ts +2 -2
- package/src/mount.ts +19 -4
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,26 @@ export { ChartLayout, ChartSpec, CompileOptions, TableLayout, TableSpec, VizSpec
|
|
|
2
2
|
import { GraphSpec, ThemeConfig, DarkMode, ChartSpec, ChartLayout, ChartEventHandlers, BarTableCell, CategoryTableCell, TableCell, FlagTableCell, HeatmapTableCell, ImageTableCell, SparklineTableCell, TextTableCell, Mark, TableSpec, SortState, TableLayout, TooltipContent } from '@opendata-ai/openchart-core';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Export utilities: serialize charts to SVG, PNG, or CSV.
|
|
5
|
+
* Export utilities: serialize charts to SVG, PNG, JPG, or CSV.
|
|
6
6
|
*
|
|
7
7
|
* - SVG: serializes the rendered DOM element via XMLSerializer
|
|
8
|
+
* - SVG with fonts: async version that embeds @font-face data URIs
|
|
8
9
|
* - PNG: renders SVG to canvas, then extracts as Blob
|
|
10
|
+
* - JPG: same as PNG but with JPEG compression and background fill
|
|
9
11
|
* - CSV: converts a data array to comma-separated text
|
|
10
12
|
*/
|
|
13
|
+
interface SVGExportOptions {
|
|
14
|
+
/** Embed fonts as base64 data URIs in the SVG. Defaults to true. */
|
|
15
|
+
embedFonts?: boolean;
|
|
16
|
+
}
|
|
17
|
+
interface PNGExportOptions extends SVGExportOptions {
|
|
18
|
+
/** DPI scaling factor. Defaults to 2 for retina-quality output. */
|
|
19
|
+
dpi?: number;
|
|
20
|
+
}
|
|
21
|
+
interface JPGExportOptions extends PNGExportOptions {
|
|
22
|
+
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
23
|
+
quality?: number;
|
|
24
|
+
}
|
|
11
25
|
/**
|
|
12
26
|
* Serialize an SVG element to an XML string.
|
|
13
27
|
*
|
|
@@ -15,31 +29,39 @@ import { GraphSpec, ThemeConfig, DarkMode, ChartSpec, ChartLayout, ChartEventHan
|
|
|
15
29
|
* @returns The SVG markup as a string.
|
|
16
30
|
*/
|
|
17
31
|
declare function exportSVG(svgElement: SVGElement): string;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Serialize an SVG element with embedded fonts to an XML string.
|
|
34
|
+
*
|
|
35
|
+
* Collects font-family declarations from the SVG's text elements,
|
|
36
|
+
* finds matching @font-face rules in the page's stylesheets, fetches
|
|
37
|
+
* the font files, and embeds them as base64 data URIs. The resulting
|
|
38
|
+
* SVG is self-contained and renders correctly without external fonts.
|
|
39
|
+
*
|
|
40
|
+
* @param svgElement - The rendered SVG element to serialize.
|
|
41
|
+
* @param options - Export options.
|
|
42
|
+
* @returns A Promise resolving to the SVG markup as a string.
|
|
43
|
+
*/
|
|
44
|
+
declare function exportSVGWithFonts(svgElement: SVGElement, options?: SVGExportOptions): Promise<string>;
|
|
22
45
|
/**
|
|
23
46
|
* Render an SVG element to a PNG Blob via a canvas.
|
|
24
47
|
*
|
|
48
|
+
* Embeds fonts by default so the exported image matches on-screen rendering.
|
|
49
|
+
* Set `embedFonts: false` to skip font embedding for faster exports.
|
|
50
|
+
*
|
|
25
51
|
* @param svgElement - The rendered SVG element.
|
|
26
|
-
* @param options - Optional DPI scaling.
|
|
52
|
+
* @param options - Optional DPI scaling and font embedding.
|
|
27
53
|
* @returns A Promise resolving to the PNG Blob.
|
|
28
54
|
*/
|
|
29
55
|
declare function exportPNG(svgElement: SVGElement, options?: PNGExportOptions): Promise<Blob>;
|
|
30
|
-
interface JPGExportOptions extends PNGExportOptions {
|
|
31
|
-
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
32
|
-
quality?: number;
|
|
33
|
-
}
|
|
34
56
|
/**
|
|
35
57
|
* Render an SVG element to a JPEG Blob via a canvas.
|
|
36
58
|
*
|
|
37
59
|
* Same pipeline as exportPNG but outputs JPEG with configurable quality.
|
|
38
|
-
* The canvas is filled with
|
|
39
|
-
* backgrounds rendering as black in JPEG format.
|
|
60
|
+
* The canvas is filled with the chart's background color before drawing
|
|
61
|
+
* to avoid transparent backgrounds rendering as black in JPEG format.
|
|
40
62
|
*
|
|
41
63
|
* @param svgElement - The rendered SVG element.
|
|
42
|
-
* @param options - Optional DPI scaling and
|
|
64
|
+
* @param options - Optional DPI scaling, JPEG quality, and font embedding.
|
|
43
65
|
* @returns A Promise resolving to the JPEG Blob.
|
|
44
66
|
*/
|
|
45
67
|
declare function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob>;
|
|
@@ -149,10 +171,11 @@ interface ChartInstance {
|
|
|
149
171
|
resize(): void;
|
|
150
172
|
/** Export the chart. */
|
|
151
173
|
export(format: 'svg'): string;
|
|
174
|
+
export(format: 'svg-with-fonts', options?: SVGExportOptions): Promise<string>;
|
|
152
175
|
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
153
176
|
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
154
177
|
export(format: 'csv'): string;
|
|
155
|
-
export(format: 'svg' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
178
|
+
export(format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob> | Promise<string>;
|
|
156
179
|
/** Remove all DOM elements and disconnect observers. */
|
|
157
180
|
destroy(): void;
|
|
158
181
|
/** The current compiled layout (for hooks / debugging). */
|
|
@@ -350,4 +373,4 @@ interface TooltipManager {
|
|
|
350
373
|
*/
|
|
351
374
|
declare function createTooltipManager(container: HTMLElement): TooltipManager;
|
|
352
375
|
|
|
353
|
-
export { type ChartInstance, type ExportOptions, type GraphInstance, type GraphMountOptions, type JPGExportOptions, type KeyboardNavOptions, type MountOptions, type PNGExportOptions, type TableInstance, type TableMountOptions, type TableState, type TooltipManager, attachKeyboardNav, createChart, createGraph, createSimulationWorker, createTable, createTooltipManager, exportCSV, exportJPG, exportPNG, exportSVG, observeResize, registerMarkRenderer, renderBarCell, renderCategoryCell, renderCell, renderChartSVG, renderFlagCell, renderHeatmapCell, renderImageCell, renderSparklineCell, renderTable, renderTextCell };
|
|
376
|
+
export { type ChartInstance, type ExportOptions, type GraphInstance, type GraphMountOptions, type JPGExportOptions, type KeyboardNavOptions, type MountOptions, type PNGExportOptions, type SVGExportOptions, type TableInstance, type TableMountOptions, type TableState, type TooltipManager, attachKeyboardNav, createChart, createGraph, createSimulationWorker, createTable, createTooltipManager, exportCSV, exportJPG, exportPNG, exportSVG, exportSVGWithFonts, observeResize, registerMarkRenderer, renderBarCell, renderCategoryCell, renderCell, renderChartSVG, renderFlagCell, renderHeatmapCell, renderImageCell, renderSparklineCell, renderTable, renderTextCell };
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,142 @@
|
|
|
1
1
|
// src/export.ts
|
|
2
|
+
function getSVGDimensions(svg) {
|
|
3
|
+
const w = parseFloat(svg.getAttribute("width") || "");
|
|
4
|
+
const h = parseFloat(svg.getAttribute("height") || "");
|
|
5
|
+
if (w && h) return { width: w, height: h };
|
|
6
|
+
const vb = svg.getAttribute("viewBox");
|
|
7
|
+
if (vb) {
|
|
8
|
+
const parts = vb.split(/[\s,]+/).map(Number);
|
|
9
|
+
if (parts.length >= 4 && parts[2] && parts[3]) {
|
|
10
|
+
return { width: parts[2], height: parts[3] };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { width: 600, height: 400 };
|
|
14
|
+
}
|
|
15
|
+
function collectUsedFonts(svgElement) {
|
|
16
|
+
const fonts = /* @__PURE__ */ new Map();
|
|
17
|
+
const textElements = svgElement.querySelectorAll("text");
|
|
18
|
+
for (const el of textElements) {
|
|
19
|
+
const family = el.getAttribute("font-family");
|
|
20
|
+
const weight = el.getAttribute("font-weight") || "400";
|
|
21
|
+
if (family) {
|
|
22
|
+
const primary = family.split(",")[0].trim().replace(/["']/g, "");
|
|
23
|
+
if (!fonts.has(primary)) fonts.set(primary, /* @__PURE__ */ new Set());
|
|
24
|
+
fonts.get(primary).add(String(weight));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return fonts;
|
|
28
|
+
}
|
|
29
|
+
function findFontFaceRules(usedFonts) {
|
|
30
|
+
const results = [];
|
|
31
|
+
try {
|
|
32
|
+
for (const sheet of document.styleSheets) {
|
|
33
|
+
let rules;
|
|
34
|
+
try {
|
|
35
|
+
rules = sheet.cssRules;
|
|
36
|
+
} catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (const rule of rules) {
|
|
40
|
+
if (!(rule instanceof CSSFontFaceRule)) continue;
|
|
41
|
+
const familyRaw = rule.style.getPropertyValue("font-family").replace(/["']/g, "").trim();
|
|
42
|
+
const weight = rule.style.getPropertyValue("font-weight") || "400";
|
|
43
|
+
const style = rule.style.getPropertyValue("font-style") || "normal";
|
|
44
|
+
const src = rule.style.getPropertyValue("src");
|
|
45
|
+
const weights = usedFonts.get(familyRaw);
|
|
46
|
+
if (!weights) continue;
|
|
47
|
+
const weightMatch = weights.has(weight) || weight.includes(" ");
|
|
48
|
+
if (!weightMatch) continue;
|
|
49
|
+
const woff2Match = src.match(/url\(["']?([^"')]+\.woff2[^"')]*?)["']?\)/);
|
|
50
|
+
if (woff2Match) {
|
|
51
|
+
results.push({
|
|
52
|
+
family: familyRaw,
|
|
53
|
+
weight,
|
|
54
|
+
style,
|
|
55
|
+
url: woff2Match[1],
|
|
56
|
+
format: "woff2"
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
async function fetchFontsAsBase64(fontRules) {
|
|
66
|
+
const results = [];
|
|
67
|
+
const fetches = fontRules.map(async (rule) => {
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(rule.url);
|
|
70
|
+
if (!response.ok) return;
|
|
71
|
+
const buffer = await response.arrayBuffer();
|
|
72
|
+
const base64 = arrayBufferToBase64(buffer);
|
|
73
|
+
results.push({
|
|
74
|
+
family: rule.family,
|
|
75
|
+
weight: rule.weight,
|
|
76
|
+
style: rule.style,
|
|
77
|
+
base64,
|
|
78
|
+
format: rule.format
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
await Promise.all(fetches);
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
function arrayBufferToBase64(buffer) {
|
|
87
|
+
let binary = "";
|
|
88
|
+
const bytes = new Uint8Array(buffer);
|
|
89
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
90
|
+
binary += String.fromCharCode(bytes[i]);
|
|
91
|
+
}
|
|
92
|
+
return btoa(binary);
|
|
93
|
+
}
|
|
94
|
+
function injectFontsIntoSVG(svgElement, fonts) {
|
|
95
|
+
if (fonts.length === 0) return;
|
|
96
|
+
const cssRules = fonts.map(
|
|
97
|
+
(f) => `@font-face { font-family: '${f.family}'; font-weight: ${f.weight}; font-style: ${f.style}; src: url(data:font/${f.format};base64,${f.base64}) format('${f.format}'); }`
|
|
98
|
+
).join("\n");
|
|
99
|
+
const ns = "http://www.w3.org/2000/svg";
|
|
100
|
+
let defs = svgElement.querySelector("defs");
|
|
101
|
+
if (!defs) {
|
|
102
|
+
defs = document.createElementNS(ns, "defs");
|
|
103
|
+
svgElement.insertBefore(defs, svgElement.firstChild);
|
|
104
|
+
}
|
|
105
|
+
const styleEl = document.createElementNS(ns, "style");
|
|
106
|
+
styleEl.textContent = cssRules;
|
|
107
|
+
defs.insertBefore(styleEl, defs.firstChild);
|
|
108
|
+
}
|
|
109
|
+
async function embedFonts(svgElement) {
|
|
110
|
+
const usedFonts = collectUsedFonts(svgElement);
|
|
111
|
+
if (usedFonts.size === 0) return;
|
|
112
|
+
const fontRules = findFontFaceRules(usedFonts);
|
|
113
|
+
if (fontRules.length === 0) return;
|
|
114
|
+
const fontData = await fetchFontsAsBase64(fontRules);
|
|
115
|
+
injectFontsIntoSVG(svgElement, fontData);
|
|
116
|
+
}
|
|
117
|
+
function getSVGBackgroundColor(svgElement) {
|
|
118
|
+
const firstRect = svgElement.querySelector("rect");
|
|
119
|
+
return firstRect?.getAttribute("fill") || "#ffffff";
|
|
120
|
+
}
|
|
2
121
|
function exportSVG(svgElement) {
|
|
3
122
|
const serializer = new XMLSerializer();
|
|
4
123
|
return serializer.serializeToString(svgElement);
|
|
5
124
|
}
|
|
125
|
+
async function exportSVGWithFonts(svgElement, options) {
|
|
126
|
+
const shouldEmbed = options?.embedFonts ?? true;
|
|
127
|
+
if (shouldEmbed) {
|
|
128
|
+
await embedFonts(svgElement);
|
|
129
|
+
}
|
|
130
|
+
return exportSVG(svgElement);
|
|
131
|
+
}
|
|
6
132
|
async function exportPNG(svgElement, options) {
|
|
7
133
|
const dpi = options?.dpi ?? 2;
|
|
134
|
+
const shouldEmbed = options?.embedFonts ?? true;
|
|
135
|
+
if (shouldEmbed) {
|
|
136
|
+
await embedFonts(svgElement);
|
|
137
|
+
}
|
|
8
138
|
const svgString = exportSVG(svgElement);
|
|
9
|
-
const width =
|
|
10
|
-
const height = parseFloat(svgElement.getAttribute("height") || "400");
|
|
139
|
+
const { width, height } = getSVGDimensions(svgElement);
|
|
11
140
|
const canvas = document.createElement("canvas");
|
|
12
141
|
canvas.width = width * dpi;
|
|
13
142
|
canvas.height = height * dpi;
|
|
@@ -41,9 +170,12 @@ async function exportPNG(svgElement, options) {
|
|
|
41
170
|
async function exportJPG(svgElement, options) {
|
|
42
171
|
const dpi = options?.dpi ?? 2;
|
|
43
172
|
const quality = options?.quality ?? 0.92;
|
|
173
|
+
const shouldEmbed = options?.embedFonts ?? true;
|
|
174
|
+
if (shouldEmbed) {
|
|
175
|
+
await embedFonts(svgElement);
|
|
176
|
+
}
|
|
44
177
|
const svgString = exportSVG(svgElement);
|
|
45
|
-
const width =
|
|
46
|
-
const height = parseFloat(svgElement.getAttribute("height") || "400");
|
|
178
|
+
const { width, height } = getSVGDimensions(svgElement);
|
|
47
179
|
const canvas = document.createElement("canvas");
|
|
48
180
|
canvas.width = width * dpi;
|
|
49
181
|
canvas.height = height * dpi;
|
|
@@ -51,7 +183,7 @@ async function exportJPG(svgElement, options) {
|
|
|
51
183
|
if (!ctx) {
|
|
52
184
|
throw new Error("Canvas 2D context not available");
|
|
53
185
|
}
|
|
54
|
-
ctx.fillStyle =
|
|
186
|
+
ctx.fillStyle = getSVGBackgroundColor(svgElement);
|
|
55
187
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
56
188
|
ctx.scale(dpi, dpi);
|
|
57
189
|
const img = new Image();
|
|
@@ -4926,6 +5058,8 @@ function createChart(container, spec, options) {
|
|
|
4926
5058
|
switch (format) {
|
|
4927
5059
|
case "svg":
|
|
4928
5060
|
return exportSVG(svgElement);
|
|
5061
|
+
case "svg-with-fonts":
|
|
5062
|
+
return exportSVGWithFonts(svgElement, exportOptions);
|
|
4929
5063
|
case "png":
|
|
4930
5064
|
return exportPNG(svgElement, exportOptions);
|
|
4931
5065
|
case "jpg":
|
|
@@ -6130,6 +6264,7 @@ export {
|
|
|
6130
6264
|
exportJPG,
|
|
6131
6265
|
exportPNG,
|
|
6132
6266
|
exportSVG,
|
|
6267
|
+
exportSVGWithFonts,
|
|
6133
6268
|
observeResize,
|
|
6134
6269
|
registerMarkRenderer,
|
|
6135
6270
|
renderBarCell,
|