Skip to content

Commit

Permalink
Speed & accuracy improvements to pseudo element rendering.
Browse files Browse the repository at this point in the history
Previously, pseudo elements would be processed as they were found in the DOM tree, which was
an expensive operation as each element's computed :before and :after style was checked for
'content' styles.

This commit traverses the user's stylesheets for :before and :after selectors, gathers the classes
affected, selects all elements that likely have a pseudo element present, then checks computed style.
If there is actually an element present, it is created but *not* appended to the DOM until after
all elements have been processed.

After all elements have been found and created, they are added to the DOM in a single batch, and the original
pseudo elements are hidden in a single batch. This prevents the layout invalidation / relayout loop that was
occuring previously, and in my tests speeds parsing by as much as 50% or more, depending on how many
pseudo elements your page uses.

Additionally, this commit contains a bugfix to the handling of ":before" pseudo elements; the browser effectively
inserts them as the first child of the element, not before the element. This fixes a few rendering inconsistencies
and complicated pages look almost perfect in my tests.
  • Loading branch information
ssafejava committed Sep 18, 2013
1 parent e115180 commit 8bea01b
Show file tree
Hide file tree
Showing 3 changed files with 305 additions and 101 deletions.
202 changes: 152 additions & 50 deletions build/html2canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -1041,36 +1041,162 @@ _html2canvas.Parse = function (images, options, cb) {
body = doc.body,
getCSS = Util.getCSS,
pseudoHide = "___html2canvas___pseudoelement",
hidePseudoElements = doc.createElement('style');
hidePseudoElementsStyles = doc.createElement('style');

hidePseudoElements.innerHTML = '.' + pseudoHide + '-before:before { content: "" !important; display: none !important; }' +
'.' + pseudoHide + '-after:after { content: "" !important; display: none !important; }';
hidePseudoElementsStyles.innerHTML = '.' + pseudoHide +
'-parent:before { content: "" !important; display: none !important; }' +
'.' + pseudoHide + '-parent:after { content: "" !important; display: none !important; }';

body.appendChild(hidePseudoElements);
body.appendChild(hidePseudoElementsStyles);

images = images || {};

init();

function init() {
var background = getCSS(document.documentElement, "backgroundColor"),
transparentBackground = (Util.isTransparent(background) && element === document.body),
stack = renderElement(element, null, false, transparentBackground);

// create pseudo elements in a single pass to prevent synchronous layouts
addPseudoElements(element);

parseChildren(element, stack, null, function() {
parseChildren(element, stack, function() {
if (transparentBackground) {
background = stack.backgroundColor;
}

removePseudoElements();

Util.log('Done parsing, moving to Render.');

body.removeChild(hidePseudoElements);
cb({
backgroundColor: background,
stack: stack
});
});
}

init();
// Given a root element, find all pseudo elements below, create elements mocking pseudo element styles
// so we can process them as normal elements, and hide the original pseudo elements so they don't interfere
// with layout.
function addPseudoElements(el) {
// These are done in discrete steps to prevent a relayout loop caused by addClass() invalidating
// layouts & getPseudoElement calling getComputedStyle.
var jobs = [], classes = [];
getPseudoElementClasses();
findPseudoElements(el);
runJobs();

function getPseudoElementClasses(){
var findPsuedoEls = /:before|:after/;
var sheets = document.styleSheets;
for (var i = 0, j = sheets.length; i < j; i++) {
try {
var rules = sheets[i].cssRules;
for (var k = 0, l = rules.length; k < l; k++) {
if(findPsuedoEls.test(rules[k].selectorText)) {
classes.push(rules[k].selectorText);
}
}
}
catch(e) { // will throw security exception for style sheets loaded from external domains
}
}

// Trim off the :after and :before (or ::after and ::before)
for (i = 0, j = classes.length; i < j; i++) {
classes[i] = classes[i].match(/(^[^:]*)/)[1];
}
}

// Using the list of elements we know how pseudo el styles, create fake pseudo elements.
function findPseudoElements(el) {
var els = document.querySelectorAll(classes.join(','));
for(var i = 0, j = els.length; i < j; i++) {
createPseudoElements(els[i]);
}
}

// Create pseudo elements & add them to a job queue.
function createPseudoElements(el) {
var before = getPseudoElement(el, ':before'),
after = getPseudoElement(el, ':after');

if(before) {
jobs.push({type: 'before', pseudo: before, el: el});
}

if (after) {
jobs.push({type: 'after', pseudo: after, el: el});
}
}

// Adds a class to the pseudo's parent to prevent the original before/after from messing
// with layouts.
// Execute the inserts & addClass() calls in a batch to prevent relayouts.
function runJobs() {
// Add Class
jobs.forEach(function(job){
addClass(job.el, pseudoHide + "-parent");
});

// Insert el
jobs.forEach(function(job){
if(job.type === 'before'){
job.el.insertBefore(job.pseudo, job.el.firstChild);
} else {
job.el.appendChild(job.pseudo);
}
});
}
}



// Delete our fake pseudo elements from the DOM. This will remove those actual elements
// and the classes on their parents that hide the actual pseudo elements.
// Note that NodeLists are 'live' collections so you can't use a for loop here. They are
// actually deleted from the NodeList after each iteration.
function removePseudoElements(){
// delete pseudo elements
body.removeChild(hidePseudoElementsStyles);
var pseudos = document.getElementsByClassName(pseudoHide + "-element");
while (pseudos.length) {
pseudos[0].parentNode.removeChild(pseudos[0]);
}

// Remove pseudo hiding classes
var parents = document.getElementsByClassName(pseudoHide + "-parent");
while(parents.length) {
removeClass(parents[0], pseudoHide + "-parent");
}
}

function addClass (el, className) {
if (el.classList) {
el.classList.add(className);
} else {
el.className = el.className + " " + className;
}
}

function removeClass (el, className) {
if (el.classList) {
el.classList.remove(className);
} else {
el.className = el.className.replace(className, "").trim();
}
}

function hasClass (el, className) {
return el.className.indexOf(className) > -1;
}

// Note that this doesn't work in < IE8, but we don't support that anyhow
function nodeListToArray (nodeList) {
return Array.prototype.slice.call(nodeList);
}

function documentWidth () {
return Math.max(
Expand Down Expand Up @@ -1831,20 +1957,25 @@ _html2canvas.Parse = function (images, options, cb) {

function getPseudoElement(el, which) {
var elStyle = window.getComputedStyle(el, which);
if(!elStyle || !elStyle.content || elStyle.content === "none" || elStyle.content === "-moz-alt-content" || elStyle.display === "none") {
var parentStyle = window.getComputedStyle(el);
// If no content attribute is present, the pseudo element is hidden,
// or the parent has a content property equal to the content on the pseudo element,
// move along.
if(!elStyle || !elStyle.content || elStyle.content === "none" || elStyle.content === "-moz-alt-content" ||
elStyle.display === "none" || parentStyle.content === elStyle.content) {
return;
}
var content = elStyle.content + '',
first = content.substr( 0, 1 );
//strips quotes
if(first === content.substr( content.length - 1 ) && first.match(/'|"/)) {
content = content.substr( 1, content.length - 2 );
var content = elStyle.content + '';

// Strip inner quotes
if(content[0] === "'" || content[0] === "\"") {
content = content.replace(/(^['"])|(['"]$)/g, '');
}

var isImage = content.substr( 0, 3 ) === 'url',
elps = document.createElement( isImage ? 'img' : 'span' );

elps.className = pseudoHide + "-before " + pseudoHide + "-after";
elps.className = pseudoHide + "-element ";

Object.keys(elStyle).filter(indexedProperty).forEach(function(prop) {
// Prevent assigning of read only CSS Rules, ex. length, parentRule
Expand All @@ -1867,31 +1998,6 @@ _html2canvas.Parse = function (images, options, cb) {
return (isNaN(window.parseInt(property, 10)));
}

function injectPseudoElements(el, stack) {
var before = getPseudoElement(el, ':before'),
after = getPseudoElement(el, ':after');
if(!before && !after) {
return;
}

if(before) {
el.className += " " + pseudoHide + "-before";
el.parentNode.insertBefore(before, el);
parseElement(before, stack, true);
el.parentNode.removeChild(before);
el.className = el.className.replace(pseudoHide + "-before", "").trim();
}

if (after) {
el.className += " " + pseudoHide + "-after";
el.appendChild(after);
parseElement(after, stack, true);
el.removeChild(after);
el.className = el.className.replace(pseudoHide + "-after", "").trim();
}

}

function renderBackgroundRepeat(ctx, image, backgroundPosition, bounds) {
var offsetX = Math.round(bounds.left + backgroundPosition.left),
offsetY = Math.round(bounds.top + backgroundPosition.top);
Expand Down Expand Up @@ -2079,7 +2185,7 @@ _html2canvas.Parse = function (images, options, cb) {
return bounds;
}

function renderElement(element, parentStack, pseudoElement, ignoreBackground) {
function renderElement(element, parentStack, ignoreBackground) {
var transform = getTransform(element, parentStack),
bounds = getBounds(element, transform),
image,
Expand Down Expand Up @@ -2109,10 +2215,6 @@ _html2canvas.Parse = function (images, options, cb) {
renderBorders(ctx, border.args, border.color);
});

if (!pseudoElement) {
injectPseudoElements(element, stack);
}

switch(element.nodeName){
case "IMG":
if ((image = loadImage(element.getAttribute('src')))) {
Expand Down Expand Up @@ -2153,20 +2255,20 @@ _html2canvas.Parse = function (images, options, cb) {
return (getCSS(element, 'display') !== "none" && getCSS(element, 'visibility') !== "hidden" && !element.hasAttribute("data-html2canvas-ignore"));
}

function parseElement (element, stack, pseudoElement, cb) {
function parseElement (element, stack, cb) {
if (!cb) {
cb = function(){};
}
if (isElementVisible(element)) {
stack = renderElement(element, stack, pseudoElement, false) || stack;
stack = renderElement(element, stack, false) || stack;
if (!ignoreElementsRegExp.test(element.nodeName)) {
return parseChildren(element, stack, pseudoElement, cb);
return parseChildren(element, stack, cb);
}
}
cb();
}

function parseChildren(element, stack, pseudoElement, cb) {
function parseChildren(element, stack, cb) {
var children = Util.Children(element);
// After all nodes have processed, finished() will call the cb.
// We add one and kick it off so this will still work when children.length === 0.
Expand All @@ -2185,7 +2287,7 @@ _html2canvas.Parse = function (images, options, cb) {

function parseNode(node) {
if (node.nodeType === node.ELEMENT_NODE) {
parseElement(node, stack, pseudoElement, finished);
parseElement(node, stack, finished);
} else if (node.nodeType === node.TEXT_NODE) {
renderText(element, node, stack);
finished();
Expand Down Expand Up @@ -2706,9 +2808,9 @@ window.html2canvas = function(elements, opts) {
useOverflow: true,
letterRendering: false,
chinese: false,
async: false, // If true, parsing will not block, but if the user scrolls during parse the image can get weird

// render options

width: null,
height: null,
taintTest: true, // do a taint test with all images before applying to canvas
Expand Down

0 comments on commit 8bea01b

Please sign in to comment.