Update at 2020.07.29 Hangzhou Youzan e-commerce team is in urgent need of 10+ HC, covering front-end, Java, testing, interested welcome to contact ~ [email protected] or directly contact WX: WSLDD225

The old form

Our business involves e-commerce and education industries. For marketing and functional needs, there are many demands for card display (long press to save) or sharing of long pictures. And we have a merchant PC side that can edit and preview the card style in real time.

The same card content needs to be maintained in two vue React frames at both ends.

Considering such factors as too much dependence (Ungzipped 160KB +), stability, maintainability and expansibility, we did not adopt html2Canvas, a third-party conversion library. Instead, canvas drawing is carried out by extracting a series of Canvas-utils.

Because canvas’s native drawing API is based on absolutely positioned pixels, supplemented by size information for drawing.

Such as:

ctx.rect(x, y, width, height); / / draw a rectangle
ctx.drawImage(img, destx, desty, destWidth, destHeight); / / draw pictures
Copy the code

So the canvas-utils entry we define must also contain the location and size information.

/** * draw rounded rectangle ** @param {*} CTX canvas * @param {Number} RADIUS * @param {Number} x top left * @param {Number} y top left * @param {Number} width width * @param {Number} height height * @param {String} color * @param {String} mode fill mode * @param {Function} Fn callback function */
export function drawRoundedRectangle() {}

/** @param {*} CTX canvas * @param {*} img load load img object * @param {Number} x top left point x axis * @param {Number} Y Y coordinates at the upper left corner * @param {Number} W width * @param {Number} h height * @param {Number} RADIUS Radius */
export function drawImage() {}

/** ** Draw multiple line fragments ** @param {*} CTX canvas * @param {*} Content * @param {*} x draws the x coordinates of the origin in the lower left corner * @param {*} y draws the Y coordinates of the origin in the lower left corner * @param {*} maxWidth maxWidth * @param {*} fontSize fontSize * @param {*} fontFamily fontFamily * @param {*} color font color * @param {*} @param {*} lineHeight set lineHeight @param {*} maxLine maximum number of lines */
export function drawParagraph() {}

@param {*} width width @param {*} height height @return {*} canvasAndCtx Canvas information */
export function initCanvasContext(width, height) {
  return [canvas, ctx];
}
Copy the code

These four core methods cover almost all the needs of poster drawing class: image, paragraph text, background container, canvas creation. In addition, the Canvas related API has been collected. Developers do not need to pay attention to the annoying Canvas API, just need to measure the size and position on the design draft, and can absolutely locate the corresponding elements on the canvas.

Implementation in business (pseudocode) :

Promise.all([
      canvasUtils.loadUrlImage(mainCoverImg),
      canvasUtils.loadBase64Image(cardInfo.qrCode),
    ])
      .then(([cover, qrCode, shopnameIcon, titleIcon]) = > {
  const [canvas, ctx] = canvasUtils.initCanvasContext(325.564);

  // Draw the bottom boxcanvasUtils.drawRoundedRectangle(ctx, ... sizeMapValue.base);// Draw the cover imagecanvasUtils.drawImage(ctx, ... sizeMapValue.cover);// Draw the titlecanvasUtils.drawParagraph(ctx, ... sizeMapValue.title);// Draw the number of questionscanvasUtils.drawImage(ctx, ... sizeMapValue.titleIcon);// ...

  return canvas.toDataURL('image/png');
      })
Copy the code

Since the image entry is an IMG object, the image link needs to be loaded first, which is an asynchronous process, so at the beginning of the design, it was stipulated that all images should be promised before drawing at IMG.

Using this way to draw posters can achieve the basic needs, but there are some limitations.

Such as:

  • The image address needs to be loaded before drawing, which involves asynchronous and redundant operation
  • Has been adjusteddraw***Method, pass similar parameters, this is also redundant operation, using JSON configuration parameters is better?
  • What if the height of the generated image needs to be adaptive to the height of multiple child elements? This requires a lot of extra logic.
  • What if two different styles of text are horizontally centered? We’re going to have to do crazy calculations and then we’re going to have to do a lot of calculations in logic when it comes to the need for adaptive styles.

So, how to improve these problems and draw posters more elegantly on the front end?

How to define schema

Another reason for not using HTML2Canvas is that the library is based on htmlElement. Under the current situation of the company, JSX and VUE template syntax are not compatible, so code fragments cannot be reused. Another more important reason is that small programs cannot be used. And maximize compatibility across platforms?

Json is used to configure parameters to generate images.

Based on the schema:

{
  type: ' '.css: {},
  custom: null.// Custom callback
}
Copy the code

The previous core drawImage drawParagraph drawRoundedRectangle method was designed to draw images, words, and containers. Each of these three types has different additional configurations that require a different, more semantic schema.

Image:

{
  type: 'image'.css: {},
  url: ' '.mode: 'fill | contain'.custom: null};Copy the code

Text:

{
  type: 'text'.css: {},
  text: ' '.custom: null};Copy the code

Container:

{
  type: 'div'.css: {},
  mode: 'div | line'.children: [].custom: null,}Copy the code

A schema of div type is equivalent to a container with a children field, which is similar to the concept of DIV in HTML. Div can nest and bear more div, text and image to jointly build a complete node tree.

Json schema to describe a card pseudocode:

{
    type: 'div'.css: {},
    children: [{type: 'div'.css: {},
        children: [{type: 'text'.css: {},
            text: 'Text one'
          },
          {
            type: 'image'.css: {},
            url: 'cdn.image.com/test1'.mode: 'contain'}]}, {type: 'text'.css: {},
        text: 'Lots of words, lots of words, lots of words.']}},Copy the code

Using JSON Schema to describe views has solved several limitations of the previous Canvas-Utils scheme.

The image address needs to be loaded before drawing, which involves asynchronous and redundant operation

The image is passed either a URL or a base64 string, and the load is done internally.

Keep calling draw*** and passing similar parameters. This is also redundant operation. Would it be better to use JSON to configure parameters?

All method calls are replaced by type, the size and location information that must be passed

canvasUtils.drawParagraph(ctx, cardInfo.title, 14, 380, 285, 14, undefined, undefined, undefined, 20, 2);

Replaced by a CSS field:

{
  type: 'text'.css: {
    width: '285px'.height: '14px'.x: '14px'.y: '380px'. },text: cardInfo.title,
  custom: null};Copy the code

Absolutely locate the flaws in the layout system

The function of the current schema definition is essentially no different from that of the previous Canvas-utils, except that it simplifies the use of posture. All nodes are positioned in absolute terms. We need to manually pass in the size information (width height) and position information (x and y) of all nodes. Almost every library like jsonToCanvas is designed this way, but it doesn’t address the limitations we mentioned.

  • What if the height of the generated image needs to be adaptive to the height of multiple child elements? This requires a lot of extra logic.
  • What if two different styles of text are horizontally centered? We’re going to have to do crazy calculations and then we’re going to have to do a lot of calculations in logic when it comes to the need for adaptive styles.

For example, the layout below is horizontal, with different text sizes and styles, and the number of text is customized:

We need to calculate width height x and y in real time, and then pass in the CSS field, which is still a huge amount of work.

Since our schema describes image structure (nesting) closely to HTML, why not our CSS field schema closely to real CSS?

With margin block-streaming layout and inline-block horizontal layout, the previous absolute positioning is changed to the CSS default relative positioning, mimicking the ability of CSS.

More important is to simulate the implementation of CSS properties of the powerful inheritance ability, so that we define a node CSS properties, do not have to write all kinds of properties again, directly rely on the parent node CSS property inheritance.

The schema exposed to the user needs to be smart enough to eat the requirements calculation inside the component.

Original definition:

 {
  "type": "div"."css": {
    "width": "200px"."height": "200px"."x": "0px"."y": "0px",},"children": [{"type": "text"."css": {
        "width": "Dynamic computation"."height": "Dynamic computation"."x": "Dynamic computation"."y": "Dynamic computation"."fontSize": "12px"
      },
      "text": "Custom copy:"
    },
    {
      "type": "text"."css": {
        "width": "Dynamic computation"."height": "Dynamic computation"."x": "Dynamic computation"."y": "Dynamic computation"."fontSize": "16px"."color": "red"
      },
      "text": "I follow this picture."
    },
    {
      "type": "image"."css": {
        "width": "15px"."height": "15px",},"url": "https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg"."mode": "contain"}}]Copy the code

Definition of smarter:

 {
  "type": "div"."css": {
    "width": "200px"."height": "200px",},"children": [{"type": "text"."css": {
        "display": "inline-block"."marginTop": "3px",},"text": "Custom copy:"
    },
    {
      "type": "text"."css": {
        "display": "inline-block"."fontSize": "16px"."color": "red"
      },
      "text": "I follow this picture."
    },
    {
      "type": "image"."css": {
        "width": "15px"."height": "15px"."display": "inline-block"
      },
      "url": "https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg"."mode": "contain"}}]Copy the code

We can see that the optimized version does not need to specify the width and height of the text, nor does it need to specify the position of the image information, just like writing native CSS HTML.

Optimize CSS Schema to handle dynamic sizing requirements

Since we need to rely on CSS capabilities, the DEFINITION of CSS schema should refer to the CSS2.1 specification. The CSS Schema we define is a subset of the CSS2.1 specification.

So let’s find out which sets in the specification are applicable to our case.

box model

www.w3.org/TR/CSS2/box…

CSS properties related to the box model

export interface IBoxModel {
  marginLeft: string;
  marginRight: string;
  marginTop: string;
  marginBottom: string;
  borderWidth: string;
  borderColor: string;
  borderStyle: 'solid' | 'dashed';
  borderRadius: string | undefined;
  boxShadow: string | undefined;
  customVerticalAlign: 'down' | 'top' | 'center';
  customAlign: 'left' | 'right' | 'center';
}
Copy the code

visual formatting model

www.w3.org/TR/CSS2/vis…

The visual formatting model is also the most important model in the CSS specification after the Box model. It describes how elements based on the box model are arranged in the visual window, such as position to describe absolute or relative positioning. Display: block | inline – block used to describe arranged vertically or horizontally.

Extract some of the required attributes:

export interface IVisFormatModel {
  width: string;
  height: string;
  maxWidth: string | undefined;
  maxHeight: string | undefined;
  minWidth: string;
  minHeight: string;
  position: 'absolute' | 'relative';
  top: string | undefined;
  left: string | undefined;
  right: string | undefined;
  bottom: string | undefined;
  display: 'block' | 'inline-block';
}
Copy the code

Colors and Backgrounds

www.w3.org/TR/CSS2/col…

Used to describe colors and backgrounds

export interface IColorAndBg {
  color: string;
  backgroundColor: string;
}
Copy the code

Fonts

www.w3.org/TR/CSS2/fon…

Used to describe the specific style, size, font, etc. of a single text.

export interface IFonts {
  lineHeight: string | undefined; // Line-height should belong to the Visual formatting model, but unlike traditional CSS, we stipulate that text cannot be written in divs
  fontStyle: string;
  fontFamily: string;
  fontWeight: number;
  fontSize: string;
}
Copy the code

Text

www.w3.org/TR/CSS2/tex…

Unlike Fonts, this specification describes how words are arranged, such as how they are arranged, whether or not they are underlined, etc.

export interface IText {
  textAlign: 'left' | 'right' | 'center';
  lineClamp: number | undefined; // Not in cSS2.1 specification, convenient to describe a few lines of text interception display [...]
  textDecoration: 'line-through' | undefined;
}
Copy the code

Drawing library realization process, calculation box model

No matter how user friendly our CSS Schema definition is, we still need to pass in the absolute positioning size and location when we finally call the Canvas API from within the component.

After defining the schema of the element type and the SCHEMA of CSS, what needs to be realized is to calculate the box model size of each node according to the CSS properties of the node inside the component, and then draw the final canvas from the final box model data.

Overall process:

The box model data is calculated according to CSS, which is the biggest step in drawing library code. The following is the calculation flow of the compute box model.

const defaultConfig = canvasWrap.setDefault(copyConfig);

const inlineBlockConfig = canvasWrap.setInlineBlock(defaultConfig);

const widthConfig = canvasWrap.addWidth(inlineBlockConfig);

const heightConfig = canvasWrap.addHeight(widthConfig);

const originConfig = canvasWrap.addOrigin(heightConfig);
Copy the code

SetDefault Sets the default value

Because the Schema allows some fields not to be passed, the first step is to recursively traverse the incoming data source, assigning the default value to the input parameter.

SetInlineBlock modifies the structure of an inline-block element

As shown in the figure, the setInlineBlock method aggregates the sequential inline-block nodes, inserts a new, empty div into the original position, and inserts the inline-block nodes as children. This is done to facilitate subsequent width height calculations.

AddWidth computes the width of all nodes

All nodes are iterated, and if it is found to be a div with children, the recursive traversal continues.

To simulate the native CSS features, if the current node is set to width, then the current width, otherwise, the parent node calculated width.

There are also a number of CSS attributes that affect the final calculation of width, such as minWidth, maxWidth, and whether child node elements are inline-block.

If the current type is text, and width is not set, call ctx.measureText(Content).width provided by canvas. Go get width.

The calculated width will be combined with CSS attributes such as margin and border to calculate the width of various box models again.

const sumWidth = calRealdemension(sumWidth, [css.minWidth, css.maxWidth]);
const layerWidth = sumPixels(sumWidth, marginWidth);
const contentWidth = minusPixels(sumWidth, addedBorderWidth);

addBoxWidth(element, sumWidth);
addLayerWidth(element, layerWidth);
addContentWidth(element, contentWidth);
Copy the code

The computed data is assigned directly to the current Config object, so that the parent node width can be used directly when we recurse to children.

AddHeight Calculates the height of all nodes

It is similar to the calculated width and will not be described here.

AddOrigin calculates the location of all nodes

Since the size information of all nodes has been calculated, all nodes are recursively traversed, and the location information of all child nodes can be calculated based on the parent node.

Drawing a Canvas image

const images = canvasWrap.getImages(originConfig);

images.then(imgMap= > {
    resolve(canvasWrap.drawCanvas(originConfig, imgMap));
})
Copy the code

After obtaining the location and size information of all nodes and combining with the picture information of unified load, the picture can be drawn using the drawing method in Canvas-utils.

Custom slot custom

Finally, the custom field is used to define the schema, which can be passed back to the call function. The exposed parameter is CTX, which is used to call the Canvas drawing API, and the box model data of the node, so that the user can know the scope of the current node.

custom(canvas, ctx, config) {
  ctx.beginPath();
  ctx.moveTo(config.origin.x, config.origin.y);
  ctx.lineTo(50.40);
  ctx.stroke();
},
Copy the code

Attention points of Canvas drawing

Generate image blur problems

When we directly set width and height to the canvas, for example

<canvas width="200" height="200"></canvas>
Copy the code

What this actually tells the browser is to generate a canvas of 200×200 physical pixels in the form of a bitmap, which we can view as a picture.

If the logical width of the canvas is not artificially specified with CSS, the browser defaults to 200px x 200px.

We can directly imagine a 200×200 bitmap with CSS 200×200 Settings. This is equivalent to the high resolution 2-gram optimization problem known to front-end engineers.

The solution is similar to solving the 2-fold graph problem. Enlarge the width and height of the canvas by n times (n depends on window.devicepixelratio) and set the CSS to the original width and height.

function initCanvasContext(width: number, height: number) :HTMLCanvasElement.CanvasRenderingContext2D] {
  canvas.width = width * window.devicePixelRatio;
  canvas.height = height * window.devicePixelRatio;
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
  ctx.setTransform(ratio, 0.0, ratio, 0.0);
  return [canvas, ctx];
};
Copy the code

How to draw text paragraphs with Canvas

Use ctx.filltext (content, x, y); When drawing paragraphs, the y position is not below the text.

For example, if we draw two lines with y = 10 and 24, then draw the text with y = 24:

The reason is that canvas text has its own baseline rules

The default text base line is downward. Experiments have been done here, and the benchmarks differ across devices, including the Bottom Ideographic, but the Middel style is consistent across platforms.

So here’s a trick to keep the text in the middle.

ctx.textBaseline = 'middle'; // Adapt android ios text center problem

ctx.save();
ctx.translate(0, -(fontSize / 2)); // Adapt android ios text center problem
ctx.fillText(content, x, y);
ctx.restore();
Copy the code

Center the text reference line first, then change the coordinate system at the time of drawing the text, and change it to the original coordinate system after drawing.

Further

The effect of this set of drawing library is actually very similar to html2Canvas class library, but the form of Json2Canvas actually has other space can be imagined.

Such as

  • Json data matching layers can be generated directly using Sketch, and json data is suitable for different front-end frameworks.
  • Most of the implementation of this library is how to calculate the box model size position of each node, which is also platform-independent and can be quickly transferred to applets. Small program only compatible with the next drawing API can be.
  • If configuring JSON isn’t intuitive at the various front-end framework layers, you can create a few key components at the component layer<Div style={}> <Text style={}> <Image style={}>And then you can write canvas just like HTML. This is also similar to how HTML2Canvas is written.