mirror of
https://github.com/niklasvh/html2canvas.git
synced 2023-08-10 21:13:10 +03:00
Implement foreignObject renderer
This commit is contained in:
parent
26cdc0441b
commit
a73dbf8067
2
flow-typed/myLibDef.js
vendored
2
flow-typed/myLibDef.js
vendored
@ -7,3 +7,5 @@ declare class SVGSVGElement extends Element {
|
||||
|
||||
getPresentationAttribute(name: string): any;
|
||||
}
|
||||
|
||||
declare class HTMLBodyElement extends HTMLElement {}
|
||||
|
250
src/Clone.js
250
src/Clone.js
@ -2,6 +2,127 @@
|
||||
'use strict';
|
||||
import type {Bounds} from './Bounds';
|
||||
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) => {
|
||||
if (
|
||||
@ -28,44 +149,80 @@ const cloneCanvasContents = (canvas: HTMLCanvasElement, clonedCanvas: HTMLCanvas
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const cloneNode = (
|
||||
node: Node,
|
||||
referenceElement: [HTMLElement, ?HTMLElement],
|
||||
scrolledElements: Array<[HTMLElement, number, number]>
|
||||
) => {
|
||||
const clone =
|
||||
node.nodeType === Node.TEXT_NODE
|
||||
? document.createTextNode(node.nodeValue)
|
||||
: node.cloneNode(false);
|
||||
|
||||
if (referenceElement[0] === node && clone instanceof HTMLElement) {
|
||||
referenceElement[1] = clone;
|
||||
const inlinePseudoElement = (
|
||||
node: HTMLElement,
|
||||
clone: HTMLElement,
|
||||
pseudoElt: ':before' | ':after'
|
||||
): ?HTMLElement => {
|
||||
const style = node.ownerDocument.defaultView.getComputedStyle(node, pseudoElt);
|
||||
if (
|
||||
!style ||
|
||||
!style.content ||
|
||||
style.content === 'none' ||
|
||||
style.content === '-moz-alt-content' ||
|
||||
style.display === 'none'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType !== Node.ELEMENT_NODE || child.nodeName !== 'SCRIPT') {
|
||||
clone.appendChild(cloneNode(child, referenceElement, scrolledElements));
|
||||
}
|
||||
const content = stripQuotes(style.content);
|
||||
const image = content.match(URL_REGEXP);
|
||||
const anonymousReplacedElement = clone.ownerDocument.createElement(
|
||||
image ? 'img' : 'html2canvaspseudoelement'
|
||||
);
|
||||
if (image) {
|
||||
// $FlowFixMe
|
||||
anonymousReplacedElement.src = stripQuotes(image[1]);
|
||||
} else {
|
||||
anonymousReplacedElement.textContent = content;
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.scrollTop !== 0 || node.scrollLeft !== 0) {
|
||||
scrolledElements.push([node, node.scrollLeft, node.scrollTop]);
|
||||
}
|
||||
switch (node.nodeName) {
|
||||
case 'CANVAS':
|
||||
// $FlowFixMe
|
||||
cloneCanvasContents(node, clone);
|
||||
break;
|
||||
case 'TEXTAREA':
|
||||
case 'SELECT':
|
||||
// $FlowFixMe
|
||||
clone.value = node.value;
|
||||
break;
|
||||
}
|
||||
copyCSSStyles(style, anonymousReplacedElement);
|
||||
|
||||
anonymousReplacedElement.className = `${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE} ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`;
|
||||
clone.className +=
|
||||
pseudoElt === PSEUDO_BEFORE
|
||||
? ` ${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}`
|
||||
: ` ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`;
|
||||
if (pseudoElt === PSEUDO_BEFORE) {
|
||||
clone.insertBefore(anonymousReplacedElement, clone.firstChild);
|
||||
} else {
|
||||
clone.appendChild(anonymousReplacedElement);
|
||||
}
|
||||
|
||||
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]) => {
|
||||
@ -74,24 +231,13 @@ const initNode = ([element, x, y]: [HTMLElement, number, number]) => {
|
||||
};
|
||||
|
||||
export const cloneWindow = (
|
||||
documentToBeCloned: Document,
|
||||
ownerDocument: Document,
|
||||
bounds: Bounds,
|
||||
referenceElement: HTMLElement,
|
||||
options: Options
|
||||
options: Options,
|
||||
logger: Logger
|
||||
): Promise<[HTMLIFrameElement, HTMLElement]> => {
|
||||
const scrolledElements = [];
|
||||
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 cloner = new DocumentCloner(referenceElement, options, logger);
|
||||
const cloneIframeContainer = ownerDocument.createElement('iframe');
|
||||
|
||||
cloneIframeContainer.className = 'html2canvas-container';
|
||||
@ -120,7 +266,7 @@ export const cloneWindow = (
|
||||
cloneWindow.onload = cloneIframeContainer.onload = () => {
|
||||
const interval = setInterval(() => {
|
||||
if (documentClone.body.childNodes.length > 0) {
|
||||
scrolledElements.forEach(initNode);
|
||||
cloner.scrolledElements.forEach(initNode);
|
||||
clearInterval(interval);
|
||||
if (options.type === 'view') {
|
||||
cloneWindow.scrollTo(bounds.left, bounds.top);
|
||||
@ -135,10 +281,10 @@ export const cloneWindow = (
|
||||
}
|
||||
}
|
||||
if (
|
||||
referenceElementSearch[1] instanceof cloneWindow.HTMLElement ||
|
||||
referenceElementSearch[1] instanceof HTMLElement
|
||||
cloner.clonedReferenceElement instanceof cloneWindow.HTMLElement ||
|
||||
cloner.clonedReferenceElement instanceof HTMLElement
|
||||
) {
|
||||
resolve([cloneIframeContainer, referenceElementSearch[1]]);
|
||||
resolve([cloneIframeContainer, cloner.clonedReferenceElement]);
|
||||
} else {
|
||||
reject(
|
||||
__DEV__
|
||||
@ -153,9 +299,9 @@ export const cloneWindow = (
|
||||
documentClone.open();
|
||||
documentClone.write('<!DOCTYPE html><html></html>');
|
||||
// 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.adoptNode(documentElement),
|
||||
documentClone.adoptNode(cloner.documentElement),
|
||||
documentClone.documentElement
|
||||
);
|
||||
documentClone.close();
|
||||
|
@ -71,6 +71,34 @@ const testSVG = document => {
|
||||
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 = {
|
||||
// $FlowFixMe - get/set properties not yet supported
|
||||
get SUPPORT_RANGE_BOUNDS() {
|
||||
@ -94,6 +122,13 @@ const FEATURES = {
|
||||
Object.defineProperty(FEATURES, 'SUPPORT_BASE64_DRAWING', {value: () => 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -5,15 +5,16 @@ import type Options from './index';
|
||||
import type Logger from './Logger';
|
||||
|
||||
export type ImageElement = Image | HTMLCanvasElement;
|
||||
type ImageCache = {[string]: Promise<?ImageElement>};
|
||||
type ImageCache<T> = {[string]: Promise<T>};
|
||||
|
||||
import FEATURES from './Feature';
|
||||
|
||||
export default class ImageLoader {
|
||||
// $FlowFixMe
|
||||
export default class ImageLoader<T> {
|
||||
origin: string;
|
||||
options: Options;
|
||||
_link: HTMLAnchorElement;
|
||||
cache: ImageCache;
|
||||
cache: ImageCache<T>;
|
||||
logger: Logger;
|
||||
_index: number;
|
||||
_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 {
|
||||
const key = String(this._index++);
|
||||
this.cache[key] = Promise.resolve(node);
|
||||
@ -60,8 +101,8 @@ export default class ImageLoader {
|
||||
this.logger.log(`Added image ${key.substring(0, 256)}`);
|
||||
}
|
||||
|
||||
const imageLoadHandler = (supportsDataImages: boolean): Promise<Image> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageLoadHandler = (supportsDataImages: boolean): Promise<Image> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
//ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
|
||||
@ -78,7 +119,6 @@ export default class ImageLoader {
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.cache[key] =
|
||||
isInlineImage(src) && !isSVG(src)
|
||||
@ -99,7 +139,7 @@ export default class ImageLoader {
|
||||
return link.protocol + link.hostname + link.port;
|
||||
}
|
||||
|
||||
ready(): Promise<ImageStore> {
|
||||
ready(): Promise<ImageStore<T>> {
|
||||
const keys = Object.keys(this.cache);
|
||||
return Promise.all(
|
||||
keys.map(str =>
|
||||
@ -112,23 +152,23 @@ export default class ImageLoader {
|
||||
)
|
||||
).then(images => {
|
||||
if (__DEV__) {
|
||||
this.logger.log('Finished loading images', images);
|
||||
this.logger.log(`Finished loading ${images.length} images`, images);
|
||||
}
|
||||
return new ImageStore(keys, images);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageStore {
|
||||
export class ImageStore<T> {
|
||||
_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._images = images;
|
||||
}
|
||||
|
||||
get(key: string): ?ImageElement {
|
||||
get(key: string): ?T {
|
||||
const index = this._keys.indexOf(key);
|
||||
return index === -1 ? null : this._images[index];
|
||||
}
|
||||
|
@ -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 from './ImageLoader';
|
||||
import type ImageLoader, {ImageElement} from './ImageLoader';
|
||||
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,
|
||||
imageLoader: ImageLoader<ImageElement>,
|
||||
index: number
|
||||
) {
|
||||
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 (
|
||||
node instanceof node.ownerDocument.defaultView.SVGSVGElement ||
|
||||
node instanceof SVGSVGElement
|
||||
|
@ -1,16 +1,15 @@
|
||||
/* @flow */
|
||||
'use strict';
|
||||
import type ImageLoader from './ImageLoader';
|
||||
import type ImageLoader, {ImageElement} from './ImageLoader';
|
||||
import type Logger from './Logger';
|
||||
import StackingContext from './StackingContext';
|
||||
import NodeContainer from './NodeContainer';
|
||||
import TextContainer from './TextContainer';
|
||||
import {inlineInputElement, inlineTextAreaElement, inlineSelectElement} from './Input';
|
||||
import {copyCSSStyles} from './Util';
|
||||
|
||||
export const NodeParser = (
|
||||
node: HTMLElement,
|
||||
imageLoader: ImageLoader,
|
||||
imageLoader: ImageLoader<ImageElement>,
|
||||
logger: Logger
|
||||
): StackingContext => {
|
||||
if (__DEV__) {
|
||||
@ -22,7 +21,6 @@ export const NodeParser = (
|
||||
const container = new NodeContainer(node, null, imageLoader, index++);
|
||||
const stack = new StackingContext(container, null, true);
|
||||
|
||||
createPseudoHideStyles(node.ownerDocument);
|
||||
parseNodeTree(node, container, stack, imageLoader, index);
|
||||
|
||||
if (__DEV__) {
|
||||
@ -33,17 +31,12 @@ export const NodeParser = (
|
||||
};
|
||||
|
||||
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 = (
|
||||
node: HTMLElement,
|
||||
parent: NodeContainer,
|
||||
stack: StackingContext,
|
||||
imageLoader: ImageLoader,
|
||||
imageLoader: ImageLoader<ImageElement>,
|
||||
index: number
|
||||
): void => {
|
||||
if (__DEV__ && index > 50000) {
|
||||
@ -62,8 +55,6 @@ const parseNodeTree = (
|
||||
childNode instanceof HTMLElement
|
||||
) {
|
||||
if (IGNORED_NODE_NAMES.indexOf(childNode.nodeName) === -1) {
|
||||
inlinePseudoElement(childNode, PSEUDO_BEFORE);
|
||||
inlinePseudoElement(childNode, PSEUDO_AFTER);
|
||||
const container = new NodeContainer(childNode, parent, imageLoader, index++);
|
||||
if (container.isVisible()) {
|
||||
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 = (
|
||||
container: NodeContainer,
|
||||
node: HTMLElement | SVGSVGElement
|
||||
@ -197,31 +150,3 @@ const isBodyWithTransparentRoot = (
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
@ -43,7 +43,7 @@ import {BORDER_STYLE} from './parsing/border';
|
||||
export type RenderOptions = {
|
||||
scale: number,
|
||||
backgroundColor: ?Color,
|
||||
imageStore: ImageStore,
|
||||
imageStore: ImageStore<ImageElement>,
|
||||
fontMetrics: FontMetrics,
|
||||
logger: Logger,
|
||||
width: number,
|
||||
|
166
src/index.js
166
src/index.js
@ -5,13 +5,15 @@ import type {RenderTarget} from './Renderer';
|
||||
|
||||
import {NodeParser} from './NodeParser';
|
||||
import Renderer from './Renderer';
|
||||
import ForeignObjectRenderer from './renderer/ForeignObjectRenderer';
|
||||
import CanvasRenderer from './renderer/CanvasRenderer';
|
||||
import Logger from './Logger';
|
||||
import ImageLoader from './ImageLoader';
|
||||
import Feature from './Feature';
|
||||
import {Bounds, parseDocumentSize} from './Bounds';
|
||||
import {cloneWindow} from './Clone';
|
||||
import Color, {TRANSPARENT} from './Color';
|
||||
import {cloneWindow, DocumentCloner} from './Clone';
|
||||
import {FontMetrics} from './Font';
|
||||
import Color, {TRANSPARENT} from './Color';
|
||||
|
||||
export type Options = {
|
||||
async: ?boolean,
|
||||
@ -42,7 +44,7 @@ const html2canvas = (element: HTMLElement, config: Options): Promise<*> => {
|
||||
const defaultOptions = {
|
||||
async: true,
|
||||
allowTaint: false,
|
||||
imageTimeout: 10000,
|
||||
imageTimeout: 15000,
|
||||
proxy: null,
|
||||
removeContainer: true,
|
||||
scale: defaultView.devicePixelRatio || 1,
|
||||
@ -63,79 +65,105 @@ const html2canvas = (element: HTMLElement, config: Options): Promise<*> => {
|
||||
options.windowHeight
|
||||
);
|
||||
|
||||
const result = cloneWindow(
|
||||
ownerDocument,
|
||||
ownerDocument,
|
||||
windowBounds,
|
||||
element,
|
||||
options
|
||||
).then(([container, clonedElement]) => {
|
||||
if (__DEV__) {
|
||||
logger.log(`Document cloned`);
|
||||
}
|
||||
const bounds = options.type === 'view' ? windowBounds : parseDocumentSize(ownerDocument);
|
||||
|
||||
const imageLoader = new ImageLoader(
|
||||
options,
|
||||
logger,
|
||||
clonedElement.ownerDocument.defaultView
|
||||
);
|
||||
const stack = NodeParser(clonedElement, imageLoader, logger);
|
||||
const clonedDocument = clonedElement.ownerDocument;
|
||||
const size = options.type === 'view' ? windowBounds : parseDocumentSize(clonedDocument);
|
||||
const width = size.width;
|
||||
const height = size.height;
|
||||
// http://www.w3.org/TR/css3-background/#special-backgrounds
|
||||
const documentBackgroundColor = ownerDocument.documentElement
|
||||
? new Color(getComputedStyle(ownerDocument.documentElement).backgroundColor)
|
||||
: TRANSPARENT;
|
||||
const backgroundColor =
|
||||
element === ownerDocument.documentElement
|
||||
? documentBackgroundColor.isTransparent()
|
||||
? ownerDocument.body
|
||||
? new Color(getComputedStyle(ownerDocument.body).backgroundColor)
|
||||
: null
|
||||
: documentBackgroundColor
|
||||
: null;
|
||||
|
||||
// http://www.w3.org/TR/css3-background/#special-backgrounds
|
||||
const backgroundColor =
|
||||
clonedElement === clonedDocument.documentElement
|
||||
? stack.container.style.background.backgroundColor.isTransparent()
|
||||
? clonedDocument.body
|
||||
? new Color(getComputedStyle(clonedDocument.body).backgroundColor)
|
||||
: null
|
||||
: stack.container.style.background.backgroundColor
|
||||
: null;
|
||||
// $FlowFixMe
|
||||
const result = Feature.SUPPORT_FOREIGNOBJECT_DRAWING.then(
|
||||
supportForeignObject =>
|
||||
supportForeignObject
|
||||
? (cloner => {
|
||||
if (__DEV__) {
|
||||
logger.log(`Document cloned, using foreignObject rendering`);
|
||||
}
|
||||
|
||||
if (backgroundColor === stack.container.style.background.backgroundColor) {
|
||||
stack.container.style.background.backgroundColor = TRANSPARENT;
|
||||
}
|
||||
return cloner.imageLoader.ready().then(() => {
|
||||
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 => {
|
||||
if (options.removeContainer === true) {
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
} else if (__DEV__) {
|
||||
logger.log(`Cannot detach cloned iframe as it is not in the DOM anymore`);
|
||||
}
|
||||
}
|
||||
const imageLoader = new ImageLoader(
|
||||
options,
|
||||
logger,
|
||||
clonedElement.ownerDocument.defaultView
|
||||
);
|
||||
const stack = NodeParser(clonedElement, imageLoader, logger);
|
||||
const clonedDocument = clonedElement.ownerDocument;
|
||||
const width = bounds.width;
|
||||
const height = bounds.height;
|
||||
|
||||
const fontMetrics = new FontMetrics(clonedDocument);
|
||||
if (__DEV__) {
|
||||
logger.log(`Starting renderer`);
|
||||
}
|
||||
if (backgroundColor === stack.container.style.background.backgroundColor) {
|
||||
stack.container.style.background.backgroundColor = TRANSPARENT;
|
||||
}
|
||||
|
||||
const renderOptions = {
|
||||
backgroundColor,
|
||||
fontMetrics,
|
||||
imageStore,
|
||||
logger,
|
||||
scale: options.scale,
|
||||
width,
|
||||
height
|
||||
};
|
||||
return imageLoader.ready().then(imageStore => {
|
||||
if (options.removeContainer === true) {
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
} else if (__DEV__) {
|
||||
logger.log(
|
||||
`Cannot detach cloned iframe as it is not in the DOM anymore`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
const fontMetrics = new FontMetrics(clonedDocument);
|
||||
if (__DEV__) {
|
||||
logger.log(`Starting renderer`);
|
||||
}
|
||||
|
||||
const renderOptions = {
|
||||
backgroundColor,
|
||||
fontMetrics,
|
||||
imageStore,
|
||||
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__) {
|
||||
return result.catch(e => {
|
||||
|
@ -223,7 +223,7 @@ export const calculateBackgroundRepeatPath = (
|
||||
|
||||
export const parseBackground = (
|
||||
style: CSSStyleDeclaration,
|
||||
imageLoader: ImageLoader
|
||||
imageLoader: ImageLoader<ImageElement>
|
||||
): Background => {
|
||||
return {
|
||||
backgroundColor: new Color(style.backgroundColor),
|
||||
@ -276,12 +276,17 @@ const parseBackgroundRepeat = (backgroundRepeat: string): BackgroundRepeat => {
|
||||
|
||||
const parseBackgroundImages = (
|
||||
style: CSSStyleDeclaration,
|
||||
imageLoader: ImageLoader
|
||||
imageLoader: ImageLoader<ImageElement>
|
||||
): Array<BackgroundImage> => {
|
||||
const sources: Array<BackgroundSource> = parseBackgroundImage(
|
||||
style.backgroundImage,
|
||||
imageLoader
|
||||
);
|
||||
style.backgroundImage
|
||||
).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 repeats = style.backgroundRepeat.split(',');
|
||||
const sizes = style.backgroundSize.split(',');
|
||||
@ -318,7 +323,7 @@ const parseBackgoundPosition = (position: string): Length => {
|
||||
return new Length(position);
|
||||
};
|
||||
|
||||
const parseBackgroundImage = (image: string, imageLoader: ImageLoader): Array<BackgroundSource> => {
|
||||
export const parseBackgroundImage = (image: string): Array<BackgroundSource> => {
|
||||
const whitespace = /^\s$/;
|
||||
const results = [];
|
||||
|
||||
@ -346,10 +351,6 @@ const parseBackgroundImage = (image: string, imageLoader: ImageLoader): Array<Ba
|
||||
method = method.substr(prefix_i);
|
||||
}
|
||||
method = method.toLowerCase();
|
||||
if (method === 'url') {
|
||||
const key = imageLoader.loadImage(args[0]);
|
||||
args = key ? [key] : [];
|
||||
}
|
||||
if (method !== 'none') {
|
||||
results.push({
|
||||
prefix,
|
||||
|
52
src/renderer/ForeignObjectRenderer.js
Normal file
52
src/renderer/ForeignObjectRenderer.js
Normal 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)
|
||||
)}`;
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user