@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@floating-ui/dom": "^1.7.6",
|
|
53
|
-
"@opendata-ai/openchart-core": "2.
|
|
54
|
-
"@opendata-ai/openchart-engine": "2.
|
|
53
|
+
"@opendata-ai/openchart-core": "2.13.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "2.13.0",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Export utility tests.
|
|
3
3
|
*
|
|
4
|
-
* Tests exportSVG, exportCSV, and exportJPG
|
|
5
|
-
*
|
|
4
|
+
* Tests exportSVG, exportSVGWithFonts, exportCSV, exportPNG, and exportJPG
|
|
5
|
+
* functions directly, verifying SVG string validity, font embedding,
|
|
6
|
+
* dimension parsing, CSV formatting, and raster export interfaces.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { CompileOptions } from '@opendata-ai/openchart-engine';
|
|
@@ -10,7 +11,7 @@ import { compileChart } from '@opendata-ai/openchart-engine';
|
|
|
10
11
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
11
12
|
import { createContainer } from '../__test-fixtures__/dom';
|
|
12
13
|
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
13
|
-
import { exportCSV, exportJPG, exportSVG } from '../export';
|
|
14
|
+
import { exportCSV, exportJPG, exportPNG, exportSVG, exportSVGWithFonts } from '../export';
|
|
14
15
|
import { renderChartSVG } from '../svg-renderer';
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
@@ -76,6 +77,57 @@ describe('exportSVG', () => {
|
|
|
76
77
|
});
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// exportSVGWithFonts
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe('exportSVGWithFonts', () => {
|
|
85
|
+
it('returns a promise', () => {
|
|
86
|
+
const svg = renderToSVG();
|
|
87
|
+
const result = exportSVGWithFonts(svg);
|
|
88
|
+
expect(result).toBeInstanceOf(Promise);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('resolves to a valid SVG string', async () => {
|
|
92
|
+
const svg = renderToSVG();
|
|
93
|
+
const result = await exportSVGWithFonts(svg);
|
|
94
|
+
expect(result.startsWith('<svg')).toBe(true);
|
|
95
|
+
expect(result.endsWith('</svg>')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('skips font embedding when embedFonts is false', async () => {
|
|
99
|
+
const svg = renderToSVG();
|
|
100
|
+
const result = await exportSVGWithFonts(svg, { embedFonts: false });
|
|
101
|
+
// Should not contain @font-face (no stylesheets in test env anyway)
|
|
102
|
+
expect(result).not.toContain('@font-face');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('produces valid SVG even without stylesheets in the document', async () => {
|
|
106
|
+
const svg = renderToSVG();
|
|
107
|
+
// In test env, no Google Fonts stylesheets exist, so font collection
|
|
108
|
+
// should gracefully return nothing and the export should still work
|
|
109
|
+
const result = await exportSVGWithFonts(svg);
|
|
110
|
+
expect(result).toContain('viewBox="0 0 600 400"');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Dimension parsing (via exportPNG which uses getSVGDimensions internally)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe('dimension parsing', () => {
|
|
119
|
+
it('exportPNG reads dimensions from viewBox when width/height are absent', () => {
|
|
120
|
+
const svg = renderToSVG();
|
|
121
|
+
// Verify the SVG has viewBox but no explicit width/height
|
|
122
|
+
expect(svg.getAttribute('viewBox')).toBe('0 0 600 400');
|
|
123
|
+
expect(svg.getAttribute('width')).toBeNull();
|
|
124
|
+
// exportPNG should still work (not fall back to 600x400 by accident)
|
|
125
|
+
const result = exportPNG(svg, { dpi: 1, embedFonts: false });
|
|
126
|
+
expect(result).toBeInstanceOf(Promise);
|
|
127
|
+
result.catch(() => {}); // happy-dom canvas limitations
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
79
131
|
// ---------------------------------------------------------------------------
|
|
80
132
|
// exportCSV
|
|
81
133
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +201,19 @@ describe('exportCSV', () => {
|
|
|
149
201
|
});
|
|
150
202
|
});
|
|
151
203
|
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// exportPNG
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe('exportPNG', () => {
|
|
209
|
+
it('returns a Promise when called with a rendered SVG element', () => {
|
|
210
|
+
const svg = renderToSVG();
|
|
211
|
+
const result = exportPNG(svg, { embedFonts: false });
|
|
212
|
+
expect(result).toBeInstanceOf(Promise);
|
|
213
|
+
result.catch(() => {});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
152
217
|
// ---------------------------------------------------------------------------
|
|
153
218
|
// exportJPG
|
|
154
219
|
// ---------------------------------------------------------------------------
|
|
@@ -161,7 +226,7 @@ describe('exportJPG', () => {
|
|
|
161
226
|
|
|
162
227
|
it('returns a Promise when called with a rendered SVG element', () => {
|
|
163
228
|
const svg = renderToSVG();
|
|
164
|
-
const result = exportJPG(svg);
|
|
229
|
+
const result = exportJPG(svg, { embedFonts: false });
|
|
165
230
|
expect(result).toBeInstanceOf(Promise);
|
|
166
231
|
// Clean up: catch any rejection from happy-dom canvas limitations
|
|
167
232
|
result.catch(() => {});
|
|
@@ -169,7 +234,7 @@ describe('exportJPG', () => {
|
|
|
169
234
|
|
|
170
235
|
it('accepts quality option between 0 and 1', () => {
|
|
171
236
|
const svg = renderToSVG();
|
|
172
|
-
const result = exportJPG(svg, { quality: 0.5, dpi: 1 });
|
|
237
|
+
const result = exportJPG(svg, { quality: 0.5, dpi: 1, embedFonts: false });
|
|
173
238
|
expect(result).toBeInstanceOf(Promise);
|
|
174
239
|
result.catch(() => {});
|
|
175
240
|
});
|
package/src/export.ts
CHANGED
|
@@ -1,11 +1,249 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Export utilities: serialize charts to SVG, PNG, or CSV.
|
|
2
|
+
* Export utilities: serialize charts to SVG, PNG, JPG, or CSV.
|
|
3
3
|
*
|
|
4
4
|
* - SVG: serializes the rendered DOM element via XMLSerializer
|
|
5
|
+
* - SVG with fonts: async version that embeds @font-face data URIs
|
|
5
6
|
* - PNG: renders SVG to canvas, then extracts as Blob
|
|
7
|
+
* - JPG: same as PNG but with JPEG compression and background fill
|
|
6
8
|
* - CSV: converts a data array to comma-separated text
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface SVGExportOptions {
|
|
16
|
+
/** Embed fonts as base64 data URIs in the SVG. Defaults to true. */
|
|
17
|
+
embedFonts?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PNGExportOptions extends SVGExportOptions {
|
|
21
|
+
/** DPI scaling factor. Defaults to 2 for retina-quality output. */
|
|
22
|
+
dpi?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface JPGExportOptions extends PNGExportOptions {
|
|
26
|
+
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
27
|
+
quality?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FontFaceData {
|
|
31
|
+
family: string;
|
|
32
|
+
weight: string;
|
|
33
|
+
style: string;
|
|
34
|
+
base64: string;
|
|
35
|
+
format: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Dimension parsing
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract dimensions from an SVG element, trying width/height attributes
|
|
44
|
+
* first, then falling back to viewBox.
|
|
45
|
+
*/
|
|
46
|
+
function getSVGDimensions(svg: SVGElement): { width: number; height: number } {
|
|
47
|
+
const w = parseFloat(svg.getAttribute('width') || '');
|
|
48
|
+
const h = parseFloat(svg.getAttribute('height') || '');
|
|
49
|
+
if (w && h) return { width: w, height: h };
|
|
50
|
+
|
|
51
|
+
const vb = svg.getAttribute('viewBox');
|
|
52
|
+
if (vb) {
|
|
53
|
+
const parts = vb.split(/[\s,]+/).map(Number);
|
|
54
|
+
if (parts.length >= 4 && parts[2] && parts[3]) {
|
|
55
|
+
return { width: parts[2], height: parts[3] };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { width: 600, height: 400 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Font embedding
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Collect unique font-family + font-weight combos from all <text> elements.
|
|
68
|
+
*/
|
|
69
|
+
function collectUsedFonts(svgElement: SVGElement): Map<string, Set<string>> {
|
|
70
|
+
const fonts = new Map<string, Set<string>>();
|
|
71
|
+
const textElements = svgElement.querySelectorAll('text');
|
|
72
|
+
|
|
73
|
+
for (const el of textElements) {
|
|
74
|
+
const family = el.getAttribute('font-family');
|
|
75
|
+
const weight = el.getAttribute('font-weight') || '400';
|
|
76
|
+
if (family) {
|
|
77
|
+
// Take the first font in the stack (e.g., "Inter, sans-serif" → "Inter")
|
|
78
|
+
const primary = family.split(',')[0].trim().replace(/["']/g, '');
|
|
79
|
+
if (!fonts.has(primary)) fonts.set(primary, new Set());
|
|
80
|
+
fonts.get(primary)!.add(String(weight));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return fonts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find @font-face rules in document stylesheets that match the requested fonts.
|
|
89
|
+
* Returns the src URLs for .woff2 files.
|
|
90
|
+
*/
|
|
91
|
+
function findFontFaceRules(
|
|
92
|
+
usedFonts: Map<string, Set<string>>,
|
|
93
|
+
): Array<{ family: string; weight: string; style: string; url: string; format: string }> {
|
|
94
|
+
const results: Array<{
|
|
95
|
+
family: string;
|
|
96
|
+
weight: string;
|
|
97
|
+
style: string;
|
|
98
|
+
url: string;
|
|
99
|
+
format: string;
|
|
100
|
+
}> = [];
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
for (const sheet of document.styleSheets) {
|
|
104
|
+
let rules: CSSRuleList;
|
|
105
|
+
try {
|
|
106
|
+
rules = sheet.cssRules;
|
|
107
|
+
} catch {
|
|
108
|
+
// Cross-origin stylesheet, skip
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const rule of rules) {
|
|
113
|
+
if (!(rule instanceof CSSFontFaceRule)) continue;
|
|
114
|
+
|
|
115
|
+
const familyRaw = rule.style.getPropertyValue('font-family').replace(/["']/g, '').trim();
|
|
116
|
+
const weight = rule.style.getPropertyValue('font-weight') || '400';
|
|
117
|
+
const style = rule.style.getPropertyValue('font-style') || 'normal';
|
|
118
|
+
const src = rule.style.getPropertyValue('src');
|
|
119
|
+
|
|
120
|
+
const weights = usedFonts.get(familyRaw);
|
|
121
|
+
if (!weights) continue;
|
|
122
|
+
|
|
123
|
+
// Check if this weight is used (handle ranges like "100 900")
|
|
124
|
+
const weightMatch = weights.has(weight) || weight.includes(' ');
|
|
125
|
+
if (!weightMatch) continue;
|
|
126
|
+
|
|
127
|
+
// Extract woff2 URL from src descriptor
|
|
128
|
+
const woff2Match = src.match(/url\(["']?([^"')]+\.woff2[^"')]*?)["']?\)/);
|
|
129
|
+
if (woff2Match) {
|
|
130
|
+
results.push({
|
|
131
|
+
family: familyRaw,
|
|
132
|
+
weight,
|
|
133
|
+
style,
|
|
134
|
+
url: woff2Match[1],
|
|
135
|
+
format: 'woff2',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Stylesheet access failed entirely, return empty
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fetch font files and convert to base64 data URIs.
|
|
149
|
+
*/
|
|
150
|
+
async function fetchFontsAsBase64(
|
|
151
|
+
fontRules: Array<{ family: string; weight: string; style: string; url: string; format: string }>,
|
|
152
|
+
): Promise<FontFaceData[]> {
|
|
153
|
+
const results: FontFaceData[] = [];
|
|
154
|
+
|
|
155
|
+
const fetches = fontRules.map(async (rule) => {
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(rule.url);
|
|
158
|
+
if (!response.ok) return;
|
|
159
|
+
const buffer = await response.arrayBuffer();
|
|
160
|
+
const base64 = arrayBufferToBase64(buffer);
|
|
161
|
+
results.push({
|
|
162
|
+
family: rule.family,
|
|
163
|
+
weight: rule.weight,
|
|
164
|
+
style: rule.style,
|
|
165
|
+
base64,
|
|
166
|
+
format: rule.format,
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
// Font fetch failed (CORS, network, etc.) - skip this font
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await Promise.all(fetches);
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
178
|
+
let binary = '';
|
|
179
|
+
const bytes = new Uint8Array(buffer);
|
|
180
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
181
|
+
binary += String.fromCharCode(bytes[i]);
|
|
182
|
+
}
|
|
183
|
+
return btoa(binary);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Inject @font-face rules with base64 data URIs into the SVG's <defs>.
|
|
188
|
+
*/
|
|
189
|
+
function injectFontsIntoSVG(svgElement: SVGElement, fonts: FontFaceData[]): void {
|
|
190
|
+
if (fonts.length === 0) return;
|
|
191
|
+
|
|
192
|
+
const cssRules = fonts
|
|
193
|
+
.map(
|
|
194
|
+
(f) =>
|
|
195
|
+
`@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}'); }`,
|
|
196
|
+
)
|
|
197
|
+
.join('\n');
|
|
198
|
+
|
|
199
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
200
|
+
let defs = svgElement.querySelector('defs');
|
|
201
|
+
if (!defs) {
|
|
202
|
+
defs = document.createElementNS(ns, 'defs');
|
|
203
|
+
svgElement.insertBefore(defs, svgElement.firstChild);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const styleEl = document.createElementNS(ns, 'style');
|
|
207
|
+
styleEl.textContent = cssRules;
|
|
208
|
+
defs.insertBefore(styleEl, defs.firstChild);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Embed fonts into an SVG element by finding matching @font-face rules
|
|
213
|
+
* in the page's stylesheets, fetching the font files, and injecting
|
|
214
|
+
* them as base64 data URIs.
|
|
215
|
+
*
|
|
216
|
+
* Modifies the SVG element in place. Call this before serialization.
|
|
217
|
+
* If font fetching fails for any font, that font is silently skipped
|
|
218
|
+
* and the export proceeds with system font fallback for that face.
|
|
219
|
+
*/
|
|
220
|
+
async function embedFonts(svgElement: SVGElement): Promise<void> {
|
|
221
|
+
const usedFonts = collectUsedFonts(svgElement);
|
|
222
|
+
if (usedFonts.size === 0) return;
|
|
223
|
+
|
|
224
|
+
const fontRules = findFontFaceRules(usedFonts);
|
|
225
|
+
if (fontRules.length === 0) return;
|
|
226
|
+
|
|
227
|
+
const fontData = await fetchFontsAsBase64(fontRules);
|
|
228
|
+
injectFontsIntoSVG(svgElement, fontData);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// SVG background color
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read the chart's background color from its first rect element.
|
|
237
|
+
*/
|
|
238
|
+
function getSVGBackgroundColor(svgElement: SVGElement): string {
|
|
239
|
+
const firstRect = svgElement.querySelector('rect');
|
|
240
|
+
return firstRect?.getAttribute('fill') || '#ffffff';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// SVG export
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
9
247
|
/**
|
|
10
248
|
* Serialize an SVG element to an XML string.
|
|
11
249
|
*
|
|
@@ -17,24 +255,53 @@ export function exportSVG(svgElement: SVGElement): string {
|
|
|
17
255
|
return serializer.serializeToString(svgElement);
|
|
18
256
|
}
|
|
19
257
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Serialize an SVG element with embedded fonts to an XML string.
|
|
260
|
+
*
|
|
261
|
+
* Collects font-family declarations from the SVG's text elements,
|
|
262
|
+
* finds matching @font-face rules in the page's stylesheets, fetches
|
|
263
|
+
* the font files, and embeds them as base64 data URIs. The resulting
|
|
264
|
+
* SVG is self-contained and renders correctly without external fonts.
|
|
265
|
+
*
|
|
266
|
+
* @param svgElement - The rendered SVG element to serialize.
|
|
267
|
+
* @param options - Export options.
|
|
268
|
+
* @returns A Promise resolving to the SVG markup as a string.
|
|
269
|
+
*/
|
|
270
|
+
export async function exportSVGWithFonts(
|
|
271
|
+
svgElement: SVGElement,
|
|
272
|
+
options?: SVGExportOptions,
|
|
273
|
+
): Promise<string> {
|
|
274
|
+
const shouldEmbed = options?.embedFonts ?? true;
|
|
275
|
+
if (shouldEmbed) {
|
|
276
|
+
await embedFonts(svgElement);
|
|
277
|
+
}
|
|
278
|
+
return exportSVG(svgElement);
|
|
23
279
|
}
|
|
24
280
|
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Raster export (PNG / JPG)
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
25
285
|
/**
|
|
26
286
|
* Render an SVG element to a PNG Blob via a canvas.
|
|
27
287
|
*
|
|
288
|
+
* Embeds fonts by default so the exported image matches on-screen rendering.
|
|
289
|
+
* Set `embedFonts: false` to skip font embedding for faster exports.
|
|
290
|
+
*
|
|
28
291
|
* @param svgElement - The rendered SVG element.
|
|
29
|
-
* @param options - Optional DPI scaling.
|
|
292
|
+
* @param options - Optional DPI scaling and font embedding.
|
|
30
293
|
* @returns A Promise resolving to the PNG Blob.
|
|
31
294
|
*/
|
|
32
295
|
export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptions): Promise<Blob> {
|
|
33
296
|
const dpi = options?.dpi ?? 2;
|
|
34
|
-
const
|
|
297
|
+
const shouldEmbed = options?.embedFonts ?? true;
|
|
298
|
+
|
|
299
|
+
if (shouldEmbed) {
|
|
300
|
+
await embedFonts(svgElement);
|
|
301
|
+
}
|
|
35
302
|
|
|
36
|
-
const
|
|
37
|
-
const height =
|
|
303
|
+
const svgString = exportSVG(svgElement);
|
|
304
|
+
const { width, height } = getSVGDimensions(svgElement);
|
|
38
305
|
|
|
39
306
|
const canvas = document.createElement('canvas');
|
|
40
307
|
canvas.width = width * dpi;
|
|
@@ -74,29 +341,28 @@ export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptio
|
|
|
74
341
|
});
|
|
75
342
|
}
|
|
76
343
|
|
|
77
|
-
export interface JPGExportOptions extends PNGExportOptions {
|
|
78
|
-
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
79
|
-
quality?: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
344
|
/**
|
|
83
345
|
* Render an SVG element to a JPEG Blob via a canvas.
|
|
84
346
|
*
|
|
85
347
|
* Same pipeline as exportPNG but outputs JPEG with configurable quality.
|
|
86
|
-
* The canvas is filled with
|
|
87
|
-
* backgrounds rendering as black in JPEG format.
|
|
348
|
+
* The canvas is filled with the chart's background color before drawing
|
|
349
|
+
* to avoid transparent backgrounds rendering as black in JPEG format.
|
|
88
350
|
*
|
|
89
351
|
* @param svgElement - The rendered SVG element.
|
|
90
|
-
* @param options - Optional DPI scaling and
|
|
352
|
+
* @param options - Optional DPI scaling, JPEG quality, and font embedding.
|
|
91
353
|
* @returns A Promise resolving to the JPEG Blob.
|
|
92
354
|
*/
|
|
93
355
|
export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob> {
|
|
94
356
|
const dpi = options?.dpi ?? 2;
|
|
95
357
|
const quality = options?.quality ?? 0.92;
|
|
96
|
-
const
|
|
358
|
+
const shouldEmbed = options?.embedFonts ?? true;
|
|
359
|
+
|
|
360
|
+
if (shouldEmbed) {
|
|
361
|
+
await embedFonts(svgElement);
|
|
362
|
+
}
|
|
97
363
|
|
|
98
|
-
const
|
|
99
|
-
const height =
|
|
364
|
+
const svgString = exportSVG(svgElement);
|
|
365
|
+
const { width, height } = getSVGDimensions(svgElement);
|
|
100
366
|
|
|
101
367
|
const canvas = document.createElement('canvas');
|
|
102
368
|
canvas.width = width * dpi;
|
|
@@ -107,8 +373,8 @@ export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptio
|
|
|
107
373
|
throw new Error('Canvas 2D context not available');
|
|
108
374
|
}
|
|
109
375
|
|
|
110
|
-
// Fill
|
|
111
|
-
ctx.fillStyle =
|
|
376
|
+
// Fill with the chart's actual background color (not hardcoded white)
|
|
377
|
+
ctx.fillStyle = getSVGBackgroundColor(svgElement);
|
|
112
378
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
113
379
|
|
|
114
380
|
ctx.scale(dpi, dpi);
|
|
@@ -144,6 +410,10 @@ export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptio
|
|
|
144
410
|
});
|
|
145
411
|
}
|
|
146
412
|
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// CSV export
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
147
417
|
/**
|
|
148
418
|
* Convert an array of data objects to a CSV string.
|
|
149
419
|
*
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,9 @@ export type {
|
|
|
17
17
|
TableSpec,
|
|
18
18
|
VizSpec,
|
|
19
19
|
} from '@opendata-ai/openchart-engine';
|
|
20
|
-
export type { JPGExportOptions, PNGExportOptions } from './export';
|
|
20
|
+
export type { JPGExportOptions, PNGExportOptions, SVGExportOptions } from './export';
|
|
21
21
|
// Export utilities
|
|
22
|
-
export { exportCSV, exportJPG, exportPNG, exportSVG } from './export';
|
|
22
|
+
export { exportCSV, exportJPG, exportPNG, exportSVG, exportSVGWithFonts } from './export';
|
|
23
23
|
// Graph simulation worker
|
|
24
24
|
export { createSimulationWorker } from './graph/simulation-worker-url';
|
|
25
25
|
export type { GraphInstance, GraphMountOptions } from './graph-mount';
|
package/src/mount.ts
CHANGED
|
@@ -26,7 +26,15 @@ import type {
|
|
|
26
26
|
TooltipContent,
|
|
27
27
|
} from '@opendata-ai/openchart-core';
|
|
28
28
|
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
exportCSV,
|
|
31
|
+
exportJPG,
|
|
32
|
+
exportPNG,
|
|
33
|
+
exportSVG,
|
|
34
|
+
exportSVGWithFonts,
|
|
35
|
+
type JPGExportOptions,
|
|
36
|
+
type SVGExportOptions,
|
|
37
|
+
} from './export';
|
|
30
38
|
import { observeResize } from './resize-observer';
|
|
31
39
|
import { renderChartSVG } from './svg-renderer';
|
|
32
40
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
@@ -57,10 +65,14 @@ export interface ChartInstance {
|
|
|
57
65
|
resize(): void;
|
|
58
66
|
/** Export the chart. */
|
|
59
67
|
export(format: 'svg'): string;
|
|
68
|
+
export(format: 'svg-with-fonts', options?: SVGExportOptions): Promise<string>;
|
|
60
69
|
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
61
70
|
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
62
71
|
export(format: 'csv'): string;
|
|
63
|
-
export(
|
|
72
|
+
export(
|
|
73
|
+
format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg' | 'csv',
|
|
74
|
+
options?: ExportOptions,
|
|
75
|
+
): string | Promise<Blob> | Promise<string>;
|
|
64
76
|
/** Remove all DOM elements and disconnect observers. */
|
|
65
77
|
destroy(): void;
|
|
66
78
|
/** The current compiled layout (for hooks / debugging). */
|
|
@@ -1571,13 +1583,14 @@ export function createChart(
|
|
|
1571
1583
|
}
|
|
1572
1584
|
|
|
1573
1585
|
function doExport(format: 'svg'): string;
|
|
1586
|
+
function doExport(format: 'svg-with-fonts', exportOptions?: SVGExportOptions): Promise<string>;
|
|
1574
1587
|
function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1575
1588
|
function doExport(format: 'jpg', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1576
1589
|
function doExport(format: 'csv'): string;
|
|
1577
1590
|
function doExport(
|
|
1578
|
-
format: 'svg' | 'png' | 'jpg' | 'csv',
|
|
1591
|
+
format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg' | 'csv',
|
|
1579
1592
|
exportOptions?: ExportOptions,
|
|
1580
|
-
): string | Promise<Blob> {
|
|
1593
|
+
): string | Promise<Blob> | Promise<string> {
|
|
1581
1594
|
if (!svgElement) {
|
|
1582
1595
|
throw new Error('Chart is not rendered yet');
|
|
1583
1596
|
}
|
|
@@ -1585,6 +1598,8 @@ export function createChart(
|
|
|
1585
1598
|
switch (format) {
|
|
1586
1599
|
case 'svg':
|
|
1587
1600
|
return exportSVG(svgElement);
|
|
1601
|
+
case 'svg-with-fonts':
|
|
1602
|
+
return exportSVGWithFonts(svgElement, exportOptions);
|
|
1588
1603
|
case 'png':
|
|
1589
1604
|
return exportPNG(svgElement, exportOptions);
|
|
1590
1605
|
case 'jpg':
|