@opendata-ai/openchart-vanilla 2.12.2 → 2.13.1

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 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
- interface PNGExportOptions {
19
- /** DPI scaling factor. Defaults to 2 for retina-quality output. */
20
- dpi?: number;
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 white before drawing to avoid transparent
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 JPEG quality.
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 = parseFloat(svgElement.getAttribute("width") || "600");
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 = parseFloat(svgElement.getAttribute("width") || "600");
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 = "#ffffff";
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();
@@ -3867,6 +3999,7 @@ function renderChartSVG(layout, container) {
3867
3999
  viewBox: `0 0 ${width} ${height}`,
3868
4000
  xmlns: SVG_NS
3869
4001
  });
4002
+ svg.style.height = `${height}px`;
3870
4003
  svg.setAttribute("role", layout.a11y.role);
3871
4004
  svg.setAttribute("aria-label", layout.a11y.altText);
3872
4005
  svg.setAttribute("class", "viz-chart");
@@ -4926,6 +5059,8 @@ function createChart(container, spec, options) {
4926
5059
  switch (format) {
4927
5060
  case "svg":
4928
5061
  return exportSVG(svgElement);
5062
+ case "svg-with-fonts":
5063
+ return exportSVGWithFonts(svgElement, exportOptions);
4929
5064
  case "png":
4930
5065
  return exportPNG(svgElement, exportOptions);
4931
5066
  case "jpg":
@@ -6130,6 +6265,7 @@ export {
6130
6265
  exportJPG,
6131
6266
  exportPNG,
6132
6267
  exportSVG,
6268
+ exportSVGWithFonts,
6133
6269
  observeResize,
6134
6270
  registerMarkRenderer,
6135
6271
  renderBarCell,