Skip to content

Commit

Permalink
feat(api): Enhance .export() to preserve fontStyle
Browse files Browse the repository at this point in the history
Enhance to preserve outlink applied font-family on export.

Close #2892
  • Loading branch information
netil authored Oct 18, 2022
1 parent 4818e45 commit 5d1944e
Show file tree
Hide file tree
Showing 8 changed files with 473 additions and 118 deletions.
7 changes: 7 additions & 0 deletions config/jsdoc/static/doc.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,10 @@ h4[id$='$'] > span:first-child {
.details p {
margin: 0;
}

.experimental:before {
content: "EXPERIMENTAL";
color: #fff;
background-color: red;
padding: 2px;
}
79 changes: 79 additions & 0 deletions demo/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
12 changes: 12 additions & 0 deletions demo/tomorrow.css
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
173 changes: 159 additions & 14 deletions src/Chart/api/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`)))
);
Expand All @@ -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
Expand All @@ -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(<a href="https://msdn.microsoft.com/en-us/library/hh834675(v=vs.85).aspx">foreignObject</a>) 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
Expand All @@ -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).<br>
* **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: <a href="https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html">https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html</a>
* @param {Function} [callback] The callback to be invoked when export is ready.
* @returns {string} dataURI
* @example
Expand All @@ -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();
Expand All @@ -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));
};

Expand Down
2 changes: 1 addition & 1 deletion src/ChartInternal/internals/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
2 changes: 1 addition & 1 deletion src/module/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`);
}
});

Expand Down
Loading

0 comments on commit 5d1944e

Please sign in to comment.