Inline fonts for ForeignObjectRenderer

This commit is contained in:
Niklas von Hertzen 2017-10-18 20:34:17 +08:00
parent f16d581f04
commit 9445b0b598
12 changed files with 234 additions and 104 deletions

View File

@ -30,14 +30,14 @@
"base64-arraybuffer": "0.1.5",
"body-parser": "1.17.2",
"chai": "4.1.1",
"chromeless": "^1.2.0",
"chromeless": "1.2.0",
"cors": "2.8.4",
"eslint": "4.2.0",
"eslint-plugin-flowtype": "2.35.0",
"eslint-plugin-prettier": "2.1.2",
"express": "4.15.4",
"filenamify-url": "1.0.0",
"flow-bin": "0.50.0",
"flow-bin": "0.56.0",
"glob": "7.1.2",
"html2canvas-proxy": "1.0.0",
"jquery": "3.2.1",

View File

@ -3,11 +3,10 @@
import type {Bounds} from './Bounds';
import type {Options} from './index';
import type Logger from './Logger';
import type {ImageElement} from './ImageLoader';
import {parseBounds} from './Bounds';
import {Proxy} from './Proxy';
import ImageLoader from './ImageLoader';
import ResourceLoader from './ResourceLoader';
import {copyCSSStyles} from './Util';
import {parseBackgroundImage} from './parsing/background';
import CanvasRenderer from './renderer/CanvasRenderer';
@ -17,7 +16,7 @@ export class DocumentCloner {
referenceElement: HTMLElement;
clonedReferenceElement: HTMLElement;
documentElement: HTMLElement;
imageLoader: ImageLoader<*>;
resourceLoader: ResourceLoader;
logger: Logger;
options: Options;
inlineImages: boolean;
@ -38,7 +37,7 @@ export class DocumentCloner {
this.logger = logger;
this.options = options;
this.renderer = renderer;
this.imageLoader = new ImageLoader(options, logger, window);
this.resourceLoader = new ResourceLoader(options, logger, window);
// $FlowFixMe
this.documentElement = this.cloneNode(element.ownerDocument.documentElement);
}
@ -49,9 +48,14 @@ export class DocumentCloner {
Promise.all(
parseBackgroundImage(style.backgroundImage).map(backgroundImage => {
if (backgroundImage.method === 'url') {
return this.imageLoader
return this.resourceLoader
.inlineImage(backgroundImage.args[0])
.then(img => (img ? `url("${img.src}")` : 'none'))
.then(
img =>
img && typeof img.src === 'string'
? `url("${img.src}")`
: 'none'
)
.catch(e => {
if (__DEV__) {
this.logger.log(`Unable to load image`, e);
@ -73,7 +77,7 @@ export class DocumentCloner {
});
if (node instanceof HTMLImageElement) {
this.imageLoader
this.resourceLoader
.inlineImage(node.src)
.then(img => {
if (img && node instanceof HTMLImageElement && node.parentNode) {
@ -91,6 +95,56 @@ export class DocumentCloner {
}
}
inlineFonts(document: Document): Promise<void> {
return Promise.all(
Array.from(document.styleSheets).map(sheet => {
if (sheet.href) {
return fetch(sheet.href)
.then(res => res.text())
.then(text => createStyleSheetFontsFromText(text, sheet.href))
.catch(e => {
if (__DEV__) {
this.logger.log(`Unable to load stylesheet`, e);
}
return [];
});
}
return getSheetFonts(sheet, document);
})
)
.then(fonts => fonts.reduce((acc, font) => acc.concat(font), []))
.then(fonts =>
Promise.all(
fonts.map(font =>
fetch(font.formats[0].src)
.then(response => response.blob())
.then(
blob =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => {
// $FlowFixMe
const result: string = reader.result;
resolve(result);
};
reader.readAsDataURL(blob);
})
)
.then(dataUri => {
font.fontFace.setProperty('src', `url("${dataUri}")`);
return `@font-face {${font.fontFace.cssText} `;
})
)
)
)
.then(fontCss => {
const style = document.createElement('style');
style.textContent = fontCss.join('\n');
this.documentElement.appendChild(style);
});
}
createElementClone(node: Node) {
if (this.copyStyles && node instanceof HTMLCanvasElement) {
const img = node.ownerDocument.createElement('img');
@ -111,7 +165,7 @@ export class DocumentCloner {
const {width, height} = parseBounds(node, 0, 0);
this.imageLoader.cache[iframeKey] = getIframeDocumentElement(node, this.options)
this.resourceLoader.cache[iframeKey] = getIframeDocumentElement(node, this.options)
.then(documentElement => {
return this.renderer(
documentElement,
@ -211,6 +265,67 @@ export class DocumentCloner {
}
}
type Font = {
src: string,
format: string
};
type FontFamily = {
formats: Array<Font>,
fontFace: CSSStyleDeclaration
};
const getSheetFonts = (sheet: StyleSheet, document: Document): Array<FontFamily> => {
// $FlowFixMe
return (sheet.cssRules ? Array.from(sheet.cssRules) : [])
.filter(rule => rule.type === CSSRule.FONT_FACE_RULE)
.map(rule => {
const src = parseBackgroundImage(rule.style.getPropertyValue('src'));
const formats = [];
for (let i = 0; i < src.length; i++) {
if (src[i].method === 'url' && src[i + 1] && src[i + 1].method === 'format') {
const a = document.createElement('a');
a.href = src[i].args[0];
if (document.body) {
document.body.appendChild(a);
}
const font = {
src: a.href,
format: src[i + 1].args[0]
};
formats.push(font);
}
}
return {
// TODO select correct format for browser),
formats: formats.filter(font => /^woff/i.test(font.format)),
fontFace: rule.style
};
})
.filter(font => font.formats.length);
};
const createStyleSheetFontsFromText = (text: string, baseHref: string): Array<FontFamily> => {
const doc = document.implementation.createHTMLDocument('');
const base = document.createElement('base');
// $FlowFixMe
base.href = baseHref;
const style = document.createElement('style');
style.textContent = text;
if (doc.head) {
doc.head.appendChild(base);
}
if (doc.body) {
doc.body.appendChild(style);
}
return style.sheet ? getSheetFonts(style.sheet, doc) : [];
};
const restoreOwnerScroll = (ownerDocument: Document, x: number, y: number) => {
if (
ownerDocument.defaultView &&
@ -415,7 +530,7 @@ export const cloneWindow = (
options: Options,
logger: Logger,
renderer: (element: HTMLElement, options: Options, logger: Logger) => Promise<*>
): Promise<[HTMLIFrameElement, HTMLElement, ImageLoader<ImageElement>]> => {
): Promise<[HTMLIFrameElement, HTMLElement, ResourceLoader]> => {
const cloner = new DocumentCloner(referenceElement, options, logger, false, renderer);
const scrollX = ownerDocument.defaultView.pageXOffset;
const scrollY = ownerDocument.defaultView.pageYOffset;
@ -445,7 +560,7 @@ export const cloneWindow = (
? Promise.resolve([
cloneIframeContainer,
cloner.clonedReferenceElement,
cloner.imageLoader
cloner.resourceLoader
])
: Promise.reject(
__DEV__

View File

@ -146,7 +146,10 @@ const FEATURES = {
// $FlowFixMe - get/set properties not yet supported
get SUPPORT_FOREIGNOBJECT_DRAWING() {
'use strict';
const value = testForeignObject(document);
const value =
typeof Array.from === 'function' && typeof window.fetch === 'function'
? testForeignObject(document)
: Promise.resolve(false);
Object.defineProperty(FEATURES, 'SUPPORT_FOREIGNOBJECT_DRAWING', {value});
return value;
},

View File

@ -18,7 +18,7 @@ import type {Visibility} from './parsing/visibility';
import type {zIndex} from './parsing/zIndex';
import type {Bounds, BoundCurves} from './Bounds';
import type ImageLoader, {ImageElement} from './ImageLoader';
import type ResourceLoader, {ImageElement} from './ResourceLoader';
import type {Path} from './drawing/Path';
import type TextContainer from './TextContainer';
@ -87,7 +87,7 @@ export default class NodeContainer {
constructor(
node: HTMLElement | SVGSVGElement,
parent: ?NodeContainer,
imageLoader: ImageLoader<ImageElement>,
resourceLoader: ResourceLoader,
index: number
) {
this.parent = parent;
@ -104,7 +104,7 @@ export default class NodeContainer {
const position = parsePosition(style.position);
this.style = {
background: IS_INPUT ? INPUT_BACKGROUND : parseBackground(style, imageLoader),
background: IS_INPUT ? INPUT_BACKGROUND : parseBackground(style, resourceLoader),
border: IS_INPUT ? INPUT_BORDERS : parseBorder(style),
borderRadius:
(node instanceof defaultView.HTMLInputElement ||
@ -148,7 +148,7 @@ export default class NodeContainer {
);
});
}
this.image = getImage(node, imageLoader);
this.image = getImage(node, resourceLoader);
this.bounds = IS_INPUT
? reformatInputBounds(parseBounds(node, scrollX, scrollY))
: parseBounds(node, scrollX, scrollY);
@ -223,26 +223,25 @@ export default class NodeContainer {
}
}
const getImage = (
node: HTMLElement | SVGSVGElement,
imageLoader: ImageLoader<ImageElement>
): ?string => {
const getImage = (node: HTMLElement | SVGSVGElement, resourceLoader: ResourceLoader): ?string => {
if (
node instanceof node.ownerDocument.defaultView.SVGSVGElement ||
node instanceof SVGSVGElement
) {
const s = new XMLSerializer();
return imageLoader.loadImage(
return resourceLoader.loadImage(
`data:image/svg+xml,${encodeURIComponent(s.serializeToString(node))}`
);
}
switch (node.tagName) {
case 'IMG':
// $FlowFixMe
return imageLoader.loadImage(node.currentSrc || node.src);
const img: HTMLImageElement = node;
return resourceLoader.loadImage(img.currentSrc || img.src);
case 'CANVAS':
// $FlowFixMe
return imageLoader.loadCanvas(node);
const canvas: HTMLCanvasElement = node;
return resourceLoader.loadCanvas(canvas);
case 'IFRAME':
const iframeKey = node.getAttribute('data-html2canvas-internal-iframe-key');
if (iframeKey) {

View File

@ -1,6 +1,6 @@
/* @flow */
'use strict';
import type ImageLoader, {ImageElement} from './ImageLoader';
import type ResourceLoader, {ImageElement} from './ResourceLoader';
import type Logger from './Logger';
import StackingContext from './StackingContext';
import NodeContainer from './NodeContainer';
@ -9,7 +9,7 @@ import {inlineInputElement, inlineTextAreaElement, inlineSelectElement} from './
export const NodeParser = (
node: HTMLElement,
imageLoader: ImageLoader<ImageElement>,
resourceLoader: ResourceLoader,
logger: Logger
): StackingContext => {
if (__DEV__) {
@ -18,10 +18,10 @@ export const NodeParser = (
let index = 0;
const container = new NodeContainer(node, null, imageLoader, index++);
const container = new NodeContainer(node, null, resourceLoader, index++);
const stack = new StackingContext(container, null, true);
parseNodeTree(node, container, stack, imageLoader, index);
parseNodeTree(node, container, stack, resourceLoader, index);
if (__DEV__) {
logger.log(`Finished parsing node tree`);
@ -36,7 +36,7 @@ const parseNodeTree = (
node: HTMLElement,
parent: NodeContainer,
stack: StackingContext,
imageLoader: ImageLoader<ImageElement>,
resourceLoader: ResourceLoader,
index: number
): void => {
if (__DEV__ && index > 50000) {
@ -60,7 +60,7 @@ const parseNodeTree = (
(defaultView.parent && childNode instanceof defaultView.parent.HTMLElement)
) {
if (IGNORED_NODE_NAMES.indexOf(childNode.nodeName) === -1) {
const container = new NodeContainer(childNode, parent, imageLoader, index++);
const container = new NodeContainer(childNode, parent, resourceLoader, index++);
if (container.isVisible()) {
if (childNode.tagName === 'INPUT') {
// $FlowFixMe
@ -92,12 +92,12 @@ const parseNodeTree = (
);
parentStack.contexts.push(childStack);
if (SHOULD_TRAVERSE_CHILDREN) {
parseNodeTree(childNode, container, childStack, imageLoader, index);
parseNodeTree(childNode, container, childStack, resourceLoader, index);
}
} else {
stack.children.push(container);
if (SHOULD_TRAVERSE_CHILDREN) {
parseNodeTree(childNode, container, stack, imageLoader, index);
parseNodeTree(childNode, container, stack, resourceLoader, index);
}
}
}
@ -107,7 +107,7 @@ const parseNodeTree = (
childNode instanceof SVGSVGElement ||
(defaultView.parent && childNode instanceof defaultView.parent.SVGSVGElement)
) {
const container = new NodeContainer(childNode, parent, imageLoader, index++);
const container = new NodeContainer(childNode, parent, resourceLoader, index++);
const treatAsRealStackingContext = createsRealStackingContext(container, childNode);
if (treatAsRealStackingContext || createsStackingContext(container)) {
// for treatAsRealStackingContext:false, any positioned descendants and descendants

View File

@ -15,7 +15,7 @@ import type {Matrix} from './parsing/transform';
import type {BoundCurves} from './Bounds';
import type {Gradient} from './Gradient';
import type {ImageStore, ImageElement} from './ImageLoader';
import type {ResourceStore, ImageElement} from './ResourceLoader';
import type NodeContainer from './NodeContainer';
import type StackingContext from './StackingContext';
import type {TextBounds} from './TextBounds';
@ -43,7 +43,7 @@ import {BORDER_STYLE} from './parsing/border';
export type RenderOptions = {
scale: number,
backgroundColor: ?Color,
imageStore: ImageStore<ImageElement>,
imageStore: ResourceStore,
fontMetrics: FontMetrics,
logger: Logger,
x: number,

View File

@ -5,17 +5,17 @@ import type Options from './index';
import type Logger from './Logger';
export type ImageElement = Image | HTMLCanvasElement;
type ImageCache<T> = {[string]: Promise<T>};
export type Resource = ImageElement;
type ResourceCache = {[string]: Promise<Resource>};
import FEATURES from './Feature';
import {Proxy} from './Proxy';
// $FlowFixMe
export default class ImageLoader<T> {
export default class ResourceLoader {
origin: string;
options: Options;
_link: HTMLAnchorElement;
cache: ImageCache<T>;
cache: ResourceCache;
logger: Logger;
_index: number;
_window: WindowProxy;
@ -30,7 +30,7 @@ export default class ImageLoader<T> {
}
loadImage(src: string): ?string {
if (this.hasImageInCache(src)) {
if (this.hasResourceInCache(src)) {
return src;
}
@ -58,11 +58,11 @@ export default class ImageLoader<T> {
}
}
inlineImage(src: string): Promise<Image> {
inlineImage(src: string): Promise<Resource> {
if (isInlineImage(src)) {
return loadImage(src, this.options.imageTimeout || 0);
}
if (this.hasImageInCache(src)) {
if (this.hasResourceInCache(src)) {
return this.cache[src];
}
if (!this.isSameOrigin(src) && typeof this.options.proxy === 'string') {
@ -74,7 +74,7 @@ export default class ImageLoader<T> {
return this.xhrImage(src);
}
xhrImage(src: string): Promise<Image> {
xhrImage(src: string): Promise<Resource> {
this.cache[src] = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
@ -88,10 +88,16 @@ export default class ImageLoader<T> {
);
} else {
const reader = new FileReader();
// $FlowFixMe
reader.addEventListener('load', () => resolve(reader.result), false);
// $FlowFixMe
reader.addEventListener('error', e => reject(e), false);
reader.addEventListener(
'load',
() => {
// $FlowFixMe
const result: string = reader.result;
resolve(result);
},
false
);
reader.addEventListener('error', (e: Event) => reject(e), false);
reader.readAsDataURL(xhr.response);
}
}
@ -118,7 +124,7 @@ export default class ImageLoader<T> {
return key;
}
hasImageInCache(key: string): boolean {
hasResourceInCache(key: string): boolean {
return typeof this.cache[key] !== 'undefined';
}
@ -177,38 +183,37 @@ export default class ImageLoader<T> {
return link.protocol + link.hostname + link.port;
}
ready(): Promise<ImageStore<T>> {
const keys = Object.keys(this.cache);
return Promise.all(
keys.map(str =>
this.cache[str].catch(e => {
if (__DEV__) {
this.logger.log(`Unable to load image`, e);
}
return null;
})
)
).then(images => {
ready(): Promise<ResourceStore> {
const keys: Array<string> = Object.keys(this.cache);
const values: Array<Promise<?Resource>> = keys.map(str =>
this.cache[str].catch(e => {
if (__DEV__) {
this.logger.log(`Unable to load image`, e);
}
return null;
})
);
return Promise.all(values).then((images: Array<?Resource>) => {
if (__DEV__) {
this.logger.log(`Finished loading ${images.length} images`, images);
}
return new ImageStore(keys, images);
return new ResourceStore(keys, images);
});
}
}
export class ImageStore<T> {
export class ResourceStore {
_keys: Array<string>;
_images: Array<?T>;
_resources: Array<?Resource>;
constructor(keys: Array<string>, images: Array<?T>) {
constructor(keys: Array<string>, resources: Array<?Resource>) {
this._keys = keys;
this._images = images;
this._resources = resources;
}
get(key: string): ?T {
get(key: string): ?Resource {
const index = this._keys.indexOf(key);
return index === -1 ? null : this._images[index];
return index === -1 ? null : this._resources[index];
}
}
@ -222,7 +227,7 @@ const isInlineBase64Image = (src: string): boolean => INLINE_BASE64.test(src);
const isSVG = (src: string): boolean =>
src.substr(-3).toLowerCase() === 'svg' || INLINE_SVG.test(src);
const loadImage = (src: string, timeout: number) => {
const loadImage = (src: string, timeout: number): Promise<Image> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);

View File

@ -57,22 +57,25 @@ export const renderElement = (
logger.log(`Document cloned, using foreignObject rendering`);
}
return cloner.imageLoader.ready().then(() => {
const renderer = new ForeignObjectRenderer(cloner.clonedReferenceElement);
return renderer.render({
backgroundColor,
logger,
scale: options.scale,
x: options.x,
y: options.y,
width: options.width,
height: options.height,
windowWidth: options.windowWidth,
windowHeight: options.windowHeight,
scrollX: options.scrollX,
scrollY: options.scrollY
return cloner
.inlineFonts(ownerDocument)
.then(() => cloner.resourceLoader.ready())
.then(() => {
const renderer = new ForeignObjectRenderer(cloner.documentElement);
return renderer.render({
backgroundColor,
logger,
scale: options.scale,
x: options.x,
y: options.y,
width: options.width,
height: options.height,
windowWidth: options.windowWidth,
windowHeight: options.windowHeight,
scrollX: options.scrollX,
scrollY: options.scrollY
});
});
});
})(new DocumentCloner(element, options, logger, true, renderElement))
: cloneWindow(
ownerDocument,
@ -81,19 +84,19 @@ export const renderElement = (
options,
logger,
renderElement
).then(([container, clonedElement, imageLoader]) => {
).then(([container, clonedElement, resourceLoader]) => {
if (__DEV__) {
logger.log(`Document cloned, using computed rendering`);
}
const stack = NodeParser(clonedElement, imageLoader, logger);
const stack = NodeParser(clonedElement, resourceLoader, logger);
const clonedDocument = clonedElement.ownerDocument;
if (backgroundColor === stack.container.style.background.backgroundColor) {
stack.container.style.background.backgroundColor = TRANSPARENT;
}
return imageLoader.ready().then(imageStore => {
return resourceLoader.ready().then(imageStore => {
if (options.removeContainer === true) {
if (container.parentNode) {
container.parentNode.removeChild(container);

View File

@ -2,7 +2,7 @@
'use strict';
import type {Path} from '../drawing/Path';
import type {Bounds, BoundCurves} from '../Bounds';
import type ImageLoader, {ImageElement} from '../ImageLoader';
import type ResourceLoader, {ImageElement} from '../ResourceLoader';
import Color from '../Color';
import Length from '../Length';
@ -223,11 +223,11 @@ export const calculateBackgroundRepeatPath = (
export const parseBackground = (
style: CSSStyleDeclaration,
imageLoader: ImageLoader<ImageElement>
resourceLoader: ResourceLoader
): Background => {
return {
backgroundColor: new Color(style.backgroundColor),
backgroundImage: parseBackgroundImages(style, imageLoader),
backgroundImage: parseBackgroundImages(style, resourceLoader),
backgroundClip: parseBackgroundClip(style.backgroundClip),
backgroundOrigin: parseBackgroundOrigin(style.backgroundOrigin)
};
@ -276,13 +276,13 @@ const parseBackgroundRepeat = (backgroundRepeat: string): BackgroundRepeat => {
const parseBackgroundImages = (
style: CSSStyleDeclaration,
imageLoader: ImageLoader<ImageElement>
resourceLoader: ResourceLoader
): Array<BackgroundImage> => {
const sources: Array<BackgroundSource> = parseBackgroundImage(
style.backgroundImage
).map(backgroundImage => {
if (backgroundImage.method === 'url') {
const key = imageLoader.loadImage(backgroundImage.args[0]);
const key = resourceLoader.loadImage(backgroundImage.args[0]);
backgroundImage.args = key ? [key] : [];
}
return backgroundImage;

View File

@ -12,7 +12,7 @@ import type {TextShadow} from '../parsing/textShadow';
import type {Matrix} from '../parsing/transform';
import type {Bounds} from '../Bounds';
import type {ImageElement} from '../ImageLoader';
import type {ImageElement} from '../ResourceLoader';
import type {Gradient} from '../Gradient';
import type {TextBounds} from '../TextBounds';

View File

@ -12,29 +12,34 @@ export default class ForeignObjectRenderer {
this.options = options;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = Math.floor(options.width * options.scale);
this.canvas.height = Math.floor(options.height * options.scale);
this.canvas.width = Math.floor(options.width) * options.scale;
this.canvas.height = Math.floor(options.height) * options.scale;
this.canvas.style.width = `${options.width}px`;
this.canvas.style.height = `${options.height}px`;
this.ctx.scale(this.options.scale, this.options.scale);
options.logger.log(
`ForeignObject renderer initialized (${options.width}x${options.height} at ${options.x},${options.y}) with scale ${this
.options.scale}`
`ForeignObject renderer initialized (${options.width}x${options.height} at ${options.x},${options.y}) with scale ${options.scale}`
);
const svg = createForeignObjectSVG(
Math.max(options.windowWidth, options.width),
Math.max(options.windowHeight, options.height),
options.scrollX,
options.scrollY,
Math.max(options.windowWidth, options.width) * options.scale,
Math.max(options.windowHeight, options.height) * options.scale,
options.scrollX * options.scale,
options.scrollY * options.scale,
this.element
);
return loadSerializedSVG(svg).then(img => {
if (options.backgroundColor) {
this.ctx.fillStyle = options.backgroundColor.toString();
this.ctx.fillRect(0, 0, options.width, options.height);
this.ctx.fillRect(
0,
0,
options.width * options.scale,
options.height * options.scale
);
}
this.ctx.drawImage(img, -options.x, -options.y);
this.ctx.drawImage(img, -options.x * options.scale, -options.y * options.scale);
return this.canvas;
});
}

View File

@ -17,7 +17,7 @@ import type {TextShadow} from '../parsing/textShadow';
import type {Matrix} from '../parsing/transform';
import type {Bounds} from '../Bounds';
import type {ImageElement} from '../ImageLoader';
import type {ImageElement} from '../ResourceLoader';
import type {Gradient} from '../Gradient';
import type {TextBounds} from '../TextBounds';