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