Chart/api/export.ts

  1. /**
  2. * Copyright (c) 2017 ~ present NAVER Corp.
  3. * billboard.js project is licensed under the MIT license
  4. */
  5. import {namespaces as d3Namespaces} from "d3-selection";
  6. import {document, window} from "../../module/browser";
  7. import {getCssRules, isFunction, mergeObj, toArray} from "../../module/util";
  8. type TExportOption = TSize & {
  9. preserveAspectRatio: boolean,
  10. preserveFontStyle: boolean,
  11. mimeType: string
  12. };
  13. type TSize = {x?: number, y?: number, width: number, height: number};
  14. type TTextGlyph = {
  15. [key: string]: TSize & {
  16. fill: string,
  17. fontFamily: string,
  18. fontSize: string,
  19. textAnchor: string,
  20. transform: string
  21. }
  22. };
  23. /**
  24. * Encode to base64
  25. * @param {string} str string to be encoded
  26. * @returns {string}
  27. * @private
  28. * @see https://developer.mozilla.org/ko/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
  29. */
  30. const b64EncodeUnicode = (str: string): string =>
  31. window.btoa?.(
  32. encodeURIComponent(str)
  33. .replace(/%([0-9A-F]{2})/g,
  34. (match, p: number | string): string => String.fromCharCode(Number(`0x${p}`)))
  35. );
  36. /**
  37. * Convert svg node to data url
  38. * @param {HTMLElement} node target node
  39. * @param {object} option object containing {width, height, preserveAspectRatio}
  40. * @param {object} orgSize object containing {width, height}
  41. * @returns {string}
  42. * @private
  43. */
  44. function nodeToSvgDataUrl(node, option: TExportOption, orgSize: TSize) {
  45. const {width, height} = option || orgSize;
  46. const serializer = new XMLSerializer();
  47. const clone = node.cloneNode(true);
  48. const cssText = getCssRules(toArray(document.styleSheets))
  49. .filter((r: CSSStyleRule) => r.cssText)
  50. .map((r: CSSStyleRule) => r.cssText);
  51. clone.setAttribute("xmlns", d3Namespaces.xhtml);
  52. // remove padding & margin
  53. clone.style.margin = "0";
  54. clone.style.padding = "0";
  55. // remove text nodes
  56. if (option.preserveFontStyle) {
  57. clone.querySelectorAll("text").forEach(t => {
  58. t.innerHTML = "";
  59. });
  60. }
  61. const nodeXml = serializer.serializeToString(clone);
  62. // escape css for XML
  63. const style = document.createElement("style");
  64. style.appendChild(document.createTextNode(cssText.join("\n")));
  65. const styleXml = serializer.serializeToString(style);
  66. // foreignObject not supported in IE11 and below
  67. // https://msdn.microsoft.com/en-us/library/hh834675(v=vs.85).aspx
  68. const dataStr = `<svg xmlns="${d3Namespaces.svg}" width="${width}" height="${height}"
  69. viewBox="0 0 ${orgSize.width} ${orgSize.height}"
  70. preserveAspectRatio="${option?.preserveAspectRatio === false ? "none" : "xMinYMid meet"}">
  71. <foreignObject width="100%" height="100%">
  72. ${styleXml}
  73. ${nodeXml.replace(/(url\()[^#]+/g, "$1")}
  74. </foreignObject></svg>`;
  75. return `data:image/svg+xml;base64,${b64EncodeUnicode(dataStr)}`;
  76. }
  77. /**
  78. * Get coordinate of the element
  79. * @param {SVGElement} elem Target element
  80. * @param {object} svgOffset SVG offset
  81. * @returns {object}
  82. * @private
  83. */
  84. function getCoords(elem, svgOffset): TSize {
  85. const {top, left} = svgOffset;
  86. const {x, y} = elem.getBBox();
  87. const {a, b, c, d, e, f} = elem.getScreenCTM();
  88. const {width, height} = elem.getBoundingClientRect();
  89. return {
  90. x: (a * x) + (c * y) + e - left,
  91. y: (b * x) + (d * y) + f - top + (height - Math.round(height / 4)),
  92. width,
  93. height
  94. };
  95. }
  96. /**
  97. * Get text glyph
  98. * @param {SVGTextElement} svg Target svg node
  99. * @returns {Array}
  100. * @private
  101. */
  102. function getGlyph(svg: SVGElement): TTextGlyph[] {
  103. const {left, top} = svg.getBoundingClientRect();
  104. const filterFn = t => t.textContent || t.childElementCount;
  105. const glyph: TTextGlyph[] = [];
  106. toArray(svg.querySelectorAll("text"))
  107. .filter(filterFn)
  108. .forEach((t: SVGTextElement) => { // eslint-disable-line
  109. const getStyleFn = (ts: SVGTextElement): TTextGlyph => {
  110. const {fill, fontFamily, fontSize, textAnchor, transform} = window.getComputedStyle(
  111. ts
  112. );
  113. const {x, y, width, height} = getCoords(ts, {left, top});
  114. return {
  115. [ts.textContent as string]: {
  116. x,
  117. y,
  118. width,
  119. height,
  120. fill,
  121. fontFamily,
  122. fontSize,
  123. textAnchor,
  124. transform
  125. }
  126. };
  127. };
  128. if (t.childElementCount > 1) {
  129. const text: TTextGlyph[] = [];
  130. toArray(t.querySelectorAll("tspan"))
  131. .filter(filterFn)
  132. .forEach((ts: SVGTSpanElement) => {
  133. glyph.push(getStyleFn(ts));
  134. });
  135. return text;
  136. } else {
  137. glyph.push(getStyleFn(t));
  138. }
  139. });
  140. return glyph;
  141. }
  142. /**
  143. * Render text glyph
  144. * - NOTE: Called when the 'preserveFontStyle' option is true
  145. * @param {CanvasRenderingContext2D} ctx Canvas context
  146. * @param {Array} glyph Text glyph array
  147. * @private
  148. */
  149. function renderText(ctx, glyph): void {
  150. glyph.forEach(g => {
  151. Object.keys(g).forEach(key => {
  152. const {x, y, width, height, fill, fontFamily, fontSize, transform} = g[key];
  153. ctx.save();
  154. ctx.font = `${fontSize} ${fontFamily}`;
  155. ctx.fillStyle = fill;
  156. if (transform === "none") {
  157. ctx.fillText(key, x, y);
  158. } else {
  159. const args = transform
  160. .replace(/(matrix|\(|\))/g, "")
  161. .split(",");
  162. if (args.splice(4).every(v => +v === 0)) {
  163. args.push(x + width - (width / 4));
  164. args.push(y - height + (height / 3));
  165. } else {
  166. args.push(x);
  167. args.push(y);
  168. }
  169. ctx.transform(...args);
  170. ctx.fillText(key, 0, 0);
  171. }
  172. ctx.restore();
  173. });
  174. });
  175. }
  176. export default {
  177. /**
  178. * Export chart as an image.
  179. * - **NOTE:**
  180. * - IE11 and below not work properly due to the lack of the feature(<a href="https://msdn.microsoft.com/en-us/library/hh834675(v=vs.85).aspx">foreignObject</a>) support
  181. * - Every style applied to the chart & the basic CSS file(ex. billboard.css) should be at same domain as API call context to get correct styled export image.
  182. * @function export
  183. * @instance
  184. * @memberof Chart
  185. * @param {object} option Export option
  186. * @param {string} [option.mimeType="image/png"] The desired output image format. (ex. 'image/png' for png, 'image/jpeg' for jpeg format)
  187. * @param {number} [option.width={currentWidth}] width
  188. * @param {number} [option.height={currentHeigth}] height
  189. * @param {boolean} [option.preserveAspectRatio=true] Preserve aspect ratio on given size
  190. * @param {boolean} [option.preserveFontStyle=false] Preserve font style(font-family).<br>
  191. * **NOTE:**
  192. * - This option is useful when outlink web font style's `font-family` are applied to chart's text element.
  193. * - Text element's position(especially "transformed") can't be preserved correctly according the page's layout condition.
  194. * - If need to preserve accurate text position, embed the web font data within to the page and set `preserveFontStyle=false`.
  195. * - Checkout the embed example: <a href="https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html">https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html</a>
  196. * @param {Function} [callback] The callback to be invoked when export is ready.
  197. * @returns {string} dataURI
  198. * @example
  199. * chart.export();
  200. * // --> "data:image/svg+xml;base64,PHN..."
  201. *
  202. * // Initialize the download automatically
  203. * chart.export({mimeType: "image/png"}, dataUrl => {
  204. * const link = document.createElement("a");
  205. *
  206. * link.download = `${Date.now()}.png`;
  207. * link.href = dataUrl;
  208. * link.innerHTML = "Download chart as image";
  209. *
  210. * document.body.appendChild(link);
  211. * });
  212. *
  213. * // Resize the exported image
  214. * chart.export(
  215. * {
  216. * width: 800,
  217. * height: 600,
  218. * preserveAspectRatio: false,
  219. * preserveFontStyle: false,
  220. * mimeType: "image/png"
  221. * },
  222. * dataUrl => { ... }
  223. * );
  224. */
  225. export(option?: TExportOption, callback?: (dataUrl: string) => void): string {
  226. const $$ = this.internal;
  227. const {state, $el: {chart, svg}} = $$;
  228. const {width, height} = state.current;
  229. const opt = mergeObj({
  230. width,
  231. height,
  232. preserveAspectRatio: true,
  233. preserveFontStyle: false,
  234. mimeType: "image/png"
  235. }, option) as TExportOption;
  236. const svgDataUrl = nodeToSvgDataUrl(chart.node(), opt, {width, height});
  237. const glyph = opt.preserveFontStyle ? getGlyph(svg.node()) : [];
  238. if (callback && isFunction(callback)) {
  239. const img = new Image();
  240. img.crossOrigin = "Anonymous";
  241. img.onload = () => {
  242. const canvas = document.createElement("canvas");
  243. const ctx = canvas.getContext("2d");
  244. canvas.width = opt.width || width;
  245. canvas.height = opt.height || height;
  246. ctx.drawImage(img, 0, 0);
  247. if (glyph.length) {
  248. renderText(ctx, glyph);
  249. // release glyph array
  250. glyph.length = 0;
  251. }
  252. callback.bind(this)(canvas.toDataURL(opt.mimeType));
  253. };
  254. img.src = svgDataUrl;
  255. }
  256. return svgDataUrl;
  257. }
  258. };