Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): Enhance .export() to preserve fontStyle #2908

Merged
merged 3 commits into from
Oct 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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