From 761f5f8c2d43360a80c909cdadcb63e475dc34e8 Mon Sep 17 00:00:00 2001 From: Jae Sung Park Date: Mon, 17 Oct 2022 22:19:21 +0900 Subject: [PATCH 1/3] feat(api): Enhance .export() to preserve fontStyle Enhance to preserve outlink applied font-family on export. Ref #2892 --- config/jsdoc/static/doc.css | 7 + demo/demo.js | 79 +++++++ demo/tomorrow.css | 12 ++ src/Chart/api/export.ts | 173 +++++++++++++-- src/ChartInternal/internals/grid.ts | 2 +- src/module/util.ts | 2 +- test/api/export-spec.ts | 318 ++++++++++++++++++---------- types/chart.d.ts | 7 + 8 files changed, 474 insertions(+), 126 deletions(-) diff --git a/config/jsdoc/static/doc.css b/config/jsdoc/static/doc.css index c1c3aead6..38b6f939c 100644 --- a/config/jsdoc/static/doc.css +++ b/config/jsdoc/static/doc.css @@ -50,3 +50,10 @@ h4[id$='$'] > span:first-child { .details p { margin: 0; } + +.experimental:before { + content: "EXPERIMENTAL"; + color: #fff; + background-color: red; + padding: 2px; +} diff --git a/demo/demo.js b/demo/demo.js index 5c569976f..8099fdcdf 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -5589,6 +5589,85 @@ d3.select(".chart_area") ] } }, + ExportPreserveFontStyle: { + description: "Export with preserving web font-family.", + options: { + data: { + columns: [ + ["data1", 30, 200, 100, 400, 150, 250], + ["data2", 5000, 2000, 1000, 4000, 1500, 2500] + ], + types: { + data1: "bar", + data2: "area" + }, + labels: true + }, + grid: { + x: { + lines: [ + { + value: 1, + text: "Label 1", + position: 'middle' + }, + { + value: 3, + text: "Label 3" + } + ] + }, + y: { + lines: [ + { + value: 4000, + text: "Y Label 1" + }, + ] + } + }, + }, + style: [ + `@font-face { + font-family: 'Alfa Slab One'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/alfaslabone/v17/6NUQ8FmMKwSEKjnm5-4v-4Jh2dJhe_escmA.woff2) format('woff2'); +} + +#exportPreserveFontStyle svg { + font-family: 'Alfa Slab One'; +}` + ], + func: function(chart) { + chart.timer = [ + setTimeout(function() { + // crate a div element + var exported = document.createElement("div"); + + document.getElementById("exportPreserveFontStyle") + .insertAdjacentElement("afterend", exported); + + // Preserve web-font style + chart.export({ + preserveFontStyle: true + }, function(dataUrl) { + var img = document.getElementById("exported"); + + if (!img) { + img = document.createElement("img"); + + img.id = "exported"; + exported.appendChild(img); + } + + img.src = dataUrl; + }); + }, 1000) + ] + } + }, Flow: { options: { data: { diff --git a/demo/tomorrow.css b/demo/tomorrow.css index bf1fb1088..e8a2151ff 100644 --- a/demo/tomorrow.css +++ b/demo/tomorrow.css @@ -70,4 +70,16 @@ pre code { float: none; clear: left; content: ""; +} + +@font-face { + font-family: 'Alfa Slab One'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/alfaslabone/v17/6NUQ8FmMKwSEKjnm5-4v-4Jh2dJhe_escmA.woff2) format('woff2'); +} + +#exportPreserveFontStyle svg { + font-family: 'Alfa Slab One'; } \ No newline at end of file diff --git a/src/Chart/api/export.ts b/src/Chart/api/export.ts index bb41a1b83..48fbdc9cd 100644 --- a/src/Chart/api/export.ts +++ b/src/Chart/api/export.ts @@ -3,18 +3,31 @@ * billboard.js project is licensed under the MIT license */ import {namespaces as d3Namespaces} from "d3-selection"; -import {document} from "../../module/browser"; +import {document, window} from "../../module/browser"; import {isFunction, toArray, getCssRules, mergeObj} from "../../module/util"; -type Size = { +type TExportOption = TSize & { + preserveAspectRatio: boolean; + preserveFontStyle: boolean; + mimeType: string; +}; + +type TSize = { + x?: number; + y?: number; width: number; height: number; }; -type ExportOption = Size & { - mimeType: string; - preserveAspectRatio: boolean; -} +type TTextGlyph = { + [key: string]: TSize & { + fill: string; + fontFamily: string; + fontSize: string; + textAnchor: string; + transform: string; + } +}; /** * Encode to base64 @@ -23,7 +36,7 @@ type ExportOption = Size & { * @private * @see https://developer.mozilla.org/ko/docs/Web/API/WindowBase64/Base64_encoding_and_decoding */ -const b64EncodeUnicode = (str: string): string => btoa( +const b64EncodeUnicode = (str: string): string => window.btoa?.( encodeURIComponent(str) .replace(/%([0-9A-F]{2})/g, (match, p: number | string): string => String.fromCharCode(Number(`0x${p}`))) ); @@ -36,16 +49,27 @@ const b64EncodeUnicode = (str: string): string => btoa( * @returns {string} * @private */ -function nodeToSvgDataUrl(node, option: ExportOption, orgSize: Size) { +function nodeToSvgDataUrl(node, option: TExportOption, orgSize: TSize) { const {width, height} = option || orgSize; const serializer = new XMLSerializer(); const clone = node.cloneNode(true); const cssText = getCssRules(toArray(document.styleSheets)) - .filter((r: any) => r.cssText) - .map((r: any) => r.cssText); + .filter((r: CSSStyleRule) => r.cssText) + .map((r: CSSStyleRule) => r.cssText); clone.setAttribute("xmlns", d3Namespaces.xhtml); + // remove padding & margin + clone.style.margin = "0"; + clone.style.padding = "0"; + + // remove text nodes + if (option.preserveFontStyle) { + clone.querySelectorAll("text").forEach(t => { + t.innerHTML = ""; + }); + } + const nodeXml = serializer.serializeToString(clone); // escape css for XML @@ -69,12 +93,117 @@ function nodeToSvgDataUrl(node, option: ExportOption, orgSize: Size) { return `data:image/svg+xml;base64,${b64EncodeUnicode(dataStr)}`; } +/** + * Get coordinate of the element + * @param {SVGElement} elem Target element + * @param {object} svgOffset SVG offset + * @returns {object} + * @private + */ +function getCoords(elem, svgOffset): TSize { + const {top, left} = svgOffset; + const {x, y} = elem.getBBox(); + const {a, b, c, d, e, f} = elem.getScreenCTM(); + const {width, height} = elem.getBoundingClientRect(); + + return { + x: (a * x) + (c * y) + e - left, + y: (b * x) + (d * y) + f - top + (height - Math.round(height / 4)), + width, + height + }; +} + +/** + * Get text glyph + * @param {SVGTextElement} svg Target svg node + * @returns {Array} + * @private + */ +function getGlyph(svg: SVGElement): TTextGlyph[] { + const {left, top} = svg.getBoundingClientRect(); + const filterFn = t => t.textContent || t.childElementCount; + const glyph: TTextGlyph[] = []; + + toArray(svg.querySelectorAll("text")) + .filter(filterFn) + .forEach((t: SVGTextElement) => { // eslint-disable-line + const getStyleFn = (ts: SVGTextElement): TTextGlyph => { + const {fill, fontFamily, fontSize, textAnchor, transform} = window.getComputedStyle(ts); + const {x, y, width, height} = getCoords(ts, {left, top}); + + return { + [ts.textContent as string]: { + x, y, width, height, fill, fontFamily, fontSize, textAnchor, transform + } + }; + }; + + if (t.childElementCount > 1) { + const text: TTextGlyph[] = []; + + toArray(t.querySelectorAll("tspan")) + .filter(filterFn) + .forEach((ts: SVGTSpanElement) => { + glyph.push(getStyleFn(ts)); + }); + + return text; + } else { + glyph.push(getStyleFn(t)); + } + }); + + return glyph; +} + +/** + * Render text glyph + * - NOTE: Called when the 'preserveFontStyle' option is true + * @param {CanvasRenderingContext2D} ctx Canvas context + * @param {Array} glyph Text glyph array + * @private + */ +function renderText(ctx, glyph): void { + glyph.forEach(g => { + Object.keys(g).forEach(key => { + const {x, y, width, height, fill, fontFamily, fontSize, transform} = g[key]; + + ctx.save(); + + ctx.font = `${fontSize} ${fontFamily}`; + ctx.fillStyle = fill; + + if (transform === "none") { + ctx.fillText(key, x, y); + } else { + const args = transform + .replace(/(matrix|\(|\))/g, "") + .split(","); + + if (args.splice(4).every(v => +v === 0)) { + args.push(x + width - (width / 4)); + args.push(y - height + (height / 3)); + } else { + args.push(x); + args.push(y); + } + + ctx.transform(...args); + ctx.fillText(key, 0, 0); + } + + ctx.restore(); + }); + }); +} + export default { /** * Export chart as an image. * - **NOTE:** * - IE11 and below not work properly due to the lack of the feature(foreignObject) support - * - The basic CSS file(ex. billboard.css) should be at same domain as API call context to get correct styled export image. + * - 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. * @function export * @instance * @memberof Chart @@ -83,6 +212,12 @@ export default { * @param {number} [option.width={currentWidth}] width * @param {number} [option.height={currentHeigth}] height * @param {boolean} [option.preserveAspectRatio=true] Preserve aspect ratio on given size + * @param {boolean} [option.preserveFontStyle=false] Preserve font style(font-family).
+ * **NOTE:** + * - This option is useful when outlink web font style's `font-family` are applied to chart's text element. + * - Text element's position(especially "transformed") can't be preserved correctly according the page's layout condition. + * - If need to preserve accurate text position, embed the web font data within to the page and set `preserveFontStyle=false`. + * - Checkout the embed example: https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html * @param {Function} [callback] The callback to be invoked when export is ready. * @returns {string} dataURI * @example @@ -106,23 +241,26 @@ export default { * width: 800, * height: 600, * preserveAspectRatio: false, + * preserveFontStyle: false, * mimeType: "image/png" * }, * dataUrl => { ... } * ); */ - export(option?: ExportOption, callback?: (dataUrl: string) => void): string { + export(option?: TExportOption, callback?: (dataUrl: string) => void): string { const $$ = this.internal; - const {state, $el: {chart}} = $$; + const {state, $el: {chart, svg}} = $$; const {width, height} = state.current; const opt = mergeObj({ width, height, preserveAspectRatio: true, + preserveFontStyle: false, mimeType: "image/png" - }, option) as ExportOption; + }, option) as TExportOption; const svgDataUrl = nodeToSvgDataUrl(chart.node(), opt, {width, height}); + const glyph = opt.preserveFontStyle ? getGlyph(svg.node()) : []; if (callback && isFunction(callback)) { const img = new Image(); @@ -136,6 +274,13 @@ export default { canvas.height = opt.height || height; ctx.drawImage(img, 0, 0); + if (glyph.length) { + renderText(ctx, glyph); + + // release glyph array + glyph.length = 0; + } + callback.bind(this)(canvas.toDataURL(opt.mimeType)); }; diff --git a/src/ChartInternal/internals/grid.ts b/src/ChartInternal/internals/grid.ts index 3cdd4b81a..bbaaaafda 100644 --- a/src/ChartInternal/internals/grid.ts +++ b/src/ChartInternal/internals/grid.ts @@ -200,7 +200,7 @@ export default { .style("opacity", "0"); xgridLine.append("text") - .attr("transform", isRotated ? "" : "rotate(-90)") + .attr("transform", isRotated ? null : "rotate(-90)") .attr("dy", -5) .style("opacity", "0"); diff --git a/src/module/util.ts b/src/module/util.ts index 2e2bfa6d5..5906fcaae 100644 --- a/src/module/util.ts +++ b/src/module/util.ts @@ -497,7 +497,7 @@ function getCssRules(styleSheets: any[]) { rules = rules.concat(toArray(sheet.cssRules)); } } catch (e) { - console.error(`Error while reading rules from ${sheet.href}: ${e.toString()}`); + window.console?.warn(`Error while reading rules from ${sheet.href}: ${e.toString()}`); } }); diff --git a/test/api/export-spec.ts b/test/api/export-spec.ts index a3ecf0d7a..8281b5ed4 100644 --- a/test/api/export-spec.ts +++ b/test/api/export-spec.ts @@ -25,132 +25,230 @@ describe("API export", () => { chart = util.generate(args); }); + describe("Basic export functionalities", () => { + it("should invoke a callback when ready", done => { + function exportCallback(dataUrl) { + expect(dataUrl).to.not.be.equal(""); + done(); + } - it("should invoke a callback when ready", done => { - function exportCallback(dataUrl) { - expect(dataUrl).to.not.be.equal(""); - done(); - } - - expect(/^data:image\/svg\+xml;base64,.+/.test(chart.export())).to.be.true; - chart.export(null, exportCallback); - }); + expect(/^data:image\/svg\+xml;base64,.+/.test(chart.export())).to.be.true; + chart.export(null, exportCallback); + }); - it("should export chart as image/png", done => { - function exportCallback(dataUrl) { - const link: any = document.createElement("link"); + it("should export chart as image/png", done => { + function exportCallback(dataUrl) { + const link: any = document.createElement("link"); - link.download = `${Date.now}.png`; - link.href = dataUrl; - expect(link.getAttribute("href").length).to.be.not.equal(0); + link.download = `${Date.now}.png`; + link.href = dataUrl; + expect(link.getAttribute("href").length).to.be.not.equal(0); - done(); - } + done(); + } - chart.export({mimeType: "image/png"}, exportCallback); + chart.export({mimeType: "image/png"}, exportCallback); + }); + + it("should export in different size", done => { + const expectedDataURL = [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAJYCAYAAADxHswlAAA", + + // tails + "AAAAASUVORK5CYII=", + "XRLKx5vmwDQAAAABJRU5ErkJggg==", + + // for window test + "AAAAAElFTkSuQmCC" + ]; + + setTimeout(() => { + chart.export({ + width: 1000, height: 600 + }, data => { + expect( + expectedDataURL.map(v => data.indexOf(v) >= 0).filter(Boolean).length + ).to.be.equal(2); + + done(); + }); + }, 500); + }); + + it("should export in different aspectRatio", done => { + const expectedDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAEsCAYAAACG+vy+AAA"; + + setTimeout(() => { + chart.export({ + width: 200, height: 300, preserveAspectRatio: false + }, data => { + expect(data.indexOf(expectedDataURL) > -1).to.be.true; + + done(); + }); + }, 500); + }); + + it("set options", () => { + args = { + size: { + width: 35, + height: 35 + }, + data: { + columns: [ + ["data1", 2] + ] + }, + point: { + pattern: [ + "" + ] + }, + axis: { + x: { + show: false, + padding: { + left: 0, + right: 0 + } + }, + y: { + show: false, + min: 1, + max: 3, + padding: { + top: 0, + bottom: 0 + } + } + }, + legend: { + show: false + } + }; + }); + + it("should export custom points properly", done => { + const expectedDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAA"; + + setTimeout(() => { + chart.export(null, data => { + expect(data.indexOf(expectedDataURL) > -1).to.be.true; + done(); + }); + }, 500); + }); + + it("should export valid svg even with weird css", () => { + document.body.innerHTML += ``; + + const dataURL = chart.export(); + + // test generated svg + const svg = atob(dataURL.split("base64,")[1]); + const oParser = new DOMParser(); + const doc = oParser.parseFromString(svg, "image/svg+xml"); + + // check that it does not start with error message + expect(doc.documentElement.nodeName === "svg").to.be.true; + }); }); - it("should export in different size", done => { - const expectedDataURL = [ - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAJYCAYAAADxHswlAAA", - - // tails - "AAAAASUVORK5CYII=", - "XRLKx5vmwDQAAAABJRU5ErkJggg==", + describe("Additional functionalities", () => { + before(() => { + args = { + svg: { + classname: "export-preserve-font-style" + }, + data: { + columns: [ + ["data1", 30, 200, 100, 400, 150, 250], + ["data2", 5000, 2000, 1000, 4000, 1500, 2500] + ], + types: { + data1: "bar", + data2: "area" + }, + labels: true + }, + grid: { + x: { + lines: [ + { + value: 1, + text: "Label 1", + position: "middle" + }, + { + value: 3, + text: "Label 3" + } + ] + }, + y: { + lines: [ + { + value: 4000, + text: "Y Label 1" + } + ] + } + } + }; + }); - // for window test - "AAAAAElFTkSuQmCC" + const expected = [ + "P9CArwf2B9IIJoEKAAdimvjBKA5GoLnJZFJOuSu", + "oANQ+oASUgBJQAkpACSiBEiOgArDEHK7NVQJKQA", + "SWgBJTA/wPqQjRHnDx9rAAAAABJRU5ErkJggg==" ]; - setTimeout(() => { + it("check when 'preserveFontStyle=false'", done => { chart.export({ - width: 1000, height: 600 - }, data => { + preserveFontStyle: false + }, function(dataUrl) { expect( - expectedDataURL.map(v => data.indexOf(v) >= 0).filter(Boolean).length - ).to.be.equal(2); + expected.every(v => dataUrl.indexOf(v) == -1) + ).to.be.true; done(); }); - }, 500); - }); - - it("should export in different aspectRatio", done => { - const expectedDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAEsCAYAAACG+vy+AAA"; + }); - setTimeout(() => { - chart.export({ - width: 200, height: 300, preserveAspectRatio: false - }, data => { - expect(data.indexOf(expectedDataURL) > -1).to.be.true; - - done(); + it("check when 'preserveFontStyle=true'", done => { + const font = new FontFace("Alfa Slab One", "url(https://fonts.gstatic.com/s/alfaslabone/v17/6NUQ8FmMKwSEKjnm5-4v-4Jh2dJhe_escmA.woff2)", { + style: "normal", + weight: "400" }); - }, 500); - }); - - it("set options", () => { - args = { - size: { - width: 35, - height: 35 - }, - data: { - columns: [ - ["data1", 2] - ] - }, - point: { - pattern: [ - "" - ] - }, - axis: { - x: { - show: false, - padding: { - left: 0, - right: 0 - } - }, - y: { - show: false, - min: 1, - max: 3, - padding: { - top: 0, - bottom: 0 - } - } - }, - legend: { - show: false - } - }; - }); - - it("should export custom points properly", done => { - const expectedDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAA"; - - setTimeout(() => { - chart.export(null, data => { - expect(data.indexOf(expectedDataURL) > -1).to.be.true; - done(); - }); - }, 500); - }); - - it("should export valid svg even with weird css", () => { - document.body.innerHTML += ``; - - const dataURL = chart.export(); - - // test generated svg - const svg = atob(dataURL.split("base64,")[1]); - const oParser = new DOMParser(); - const doc = oParser.parseFromString(svg, "image/svg+xml"); - - // check that it does not start with error message - expect(doc.documentElement.nodeName === "svg").to.be.true; + + document.fonts.add(font); + font.load(); + + document.fonts.ready.then(() => { + chart.$.chart + .style("margin-left", "100px") + .style("padding-top", "50px"); + + chart.$.svg + .style("font-family", "Alfa Slab One"); + + chart.export({ + preserveFontStyle: true + }, function(dataUrl) { + //console.log(dataUrl) + + expect( + expected.every(v => dataUrl.indexOf(v) >= 0) + ).to.be.true; + + chart.$.chart + .style("margin-left", null) + .style("padding-top", null); + + done(); + }); + }); + }); }); }); diff --git a/types/chart.d.ts b/types/chart.d.ts index e5734ca8f..26de98a37 100644 --- a/types/chart.d.ts +++ b/types/chart.d.ts @@ -504,6 +504,12 @@ export interface Chart { * @param [option.width={currentWidth}] width * @param [option.height={currentHeigth}] height * @param [option.preserveAspectRatio=true] Preserve aspect ratio on given size + * @param [option.preserveFontStyle=false] Preserve font style(font-family). + * **NOTE:** + * - This option is useful when outlink web font style's `font-family` are applied to chart's text element. + * - Text element's position(especially "transformed") can't be preserved correctly according the page's layout condition. + * - If need to preserve accurate text position, embed the web font data within to the page and set `preserveFontStyle=false`. + * - Checkout the embed example: https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html * @param callback The callback to be invoked when export is ready. */ export(this: Chart, option?: { @@ -511,6 +517,7 @@ export interface Chart { height?: number; mimeType?: string; preserveAspectRatio?: boolean; + preserveFontStyle?: boolean; }, callback?: (this: Chart, dataUrl: string) => void): string; /** From 18aa4378f4e99358624f747cff5abef4c2f7d930 Mon Sep 17 00:00:00 2001 From: Jae Sung Park Date: Tue, 18 Oct 2022 22:02:38 +0900 Subject: [PATCH 2/3] skip: log dataURI --- test/api/export-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/export-spec.ts b/test/api/export-spec.ts index 8281b5ed4..b46aee33c 100644 --- a/test/api/export-spec.ts +++ b/test/api/export-spec.ts @@ -236,7 +236,7 @@ describe("API export", () => { chart.export({ preserveFontStyle: true }, function(dataUrl) { - //console.log(dataUrl) + console.log(dataUrl) expect( expected.every(v => dataUrl.indexOf(v) >= 0) From 0c102d38bc98a1a3f2595672fd2f3b961287859e Mon Sep 17 00:00:00 2001 From: Jae Sung Park Date: Tue, 18 Oct 2022 22:26:21 +0900 Subject: [PATCH 3/3] skip: fix test pattern --- test/api/export-spec.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/api/export-spec.ts b/test/api/export-spec.ts index b46aee33c..b60a9137c 100644 --- a/test/api/export-spec.ts +++ b/test/api/export-spec.ts @@ -199,9 +199,18 @@ describe("API export", () => { }); const expected = [ - "P9CArwf2B9IIJoEKAAdimvjBKA5GoLnJZFJOuSu", - "oANQ+oASUgBJQAkpACSiBEiOgArDEHK7NVQJKQA", - "SWgBJTA/wPqQjRHnDx9rAAAAABJRU5ErkJggg==" + // pattern for local + [ + "P9CArwf2B9IIJoEKAAdimvjBKA5GoLnJZFJOuSu", + "oANQ+oASUgBJQAkpACSiBEiOgArDEHK7NVQJKQA", + "SWgBJTA/wPqQjRHnDx9rAAAAABJRU5ErkJggg==" + ], + // pattern for CI + [ + "SR0IArs4c6QAAIABJREFUeF7snXmcXEXV/p9TdwI", + "ALxJBBVAaiTpCOq8cRzdkU0qkwmo8lkknMpovmj2", + "AAmQAAnESOD/AxjGOWWrWLsNAAAAAElFTkSuQmCC" + ] ]; it("check when 'preserveFontStyle=false'", done => { @@ -209,7 +218,7 @@ describe("API export", () => { preserveFontStyle: false }, function(dataUrl) { expect( - expected.every(v => dataUrl.indexOf(v) == -1) + expected.some(pttr => pttr.every(v => dataUrl.indexOf(v) == -1)) ).to.be.true; done(); @@ -236,10 +245,8 @@ describe("API export", () => { chart.export({ preserveFontStyle: true }, function(dataUrl) { - console.log(dataUrl) - expect( - expected.every(v => dataUrl.indexOf(v) >= 0) + expected.some(pttr => pttr.every(v => dataUrl.indexOf(v) >= 0)) ).to.be.true; chart.$.chart