Implement foreignObject renderer

This commit is contained in:
Niklas von Hertzen 2017-08-17 23:14:44 +08:00
parent 26cdc0441b
commit a73dbf8067
10 changed files with 457 additions and 225 deletions

View File

@ -7,3 +7,5 @@ declare class SVGSVGElement extends Element {
getPresentationAttribute(name: string): any; getPresentationAttribute(name: string): any;
} }
declare class HTMLBodyElement extends HTMLElement {}

View File

@ -2,6 +2,127 @@
'use strict'; 'use strict';
import type {Bounds} from './Bounds'; import type {Bounds} from './Bounds';
import type {Options} from './index'; import type {Options} from './index';
import type Logger from './Logger';
import ImageLoader from './ImageLoader';
import {copyCSSStyles} from './Util';
import {parseBackgroundImage} from './parsing/background';
export class DocumentCloner {
scrolledElements: Array<[HTMLElement, number, number]>;
referenceElement: HTMLElement;
clonedReferenceElement: HTMLElement;
documentElement: HTMLElement;
imageLoader: ImageLoader<string>;
logger: Logger;
inlineImages: boolean;
copyStyles: boolean;
constructor(element: HTMLElement, options: Options, logger: Logger) {
this.referenceElement = element;
this.scrolledElements = [];
this.copyStyles = true;
this.inlineImages = true;
this.logger = logger;
this.imageLoader = new ImageLoader(options, logger, window);
// $FlowFixMe
this.documentElement = this.cloneNode(element.ownerDocument.documentElement);
}
inlineAllImages(node: ?HTMLElement) {
if (this.inlineImages && node) {
const style = node.style;
Promise.all(
parseBackgroundImage(style.backgroundImage).map(backgroundImage => {
if (backgroundImage.method === 'url') {
return this.imageLoader
.inlineImage(backgroundImage.args[0])
.then(src => (src ? `url("${src}")` : 'none'));
}
return Promise.resolve(
`${backgroundImage.prefix}${backgroundImage.method}(${backgroundImage.args.join(
','
)})`
);
})
).then(backgroundImages => {
if (backgroundImages.length > 1) {
// TODO Multiple backgrounds somehow broken in Chrome
style.backgroundColor = '';
}
style.backgroundImage = backgroundImages.join(',');
});
if (node instanceof HTMLImageElement) {
this.imageLoader.inlineImage(node.src).then(src => {
if (src && node instanceof HTMLImageElement) {
node.src = src;
}
});
}
}
}
createElementClone(node: Node) {
if (this.copyStyles && node instanceof HTMLCanvasElement) {
const img = node.ownerDocument.createElement('img');
try {
img.src = node.toDataURL();
return img;
} catch (e) {
this.logger.log(`Unable to clone canvas contents, canvas is tainted`);
}
}
return node.cloneNode(false);
}
cloneNode(node: Node): Node {
const clone =
node.nodeType === Node.TEXT_NODE
? document.createTextNode(node.nodeValue)
: this.createElementClone(node);
if (this.referenceElement === node && clone instanceof HTMLElement) {
this.clonedReferenceElement = clone;
}
if (clone instanceof HTMLBodyElement) {
createPseudoHideStyles(clone);
}
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.nodeType !== Node.ELEMENT_NODE || child.nodeName !== 'SCRIPT') {
clone.appendChild(this.cloneNode(child));
}
}
if (node instanceof HTMLElement && clone instanceof HTMLElement) {
this.inlineAllImages(inlinePseudoElement(node, clone, PSEUDO_BEFORE));
this.inlineAllImages(inlinePseudoElement(node, clone, PSEUDO_AFTER));
if (this.copyStyles) {
copyCSSStyles(node.ownerDocument.defaultView.getComputedStyle(node), clone);
}
this.inlineAllImages(clone);
if (node.scrollTop !== 0 || node.scrollLeft !== 0) {
this.scrolledElements.push([node, node.scrollLeft, node.scrollTop]);
}
switch (node.nodeName) {
case 'CANVAS':
if (!this.copyStyles) {
// $FlowFixMe
cloneCanvasContents(node, clone);
}
break;
case 'TEXTAREA':
case 'SELECT':
// $FlowFixMe
clone.value = node.value;
break;
}
}
return clone;
}
}
const restoreOwnerScroll = (ownerDocument: Document, x: number, y: number) => { const restoreOwnerScroll = (ownerDocument: Document, x: number, y: number) => {
if ( if (
@ -28,44 +149,80 @@ const cloneCanvasContents = (canvas: HTMLCanvasElement, clonedCanvas: HTMLCanvas
} catch (e) {} } catch (e) {}
}; };
const cloneNode = ( const inlinePseudoElement = (
node: Node, node: HTMLElement,
referenceElement: [HTMLElement, ?HTMLElement], clone: HTMLElement,
scrolledElements: Array<[HTMLElement, number, number]> pseudoElt: ':before' | ':after'
) => { ): ?HTMLElement => {
const clone = const style = node.ownerDocument.defaultView.getComputedStyle(node, pseudoElt);
node.nodeType === Node.TEXT_NODE if (
? document.createTextNode(node.nodeValue) !style ||
: node.cloneNode(false); !style.content ||
style.content === 'none' ||
if (referenceElement[0] === node && clone instanceof HTMLElement) { style.content === '-moz-alt-content' ||
referenceElement[1] = clone; style.display === 'none'
) {
return;
} }
for (let child = node.firstChild; child; child = child.nextSibling) { const content = stripQuotes(style.content);
if (child.nodeType !== Node.ELEMENT_NODE || child.nodeName !== 'SCRIPT') { const image = content.match(URL_REGEXP);
clone.appendChild(cloneNode(child, referenceElement, scrolledElements)); const anonymousReplacedElement = clone.ownerDocument.createElement(
} image ? 'img' : 'html2canvaspseudoelement'
);
if (image) {
// $FlowFixMe
anonymousReplacedElement.src = stripQuotes(image[1]);
} else {
anonymousReplacedElement.textContent = content;
} }
if (node instanceof HTMLElement) { copyCSSStyles(style, anonymousReplacedElement);
if (node.scrollTop !== 0 || node.scrollLeft !== 0) {
scrolledElements.push([node, node.scrollLeft, node.scrollTop]); anonymousReplacedElement.className = `${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE} ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`;
} clone.className +=
switch (node.nodeName) { pseudoElt === PSEUDO_BEFORE
case 'CANVAS': ? ` ${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}`
// $FlowFixMe : ` ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`;
cloneCanvasContents(node, clone); if (pseudoElt === PSEUDO_BEFORE) {
break; clone.insertBefore(anonymousReplacedElement, clone.firstChild);
case 'TEXTAREA': } else {
case 'SELECT': clone.appendChild(anonymousReplacedElement);
// $FlowFixMe
clone.value = node.value;
break;
}
} }
return clone; return anonymousReplacedElement;
};
const stripQuotes = (content: string): string => {
const first = content.substr(0, 1);
return first === content.substr(content.length - 1) && first.match(/['"]/)
? content.substr(1, content.length - 2)
: content;
};
const URL_REGEXP = /^url\((.+)\)$/i;
const PSEUDO_BEFORE = ':before';
const PSEUDO_AFTER = ':after';
const PSEUDO_HIDE_ELEMENT_CLASS_BEFORE = '___html2canvas___pseudoelement_before';
const PSEUDO_HIDE_ELEMENT_CLASS_AFTER = '___html2canvas___pseudoelement_after';
const PSEUDO_HIDE_ELEMENT_STYLE = `{
content: "" !important;
display: none !important;
}`;
const createPseudoHideStyles = (body: HTMLElement) => {
createStyles(
body,
`.${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}${PSEUDO_BEFORE}${PSEUDO_HIDE_ELEMENT_STYLE}
.${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}${PSEUDO_AFTER}${PSEUDO_HIDE_ELEMENT_STYLE}`
);
};
const createStyles = (body: HTMLElement, styles) => {
const style = body.ownerDocument.createElement('style');
style.innerHTML = styles;
body.appendChild(style);
}; };
const initNode = ([element, x, y]: [HTMLElement, number, number]) => { const initNode = ([element, x, y]: [HTMLElement, number, number]) => {
@ -74,24 +231,13 @@ const initNode = ([element, x, y]: [HTMLElement, number, number]) => {
}; };
export const cloneWindow = ( export const cloneWindow = (
documentToBeCloned: Document,
ownerDocument: Document, ownerDocument: Document,
bounds: Bounds, bounds: Bounds,
referenceElement: HTMLElement, referenceElement: HTMLElement,
options: Options options: Options,
logger: Logger
): Promise<[HTMLIFrameElement, HTMLElement]> => { ): Promise<[HTMLIFrameElement, HTMLElement]> => {
const scrolledElements = []; const cloner = new DocumentCloner(referenceElement, options, logger);
const referenceElementSearch = [referenceElement, null];
if (!(documentToBeCloned.documentElement instanceof HTMLElement)) {
return Promise.reject(__DEV__ ? `Invalid document provided for cloning` : '');
}
const documentElement = cloneNode(
documentToBeCloned.documentElement,
referenceElementSearch,
scrolledElements
);
const cloneIframeContainer = ownerDocument.createElement('iframe'); const cloneIframeContainer = ownerDocument.createElement('iframe');
cloneIframeContainer.className = 'html2canvas-container'; cloneIframeContainer.className = 'html2canvas-container';
@ -120,7 +266,7 @@ export const cloneWindow = (
cloneWindow.onload = cloneIframeContainer.onload = () => { cloneWindow.onload = cloneIframeContainer.onload = () => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (documentClone.body.childNodes.length > 0) { if (documentClone.body.childNodes.length > 0) {
scrolledElements.forEach(initNode); cloner.scrolledElements.forEach(initNode);
clearInterval(interval); clearInterval(interval);
if (options.type === 'view') { if (options.type === 'view') {
cloneWindow.scrollTo(bounds.left, bounds.top); cloneWindow.scrollTo(bounds.left, bounds.top);
@ -135,10 +281,10 @@ export const cloneWindow = (
} }
} }
if ( if (
referenceElementSearch[1] instanceof cloneWindow.HTMLElement || cloner.clonedReferenceElement instanceof cloneWindow.HTMLElement ||
referenceElementSearch[1] instanceof HTMLElement cloner.clonedReferenceElement instanceof HTMLElement
) { ) {
resolve([cloneIframeContainer, referenceElementSearch[1]]); resolve([cloneIframeContainer, cloner.clonedReferenceElement]);
} else { } else {
reject( reject(
__DEV__ __DEV__
@ -153,9 +299,9 @@ export const cloneWindow = (
documentClone.open(); documentClone.open();
documentClone.write('<!DOCTYPE html><html></html>'); documentClone.write('<!DOCTYPE html><html></html>');
// Chrome scrolls the parent document for some reason after the write to the cloned window??? // Chrome scrolls the parent document for some reason after the write to the cloned window???
restoreOwnerScroll(documentToBeCloned, bounds.left, bounds.top); restoreOwnerScroll(referenceElement.ownerDocument, bounds.left, bounds.top);
documentClone.replaceChild( documentClone.replaceChild(
documentClone.adoptNode(documentElement), documentClone.adoptNode(cloner.documentElement),
documentClone.documentElement documentClone.documentElement
); );
documentClone.close(); documentClone.close();

View File

@ -71,6 +71,34 @@ const testSVG = document => {
return true; return true;
}; };
const testForeignObject = document => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
img.src = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><foreignObject><div></div></foreignObject></svg>`;
return new Promise(resolve => {
const onload = () => {
try {
ctx.drawImage(img, 0, 0);
canvas.toDataURL();
} catch (e) {
return resolve(false);
}
return resolve(true);
};
img.onload = onload;
if (img.complete === true) {
setTimeout(() => {
onload();
}, 50);
}
});
};
const FEATURES = { const FEATURES = {
// $FlowFixMe - get/set properties not yet supported // $FlowFixMe - get/set properties not yet supported
get SUPPORT_RANGE_BOUNDS() { get SUPPORT_RANGE_BOUNDS() {
@ -94,6 +122,13 @@ const FEATURES = {
Object.defineProperty(FEATURES, 'SUPPORT_BASE64_DRAWING', {value: () => value}); Object.defineProperty(FEATURES, 'SUPPORT_BASE64_DRAWING', {value: () => value});
return value; return value;
}; };
},
// $FlowFixMe - get/set properties not yet supported
get SUPPORT_FOREIGNOBJECT_DRAWING() {
'use strict';
const value = testForeignObject(document);
Object.defineProperty(FEATURES, 'SUPPORT_FOREIGNOBJECT_DRAWING', {value});
return value;
} }
}; };

View File

@ -5,15 +5,16 @@ import type Options from './index';
import type Logger from './Logger'; import type Logger from './Logger';
export type ImageElement = Image | HTMLCanvasElement; export type ImageElement = Image | HTMLCanvasElement;
type ImageCache = {[string]: Promise<?ImageElement>}; type ImageCache<T> = {[string]: Promise<T>};
import FEATURES from './Feature'; import FEATURES from './Feature';
export default class ImageLoader { // $FlowFixMe
export default class ImageLoader<T> {
origin: string; origin: string;
options: Options; options: Options;
_link: HTMLAnchorElement; _link: HTMLAnchorElement;
cache: ImageCache; cache: ImageCache<T>;
logger: Logger; logger: Logger;
_index: number; _index: number;
_window: WindowProxy; _window: WindowProxy;
@ -45,6 +46,46 @@ export default class ImageLoader {
} }
} }
inlineImage(src: string): Promise<string> {
if (isInlineImage(src)) {
return Promise.resolve(src);
}
if (this.hasImageInCache(src)) {
return this.cache[src];
}
// TODO proxy
return this.xhrImage(src);
}
xhrImage(src: string): Promise<string> {
this.cache[src] = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status !== 200) {
reject(`Failed to fetch image ${src} with status code ${xhr.status}`);
} else {
const reader = new FileReader();
// $FlowFixMe
reader.addEventListener('load', () => resolve(reader.result), false);
// $FlowFixMe
reader.addEventListener('error', e => reject(e), false);
reader.readAsDataURL(xhr.response);
}
}
};
xhr.ontimeout = () => reject(`Timed out fetching ${src}`);
xhr.responseType = 'blob';
if (this.options.imageTimeout) {
xhr.timeout = this.options.imageTimeout;
}
xhr.open('GET', src, true);
xhr.send();
});
return this.cache[src];
}
loadCanvas(node: HTMLCanvasElement): string { loadCanvas(node: HTMLCanvasElement): string {
const key = String(this._index++); const key = String(this._index++);
this.cache[key] = Promise.resolve(node); this.cache[key] = Promise.resolve(node);
@ -60,8 +101,8 @@ export default class ImageLoader {
this.logger.log(`Added image ${key.substring(0, 256)}`); this.logger.log(`Added image ${key.substring(0, 256)}`);
} }
const imageLoadHandler = (supportsDataImages: boolean): Promise<Image> => { const imageLoadHandler = (supportsDataImages: boolean): Promise<Image> =>
return new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
//ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous //ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
@ -78,7 +119,6 @@ export default class ImageLoader {
}, 500); }, 500);
} }
}); });
};
this.cache[key] = this.cache[key] =
isInlineImage(src) && !isSVG(src) isInlineImage(src) && !isSVG(src)
@ -99,7 +139,7 @@ export default class ImageLoader {
return link.protocol + link.hostname + link.port; return link.protocol + link.hostname + link.port;
} }
ready(): Promise<ImageStore> { ready(): Promise<ImageStore<T>> {
const keys = Object.keys(this.cache); const keys = Object.keys(this.cache);
return Promise.all( return Promise.all(
keys.map(str => keys.map(str =>
@ -112,23 +152,23 @@ export default class ImageLoader {
) )
).then(images => { ).then(images => {
if (__DEV__) { if (__DEV__) {
this.logger.log('Finished loading images', images); this.logger.log(`Finished loading ${images.length} images`, images);
} }
return new ImageStore(keys, images); return new ImageStore(keys, images);
}); });
} }
} }
export class ImageStore { export class ImageStore<T> {
_keys: Array<string>; _keys: Array<string>;
_images: Array<?ImageElement>; _images: Array<?T>;
constructor(keys: Array<string>, images: Array<?ImageElement>) { constructor(keys: Array<string>, images: Array<?T>) {
this._keys = keys; this._keys = keys;
this._images = images; this._images = images;
} }
get(key: string): ?ImageElement { get(key: string): ?T {
const index = this._keys.indexOf(key); const index = this._keys.indexOf(key);
return index === -1 ? null : this._images[index]; return index === -1 ? null : this._images[index];
} }

View File

@ -18,7 +18,7 @@ import type {Visibility} from './parsing/visibility';
import type {zIndex} from './parsing/zIndex'; import type {zIndex} from './parsing/zIndex';
import type {Bounds, BoundCurves} from './Bounds'; import type {Bounds, BoundCurves} from './Bounds';
import type ImageLoader from './ImageLoader'; import type ImageLoader, {ImageElement} from './ImageLoader';
import type {Path} from './drawing/Path'; import type {Path} from './drawing/Path';
import type TextContainer from './TextContainer'; import type TextContainer from './TextContainer';
@ -87,7 +87,7 @@ export default class NodeContainer {
constructor( constructor(
node: HTMLElement | SVGSVGElement, node: HTMLElement | SVGSVGElement,
parent: ?NodeContainer, parent: ?NodeContainer,
imageLoader: ImageLoader, imageLoader: ImageLoader<ImageElement>,
index: number index: number
) { ) {
this.parent = parent; this.parent = parent;
@ -219,7 +219,10 @@ export default class NodeContainer {
} }
} }
const getImage = (node: HTMLElement | SVGSVGElement, imageLoader: ImageLoader): ?string => { const getImage = (
node: HTMLElement | SVGSVGElement,
imageLoader: ImageLoader<ImageElement>
): ?string => {
if ( if (
node instanceof node.ownerDocument.defaultView.SVGSVGElement || node instanceof node.ownerDocument.defaultView.SVGSVGElement ||
node instanceof SVGSVGElement node instanceof SVGSVGElement

View File

@ -1,16 +1,15 @@
/* @flow */ /* @flow */
'use strict'; 'use strict';
import type ImageLoader from './ImageLoader'; import type ImageLoader, {ImageElement} from './ImageLoader';
import type Logger from './Logger'; import type Logger from './Logger';
import StackingContext from './StackingContext'; import StackingContext from './StackingContext';
import NodeContainer from './NodeContainer'; import NodeContainer from './NodeContainer';
import TextContainer from './TextContainer'; import TextContainer from './TextContainer';
import {inlineInputElement, inlineTextAreaElement, inlineSelectElement} from './Input'; import {inlineInputElement, inlineTextAreaElement, inlineSelectElement} from './Input';
import {copyCSSStyles} from './Util';
export const NodeParser = ( export const NodeParser = (
node: HTMLElement, node: HTMLElement,
imageLoader: ImageLoader, imageLoader: ImageLoader<ImageElement>,
logger: Logger logger: Logger
): StackingContext => { ): StackingContext => {
if (__DEV__) { if (__DEV__) {
@ -22,7 +21,6 @@ export const NodeParser = (
const container = new NodeContainer(node, null, imageLoader, index++); const container = new NodeContainer(node, null, imageLoader, index++);
const stack = new StackingContext(container, null, true); const stack = new StackingContext(container, null, true);
createPseudoHideStyles(node.ownerDocument);
parseNodeTree(node, container, stack, imageLoader, index); parseNodeTree(node, container, stack, imageLoader, index);
if (__DEV__) { if (__DEV__) {
@ -33,17 +31,12 @@ export const NodeParser = (
}; };
const IGNORED_NODE_NAMES = ['SCRIPT', 'HEAD', 'TITLE', 'OBJECT', 'BR', 'OPTION']; const IGNORED_NODE_NAMES = ['SCRIPT', 'HEAD', 'TITLE', 'OBJECT', 'BR', 'OPTION'];
const URL_REGEXP = /^url\((.+)\)$/i;
const PSEUDO_BEFORE = ':before';
const PSEUDO_AFTER = ':after';
const PSEUDO_HIDE_ELEMENT_CLASS_BEFORE = '___html2canvas___pseudoelement_before';
const PSEUDO_HIDE_ELEMENT_CLASS_AFTER = '___html2canvas___pseudoelement_after';
const parseNodeTree = ( const parseNodeTree = (
node: HTMLElement, node: HTMLElement,
parent: NodeContainer, parent: NodeContainer,
stack: StackingContext, stack: StackingContext,
imageLoader: ImageLoader, imageLoader: ImageLoader<ImageElement>,
index: number index: number
): void => { ): void => {
if (__DEV__ && index > 50000) { if (__DEV__ && index > 50000) {
@ -62,8 +55,6 @@ const parseNodeTree = (
childNode instanceof HTMLElement childNode instanceof HTMLElement
) { ) {
if (IGNORED_NODE_NAMES.indexOf(childNode.nodeName) === -1) { if (IGNORED_NODE_NAMES.indexOf(childNode.nodeName) === -1) {
inlinePseudoElement(childNode, PSEUDO_BEFORE);
inlinePseudoElement(childNode, PSEUDO_AFTER);
const container = new NodeContainer(childNode, parent, imageLoader, index++); const container = new NodeContainer(childNode, parent, imageLoader, index++);
if (container.isVisible()) { if (container.isVisible()) {
if (childNode.tagName === 'INPUT') { if (childNode.tagName === 'INPUT') {
@ -132,44 +123,6 @@ const parseNodeTree = (
} }
}; };
const inlinePseudoElement = (node: HTMLElement, pseudoElt: ':before' | ':after'): void => {
const style = node.ownerDocument.defaultView.getComputedStyle(node, pseudoElt);
if (
!style ||
!style.content ||
style.content === 'none' ||
style.content === '-moz-alt-content' ||
style.display === 'none'
) {
return;
}
const content = stripQuotes(style.content);
const image = content.match(URL_REGEXP);
const anonymousReplacedElement = node.ownerDocument.createElement(
image ? 'img' : 'html2canvaspseudoelement'
);
if (image) {
// $FlowFixMe
anonymousReplacedElement.src = stripQuotes(image[1]);
} else {
anonymousReplacedElement.textContent = content;
}
copyCSSStyles(style, anonymousReplacedElement);
anonymousReplacedElement.className = `${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE} ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`;
node.className +=
pseudoElt === PSEUDO_BEFORE
? ` ${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}`
: ` ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`;
if (pseudoElt === PSEUDO_BEFORE) {
node.insertBefore(anonymousReplacedElement, node.firstChild);
} else {
node.appendChild(anonymousReplacedElement);
}
};
const createsRealStackingContext = ( const createsRealStackingContext = (
container: NodeContainer, container: NodeContainer,
node: HTMLElement | SVGSVGElement node: HTMLElement | SVGSVGElement
@ -197,31 +150,3 @@ const isBodyWithTransparentRoot = (
container.parent.style.background.backgroundColor.isTransparent() container.parent.style.background.backgroundColor.isTransparent()
); );
}; };
const stripQuotes = (content: string): string => {
const first = content.substr(0, 1);
return first === content.substr(content.length - 1) && first.match(/['"]/)
? content.substr(1, content.length - 2)
: content;
};
const PSEUDO_HIDE_ELEMENT_STYLE = `{
content: "" !important;
display: none !important;
}`;
const createPseudoHideStyles = (document: Document) => {
createStyles(
document,
`.${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}${PSEUDO_BEFORE}${PSEUDO_HIDE_ELEMENT_STYLE}
.${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}${PSEUDO_AFTER}${PSEUDO_HIDE_ELEMENT_STYLE}`
);
};
const createStyles = (document: Document, styles) => {
const style = document.createElement('style');
style.innerHTML = styles;
if (document.body) {
document.body.appendChild(style);
}
};

View File

@ -43,7 +43,7 @@ import {BORDER_STYLE} from './parsing/border';
export type RenderOptions = { export type RenderOptions = {
scale: number, scale: number,
backgroundColor: ?Color, backgroundColor: ?Color,
imageStore: ImageStore, imageStore: ImageStore<ImageElement>,
fontMetrics: FontMetrics, fontMetrics: FontMetrics,
logger: Logger, logger: Logger,
width: number, width: number,

View File

@ -5,13 +5,15 @@ import type {RenderTarget} from './Renderer';
import {NodeParser} from './NodeParser'; import {NodeParser} from './NodeParser';
import Renderer from './Renderer'; import Renderer from './Renderer';
import ForeignObjectRenderer from './renderer/ForeignObjectRenderer';
import CanvasRenderer from './renderer/CanvasRenderer'; import CanvasRenderer from './renderer/CanvasRenderer';
import Logger from './Logger'; import Logger from './Logger';
import ImageLoader from './ImageLoader'; import ImageLoader from './ImageLoader';
import Feature from './Feature';
import {Bounds, parseDocumentSize} from './Bounds'; import {Bounds, parseDocumentSize} from './Bounds';
import {cloneWindow} from './Clone'; import {cloneWindow, DocumentCloner} from './Clone';
import Color, {TRANSPARENT} from './Color';
import {FontMetrics} from './Font'; import {FontMetrics} from './Font';
import Color, {TRANSPARENT} from './Color';
export type Options = { export type Options = {
async: ?boolean, async: ?boolean,
@ -42,7 +44,7 @@ const html2canvas = (element: HTMLElement, config: Options): Promise<*> => {
const defaultOptions = { const defaultOptions = {
async: true, async: true,
allowTaint: false, allowTaint: false,
imageTimeout: 10000, imageTimeout: 15000,
proxy: null, proxy: null,
removeContainer: true, removeContainer: true,
scale: defaultView.devicePixelRatio || 1, scale: defaultView.devicePixelRatio || 1,
@ -63,79 +65,105 @@ const html2canvas = (element: HTMLElement, config: Options): Promise<*> => {
options.windowHeight options.windowHeight
); );
const result = cloneWindow( const bounds = options.type === 'view' ? windowBounds : parseDocumentSize(ownerDocument);
ownerDocument,
ownerDocument,
windowBounds,
element,
options
).then(([container, clonedElement]) => {
if (__DEV__) {
logger.log(`Document cloned`);
}
const imageLoader = new ImageLoader( // http://www.w3.org/TR/css3-background/#special-backgrounds
options, const documentBackgroundColor = ownerDocument.documentElement
logger, ? new Color(getComputedStyle(ownerDocument.documentElement).backgroundColor)
clonedElement.ownerDocument.defaultView : TRANSPARENT;
); const backgroundColor =
const stack = NodeParser(clonedElement, imageLoader, logger); element === ownerDocument.documentElement
const clonedDocument = clonedElement.ownerDocument; ? documentBackgroundColor.isTransparent()
const size = options.type === 'view' ? windowBounds : parseDocumentSize(clonedDocument); ? ownerDocument.body
const width = size.width; ? new Color(getComputedStyle(ownerDocument.body).backgroundColor)
const height = size.height; : null
: documentBackgroundColor
: null;
// http://www.w3.org/TR/css3-background/#special-backgrounds // $FlowFixMe
const backgroundColor = const result = Feature.SUPPORT_FOREIGNOBJECT_DRAWING.then(
clonedElement === clonedDocument.documentElement supportForeignObject =>
? stack.container.style.background.backgroundColor.isTransparent() supportForeignObject
? clonedDocument.body ? (cloner => {
? new Color(getComputedStyle(clonedDocument.body).backgroundColor) if (__DEV__) {
: null logger.log(`Document cloned, using foreignObject rendering`);
: stack.container.style.background.backgroundColor }
: null;
if (backgroundColor === stack.container.style.background.backgroundColor) { return cloner.imageLoader.ready().then(() => {
stack.container.style.background.backgroundColor = TRANSPARENT; const renderer = new ForeignObjectRenderer(cloner.clonedReferenceElement);
} return renderer.render({
bounds,
backgroundColor,
logger,
scale: options.scale
});
});
})(new DocumentCloner(element, options, logger))
: cloneWindow(
ownerDocument,
windowBounds,
element,
options,
logger
).then(([container, clonedElement]) => {
if (__DEV__) {
logger.log(`Document cloned, using computed rendering`);
}
return imageLoader.ready().then(imageStore => { const imageLoader = new ImageLoader(
if (options.removeContainer === true) { options,
if (container.parentNode) { logger,
container.parentNode.removeChild(container); clonedElement.ownerDocument.defaultView
} else if (__DEV__) { );
logger.log(`Cannot detach cloned iframe as it is not in the DOM anymore`); const stack = NodeParser(clonedElement, imageLoader, logger);
} const clonedDocument = clonedElement.ownerDocument;
} const width = bounds.width;
const height = bounds.height;
const fontMetrics = new FontMetrics(clonedDocument); if (backgroundColor === stack.container.style.background.backgroundColor) {
if (__DEV__) { stack.container.style.background.backgroundColor = TRANSPARENT;
logger.log(`Starting renderer`); }
}
const renderOptions = { return imageLoader.ready().then(imageStore => {
backgroundColor, if (options.removeContainer === true) {
fontMetrics, if (container.parentNode) {
imageStore, container.parentNode.removeChild(container);
logger, } else if (__DEV__) {
scale: options.scale, logger.log(
width, `Cannot detach cloned iframe as it is not in the DOM anymore`
height );
}; }
}
if (Array.isArray(options.target)) { const fontMetrics = new FontMetrics(clonedDocument);
return Promise.all( if (__DEV__) {
options.target.map(target => { logger.log(`Starting renderer`);
const renderer = new Renderer(target, renderOptions); }
return renderer.render(stack);
}) const renderOptions = {
); backgroundColor,
} else { fontMetrics,
const renderer = new Renderer(options.target, renderOptions); imageStore,
return renderer.render(stack); logger,
} scale: options.scale,
}); width,
}); height
};
if (Array.isArray(options.target)) {
return Promise.all(
options.target.map(target => {
const renderer = new Renderer(target, renderOptions);
return renderer.render(stack);
})
);
} else {
const renderer = new Renderer(options.target, renderOptions);
return renderer.render(stack);
}
});
})
);
if (__DEV__) { if (__DEV__) {
return result.catch(e => { return result.catch(e => {

View File

@ -223,7 +223,7 @@ export const calculateBackgroundRepeatPath = (
export const parseBackground = ( export const parseBackground = (
style: CSSStyleDeclaration, style: CSSStyleDeclaration,
imageLoader: ImageLoader imageLoader: ImageLoader<ImageElement>
): Background => { ): Background => {
return { return {
backgroundColor: new Color(style.backgroundColor), backgroundColor: new Color(style.backgroundColor),
@ -276,12 +276,17 @@ const parseBackgroundRepeat = (backgroundRepeat: string): BackgroundRepeat => {
const parseBackgroundImages = ( const parseBackgroundImages = (
style: CSSStyleDeclaration, style: CSSStyleDeclaration,
imageLoader: ImageLoader imageLoader: ImageLoader<ImageElement>
): Array<BackgroundImage> => { ): Array<BackgroundImage> => {
const sources: Array<BackgroundSource> = parseBackgroundImage( const sources: Array<BackgroundSource> = parseBackgroundImage(
style.backgroundImage, style.backgroundImage
imageLoader ).map(backgroundImage => {
); if (backgroundImage.method === 'url') {
const key = imageLoader.loadImage(backgroundImage.args[0]);
backgroundImage.args = key ? [key] : [];
}
return backgroundImage;
});
const positions = style.backgroundPosition.split(','); const positions = style.backgroundPosition.split(',');
const repeats = style.backgroundRepeat.split(','); const repeats = style.backgroundRepeat.split(',');
const sizes = style.backgroundSize.split(','); const sizes = style.backgroundSize.split(',');
@ -318,7 +323,7 @@ const parseBackgoundPosition = (position: string): Length => {
return new Length(position); return new Length(position);
}; };
const parseBackgroundImage = (image: string, imageLoader: ImageLoader): Array<BackgroundSource> => { export const parseBackgroundImage = (image: string): Array<BackgroundSource> => {
const whitespace = /^\s$/; const whitespace = /^\s$/;
const results = []; const results = [];
@ -346,10 +351,6 @@ const parseBackgroundImage = (image: string, imageLoader: ImageLoader): Array<Ba
method = method.substr(prefix_i); method = method.substr(prefix_i);
} }
method = method.toLowerCase(); method = method.toLowerCase();
if (method === 'url') {
const key = imageLoader.loadImage(args[0]);
args = key ? [key] : [];
}
if (method !== 'none') { if (method !== 'none') {
results.push({ results.push({
prefix, prefix,

View File

@ -0,0 +1,52 @@
import type {RenderOptions} from '../Renderer';
export default class ForeignObjectRenderer {
options: RenderOptions;
element: HTMLElement;
constructor(element: HTMLElement) {
this.element = element;
}
render(options) {
this.options = options;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = Math.floor(options.bounds.width * options.scale);
this.canvas.height = Math.floor(options.bounds.height * options.scale);
this.canvas.style.width = `${options.bounds.width}px`;
this.canvas.style.height = `${options.bounds.height}px`;
this.ctx.scale(this.options.scale, this.options.scale);
options.logger.log(`ForeignObject renderer initialized with scale ${this.options.scale}`);
const xmlns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(xmlns, 'svg');
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
svg.setAttributeNS(null, 'width', options.bounds.width);
svg.setAttributeNS(null, 'height', options.bounds.height);
foreignObject.setAttributeNS(null, 'width', '100%');
foreignObject.setAttributeNS(null, 'height', '100%');
foreignObject.setAttributeNS(null, 'externalResourcesRequired', 'true');
svg.appendChild(foreignObject);
foreignObject.appendChild(this.element);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
if (options.backgroundColor) {
this.ctx.fillStyle = options.backgroundColor.toString();
this.ctx.fillRect(0, 0, options.bounds.width, options.bounds.height);
}
this.ctx.drawImage(img, 0, 0);
resolve(this.canvas);
};
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
new XMLSerializer().serializeToString(svg)
)}`;
});
}
}