preface

Html2canvas has been pulled out of the cocoon for a few days and made a small demo that can barely run. Although it has all the essentials, let’s have a look at the effect.With the same fund green, from the original spectrum) 👇 🏻 :



Now let’s implement it 🌈.

Perceptual knowledge

So now we have HTML, and we want to draw it on canvas, how do we do that? Here’s a quick thought for a few seconds 🤔… Ok, for those of you who don’t have a lot of ideas, take a look at the following image for inspiration 👇🏻 :The figure above shows three small examples of converting HTML into canvas (background, picture and text), from which we can know that in order to convert HTML into canvas, we only need to convert HTML into the corresponding Canvas language, that is, the code on the left in the figure above is transformed into the code on the right. With this perceptual understanding, you can start to open 🐶!

Step 1: Parse the DOM tree

To get an element onto the canvas, you need to know where (position), what (type), and how (style) to draw it. Obviously it can be used for locationgetBoundingClientRectTo get, style withgetComputedStyleTo get the type we usetagNameTo distinguish between text and images and so on, different types of processing. To obtain all of the above information, we must parse the DOM. Let’s take a look at the comparison diagram before and after parsing, to have a visual impression 👇🏻 : Obviously, traversing the DOM nodes is necessary to preserve the original tree structure. Don’t think this is too hard, traversing the DOM is a very simple thing 🙂, see the following code can understand, also everything 👇🏻 :

// Walk through the DOM with the old tree structure to create our own new object ElContainer
// ElContainer contains coordinate positions and sizes, styles, child elements, etc
class ElContainer {
    constructor(global, el) { // Global stores some global variables. Currently, only the global offset is stored, because it needs to be subtracted when calculating the position
        this.bounds = new Bounds(global, el); // Get the location and size
        this.styles = window.getComputedStyle(el); // For the convenience of taking all the styles directly, you can actually filter them as needed
        this.elements = []; / / child elements
        this.textNodes = []; // Text nodes are special and handled separately
        this.flags = 0; // falgs flags whether to create a cascading context
        this.el = el; // A reference to the element}}// Calculates the position and size of the element
class Bounds {
    constructor(global, el) {
        const { x = 0, y = 0 } = global.offset;
        const { top, left, width, height } = el.getBoundingClientRect();
        this.top = top - y;
        this.left = left - x;
        this.width = width;
        this.height = height; }}parseTree(global, el) {
    const container = this.createContainer(global, el);
    this.parseNodeTree(global, el, container);
    return container;
}
parseNodeTree(global, el, parent) {
    [...el.childNodes].map((child) = > {
        if (child.nodeType === 3) {
            // If it is a text node
            if (child.textContent.trim().length > 0) {
                // The text node is not empty
                const textElContainer = newTextElContainer(child.textContent, parent); parent.textNodes.push(textElContainer); }}else {
            // If it is a common node
            const container = this.createContainer(global, child);
            const { position, zIndex, opacity, transform } = container.styles;
            if((position ! = ='static'&&!isNaN(zIndex)) || opacity < 1|| transform ! = ='none') { // If you need to create a flag for the cascading context, you can skip it
                container.flags = 1;
            }
            parent.elements.push(container);
            this.parseNodeTree(global, child, container); }}); }Copy the code

Note the following in the above code:

  • We calculatedboundsThe outermost container needs to be considered#appOtherwise when you scroll through the page,bounds.topIt’s going to be negative, it’s going to be at the top of the canvas, and it’s going to be blank.
  • Text nodes are special because text is not a container and its style and position are affected by the parent node, so we use a separate variabletextNodesTo save.

The result of traversal is a similar process to generating the virtual DOM.

Step 2: Group by cascading rules (emphasis)

We know that normally a page is laid out as a stream, with elements arranged in order from top to bottom and left to right without overlapping one another. However, sometimes this rule is broken, such as using floats and positioning. So in a cascading context elements are presented in the following cascading order (you should have seen similar diagrams) :In the figure above, background/border is the decorative property, float and block are generally used for layout, and inline is used to display content. Inline hierarchies are higher because the content on the page is most important. This cascading order is also the order we will draw on canvas later.The front end is a camouflage technology, so the first painting and then painting which is very exquisite.

Now let’s briefly add the concept of cascading context, which is probably the most memorablez-indexIn fact, there are roughly three ways to form a cascading context:

  • The HTML, the root element of the page, is itself the cascading context, called the root cascading context
  • Position is non-static and z-index is a number
  • Some new properties in CSS3

A cascading context is essentially the concept of layers in Photoshop. If you don’t understand it, you can imagine it as a transparent sheet of paper. A page is made up of multiple sheets of paper, each with its own content on top. Here we start withz-indexFor example, let’s look at the following two images to get a better impression. Suppose the HTML structure of the page looks like 👇🏻 :So we can partition several cascading contexts, and they can be nested, as follows 👇🏻 :You can see from the figure above that A minus 2z-indexIs 99, but it is covered by C. This is because the two elements are not in the same context (the same piece of paper), so they cannot be compared to each other. This is also a problem we often encounter in developmentz-indexIt’s set to 9999, but it doesn’t work, that’s why. In fact, a good page should be rarely usedz-indexThe global mask will be used.

Um 🐶… With all this nonsense, I haven’t said what I’m going to do, because… It’s hard to describe, so let’s take a look at what it looks like 👇🏻 :All you need to do is iterate over the objects returned in the previous step according to the cascade rule to generate the image above. If you encounter conditions that satisfy the need to create a new cascading context (e.gz-indexCreate a new cascading context, otherwise group the children in the current cascading context according to the cascading rules. Here you need to take some time to taste 🤔, and recommend using the following code to eat 👇🏻 :

class StackingContext { // This is the cascading context
    constructor(container) {
        this.container = container;
        this.negativeZIndex = []; ZIndex is a negative element
        this.nonInlineLevel = []; // Block level elements
        this.nonPositionedFloats = []; // Float elements
        this.inlineLevel = []; // Inline elements
        this.positiveZIndex = []; // z-index is greater than or equal to 1
        this.zeroOrAutoZIndexOrTransformedOrOpacity = []; // Elements with transform, opacity, zIndex auto or 0}}// Start partitioning according to cascading rules
parseStackingContext(container) {
    const root = new StackingContext(container);
    this.parseStackTree(container, root);
    return root;
}
parseStackTree(parent, stackingContext) { // To simplify things here, stackingContext is the current cascading context
    parent.elements.map((child) = > { // Start grouping
        if (child.flags) { // Create the identity of the new cascading context, as mentioned above (e.g. set to 1 when z-index is encountered)
            const stack = new StackingContext(child);
            const zIndex = child.styles.zIndex;
            if (zIndex > 0) { // zIndex can be 1, 10, 100, so it's not a push, it's an insert
                stackingContext.positiveZIndex.push(stack);
            } else if (zIndex < 0) {
                stackingContext.negativeZIndex.push(stack);
            } else {
                stackingContext.zeroOrAutoZIndexOrTransformedOrOpacity.push(stack);
            }
            this.parseStackTree(child, stack);
        } else {
            if (child.styles.display.indexOf('inline') > =0) {
                stackingContext.inlineLevel.push(child);
            } else {
                stackingContext.nonInlineLevel.push(child);
            }
            this.parseStackTree(child, stackingContext); }}); }Copy the code

Step 3: Create the canvas

This one is easier, but take into account the effect of DPR (device pixel ratio) so that the canvas doesn’t blur. For more on this, read my other post: 🔥 about canvas blur (hd graphic), specifically about why it is written in this way, in fact, it is generally created in this way (canvas enlarged by DPR times) :

createCanvas(el) {
    const { width, height } = el.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;

    const canvas = document.createElement('canvas');
    const ctx2d = canvas.getContext('2d');
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(height * dpr);
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';
    ctx2d.scale(dpr, dpr);

    this.canvas = canvas;
    this.ctx2d = ctx2d;
    return canvas;
}
Copy the code

Step 4: Render

Now that we have all the data, we simply iterate over the hierarchy of results returned in step 2 and draw them in order. This step is difficult for different situations how to convert to the corresponding canvas language, need to consider a lot of things, of course, we are here some simple elements, ha ha ha 😄.

// According to the hierarchical array, draw layer by layer from bottom to top, and convert to the corresponding canvas drawing statement
render(stack) {
    const { negativeZIndex = [], nonInlineLevel = [], inlineLevel = [], positiveZIndex = [], zeroOrAutoZIndexOrTransformedOrOpacity = [] } = stack;
    this.ctx2d.save();
    // Set any properties that affect the global function, such as transform and opacity
    this.setTransformAndOpacity(stack.container);
    // 2. Draw the background and border
    this.renderNodeBackgroundAndBorders(stack.container);
    // 3, draw zIndex < 0 element
    negativeZIndex.map((el) = > this.render(el));
    // 4. Draw your own content
    this.renderNodeContent(stack.container);
    // 5. Draw block elements
    nonInlineLevel.map((el) = > this.renderNode(el));
    // 6. Draw the inline elements
    inlineLevel.map((el) = > this.renderNode(el));
    / / 7, drawing z - index: auto | | 0, the transform: none, opacity, the elements of less than 1
    zeroOrAutoZIndexOrTransformedOrOpacity.map((el) = > this.render(el));
    // draw the element zIndex > 0
    positiveZIndex.map((el) = > this.render(el));
    this.ctx2d.restore();
}
// There are different rendering methods for different elements, as mentioned in the beginning
renderNodeContent(container) {
    if (container.textNodes.length) {
        container.textNodes.map((text) = > this.renderText(text, container.styles));
    } else if (container instanceof ImageElContainer) {
        this.renderImg(container);
    } else if (container instanceof InputElContainer) {
        this.renderInput(container); }}renderNode(container) {
    this.renderNodeBackgroundAndBorders(container);
    this.renderNodeContent(container);
}
renderText(text, styles) { // Only a few factors that affect fonts are considered here
    const { ctx2d } = this;
    ctx2d.save();
    ctx2d.font = `${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
    ctx2d.fillStyle = styles.color;
    ctx2d.fillText(text.text, text.bounds.left, text.bounds.top);
    ctx2d.restore();
}
renderImg(container) { // We draw directly with the img element in the page, so we have to wait until the image is loaded or we won't see it. The normal way to write it is to draw in the img.onload callback
    const { ctx2d } = this;
    const { el, bounds, styles } = container;
    ctx2d.drawImage(el, 0.0.parseInt(styles.width), parseInt(styles.height), bounds.left, bounds.top, bounds.width, bounds.height);
}
Copy the code

Again, a few notes:

  • Styles such as transform and opacity affect themselves and their children, so we need to set the global properties of the canvas at the start of rendering (e.gsetTransformAndOpacityTransparency Settings inctx2d.globalAlpha = opacity;)
  • For elements with the transform attribute, the drawing should be wrong. Because our initial position information is wrong in the bounds, we get the position of the element after the transform. In fact, we need the position before the transform, so we need to do some simple data processing at the beginning of the loop, like 👇🏻 :
class ElContainer {
    constructor(global, el) {
        If the element uses transform, we need to restore it first and then get the style. Since we didn't clone the entire HTML, we'll do that here
        const transform = this.styles.transform;
        if(transform ! = ='none') el.style.transform = 'none';
        this.bounds = new Bounds(global, el);
        if(transform ! = ='none') el.style.transform = transform;
        // ...}}Copy the code
  • For background and border drawing, it is actually to calculate four points (points are in order, either clockwise or counterclockwise) draw four lines and then fill or stroke; If we have rounded corners, we draw four lines and four arcs; In addition, the width of the border can also affect the position of the elements inside it, otherwise there will be some deviation, but we did not deal with that, haha 😄.
  • About the text rendering, attentive students will find at the beginning of canvas in the rendering of text and HTML reports, such as location will offset a little, this is because the text rendering is a hassle, what how font, alignment, where the baseline, word spacing, line higher various attributes is multifarious, so we are just simple processing, Line breaks are also not supported at 😂.
  • For images that take time to load, rendering should be asynchronous, otherwise they might not be drawn (cross-domain, image size, etc.), so simply take the loaded IMG and draw it.

So what if we need some other functionality 😰? You should know something from the previous study, such as:

  • What if some elements don’t need to be drawn? Add a property or add a class (data-html2canvas-ignore), filter it out as you traverse.
  • What if the text has ellipses? Here we have to usectx2d.measureTextThis API calculates the text width and concatenates it on its own.In addition, this API can only calculate the width, not the height, the height need their own (according to the size, line height) tedious calculation.
  • How to deal with canvas element? You can draw the canvas directly, and other elements can be used directly if there is API (such as SVG), and handwritten if there is no API (such as check boxes). Property is the same, there is no corresponding canvas implementation method, slowly handwritten implementation. It can be seen that from HTML to Canvas basically all need to be converted to the corresponding writing method one by one, just think about it 🤔, so there will be a variety of problems is very normal, even for such a simple implementation version as this paper. In addition, it is easy to generate some undescribable bugs 😂, and then you will find a few more obscure attributes, and finally have no choice 🤷🏻♀️ (I put my cards on the table. I won’t. I can’t. I don’t want to).

Knowledge is easy to forget, here we simply look at a flow chart review: Ps: I think the hardest part is getting the position and style, but luckily the browser has solved that for us.

Another approach (HTML -> SVG ->canvas)

Those who are not interested can skip this party 😂. This is a relatively simple method, which is to load HTML into SVG and then move SVG to canvas. Since the browser provides the corresponding API, this can be done, but it also has its limitations, which are briefly covered here:

  • Walk through the DOM and write all the inline styles to the inline style (because SVG requires this, otherwise the style will not work)
  • Serialized HTML is spliced into SVG and exported as an image, like this:
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
                 <foreignObject height="100%" width="100%">${htmlString}</foreignObject>
             </svg>`;
const img = new Image();
img.src = `data:image/svg+xml,${svg}`;
Copy the code
  • Finally, paint img onto the canvas.
ctx2d.drawImage(img, 0.0);
Copy the code

Simple as it looks, the practical application is problematic.

conclusion

Ok, the above are the two ideas of HTML2Canvas, of course, in the actual development, we must directly use HTML2Canvas. But if something goes wrong in the use of this time, your heart has a bottom, you can guess about why some places will not success, this is big probability is not compatible, does not support, no corresponding transformation, so the best solution is to HTML and CSS in kinds of writing, with less gaudy style is particularly important. Finally, if you look at html2Canvas’s readme. md, you’ll find a sentence like 😂 :Feel free to like and leave a comment at 👋🏻