Skip to content

Latest commit

 

History

History
409 lines (303 loc) · 28.6 KB

README.md

File metadata and controls

409 lines (303 loc) · 28.6 KB

Skia Canvas

Skia Canvas is a browser-less implementation of the HTML Canvas drawing API for Node.js. It is based on Google’s Skia graphics engine and as a result produces very similar results to Chrome’s <canvas> element.

While the primary goal of this project is to provide a reliable emulation of the standard API according to the spec, it also extends it in a number of areas that are more relevant to the generation of static graphics files rather than ‘live’ display in a browser.

In particular, Skia Canvas:

  • is fast and compact since all the heavy lifting is done by native code written in Rust and C++

  • can generate output in both raster (JPEG & PNG) and vector (PDF & SVG) image formats

  • can save images to files, return them as Buffers, or encode dataURL strings

  • can create multiple ‘pages’ on a given canvas and then output them as a single, multi-page PDF or an image-sequence saved to multiple files

  • fully supports the CSS filter effects image processing operators

  • offers rich typographic control including:

Roadmap

This project is newly-hatched and still has some obvious gaps to fill (feel free to pitch in!).

On the agenda for subsequent updates are:

  • Windows support & prebuilt binaries
  • Use neon Tasks to provide asynchronous file i/o
  • Add SVG image loading using the µsvg parser
  • Add a density argument to Canvas and/or the output methods to allow for scaling to other device-pixel-ratios

Installation

On macOS and Linux, installation should be as simple as:

$ npm install skia-canvas

This will download a pre-compiled library from the project’s most recent release. Note that these binaries are in an early state and currently only work with fairly recent systems. In particular, if using the library with Docker you’ll want to pick a base system from the last few years like node:buster or ubuntu:bionic.

If prebuilt binaries aren’t available for your system you’ll need to compile the portions of this library that directly interface with Skia. Start by installing:

  1. The Rust compiler and cargo package manager using rustup
  2. Python 2.7 (Python 3 is not supported by neon)
  3. The GNU make tool
  4. A C compiler toolchain like LLVM/Clang, GCC, or MSVC

Once all these dependencies are present, installing from npm should work (after a fairly lengthy compilation process).

Module Contents

The library exports a number of classes emulating familiar browser objects including:

In addition, the module contains:

  • loadImage() a utility function for loading Image objects asynchronously
  • FontLibrary a class allowing you to inspect the system’s installed fonts and load additional ones

Basic Usage

const {Canvas, loadImage} = require('skia-canvas'),
      rand = n => Math.floor(n * Math.random());

let canvas = new Canvas(600, 600),
    ctx = canvas.getContext("2d"),
    {width, height} = canvas;

// draw a sea of blurred dots filling the canvas
ctx.filter = 'blur(12px) hue-rotate(20deg)'
for (let i=0; i<800; i++){
  ctx.fillStyle = `hsl(${rand(40)}deg, 80%, 50%)`
  ctx.beginPath()
  ctx.arc(rand(width), rand(height), rand(20)+5, 0, 2*Math.PI)
  ctx.fill()
}

// mask all of the dots that don't overlap with the text
ctx.filter = 'none'
ctx.globalCompositeOperation = 'destination-in'
ctx.font='italic 480px Times, DejaVu Serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillText('¶', width/2, 0)

// draw a background behind the clipped text
ctx.globalCompositeOperation = 'destination-over'
ctx.fillStyle = '#182927'
ctx.fillRect(0,0, width,height)

// save the graphic...
canvas.saveAs("pilcrow.png")
// ...or use a shorthand for canvas.toBuffer("png")
fs.writeFileSync("pilcrow.png", canvas.png)
// ...or embed it in a string
console.log(`<img src="${canvas.toDataURL("png")}">`)

API Documentation

Most of your interaction with the canvas will actually be directed toward its ‘rendering context’, a supporting object you can acquire by calling the canvas’s getContext() method. Documentation for each of the context’s attributes is linked below—properties are printed in bold and methods have parentheses attached to the name. The instances where Skia Canvas’s behavior goes beyond the standard are marked by a ⚡ symbol (see the next section for details).

Canvas State Drawing Primitives Stroke & Fill Style Compositing Effects
canvas clearRect() fillStyle filter
globalAlpha drawImage() lineCap globalCompositeOperation
beginPath() fill() lineDashOffset shadowBlur
clip() fillRect() lineJoin shadowColor
isPointInPath() fillText() lineWidth shadowOffsetX
isPointInStroke() stroke() miterLimit shadowOffsetY
restore() strokeRect() strokeStyle
save() strokeText() getLineDash()
setLineDash()
Bezier Paths Typography Pattern & Image Transform
arc() direction imageSmoothingEnabled currentTransform
arcTo() font imageSmoothingQuality getTransform()
bezierCurveTo() fontVariant createImageData() resetTransform()
closePath() textAlign createLinearGradient() rotate()
ellipse() textBaseline createPattern() scale()
lineTo() textTracking createRadialGradient() setTransform()
moveTo() textWrap getImageData() transform()
quadraticCurveTo() measureText() putImageData() translate()
rect()

Non-standard extensions

Canvas

.pages

The canvas’s .pages attribute is an array of CanvasRenderingContext2D objects corresponding to each ‘page’ that has been created. The first page is added when the canvas is initialized and additional ones can be added by calling the newPage() method. Note that all the pages remain drawable persistently, so you don’t have to constrain yourself to modifying the ‘current’ page as you render your document or image sequence.

.pdf, .svg, .jpg, and .png

These properties are syntactic sugar for calling the toBuffer() method. Each returns a Node Buffer object with the contents of the canvas in the given format. If more than one page has been added to the canvas, only the most recent one will be included unless you’ve accessed the .pdf property in which case the buffer will contain a multi-page PDF.

newPage(width, height)

This method allows for the creation of additional drawing contexts that are fully independent of one another but will be part of the same output batch. It is primarily useful in the context of creating a multi-page PDF but can be used to create multi-file image-sequences in other formats as well. Creating a new page with a different size than the previous one will update the parent Canvas object’s .width and .height attributes but will not affect any other pages that have been created previously.

The method’s return value is a CanvasRenderingContext2D object which you can either save a reference to or recover later from the .pages array.

saveAs(filename, {format, quality})

The saveAs method takes a file path and writes the canvas’s current contents to disk. If the filename ends with an extension that makes its format clear, the second argument is optional. If the filename is ambiguous, you can pass an options object with a format string using names like "png" and "jpeg" or a full mime type like "application/pdf".

The quality option is a number between 0 and 100 that controls the level of JPEG compression both when making JPEG files directly and when embedding them in a PDF. If omitted, quality will default to 100 (lossless).

The way multi-page documents are handled depends on the filename argument. If the filename contains the string "{}", it will be used as template for generating a numbered sequence of files—one per page. If no curly braces are found in the filename, only a single file will be saved. That single file will be multi-page in the case of PDF output but for other formats it will contain only the most recently added page.

An integer can optionally be placed between the braces to indicate the number of padding characters to use for numbering. For instance "page-{}.svg" will generate files of the form page-1.svg whereas "frame-{4}.png" will generate files like frame-0001.png.

toBuffer(format, {quality, page})

Node Buffer objects containing various image formats can be created by passing either a format string like "svg" or a mime-type like "image/svg+xml". The optional quality argument behaves the same as in the saveAs method.

The optional page argument accepts an integer that allows for the individual selection of pages in a multi-page canvas. Note that page indexing starts with page 1 not 0. The page value can also be negative, counting from the end of the canvas’s .pages array. For instance, .toBuffer("png", {page:-1}) is equivalent to omitting page since they both yield the canvas’s most recently added page.

toDataURL(format, {quality, page})

This method accepts the same arguments and behaves similarly to .toBuffer. However instead of returning a Buffer, it returns a string of the form "data:<mime-type>;base64,<image-data>" which can be used as a src attribute in <img> tags, embedded into CSS, etc.

CanvasRenderingContext2D

.font

By default any line-height value included in a font specification (separated from the font size by a /) will be preserved but ignored. If the textWrap property is set to true, the line-height will control the vertical spacing between lines.

.fontVariant

The context’s .font property follows the CSS 2.1 standard and allows the selection of only a single font-variant type: normal vs small-caps. The full range of CSS 3 font-variant values can be used if assigned to the context’s .fontVariant property (presuming the currently selected font supports them). Note that setting .font will also update the current .fontVariant value, so be sure to set the variant after selecting a typeface.

.textTracking

To loosen or tighten letter-spacing, set the .textTracking property to an integer representing the amount of space to add/remove in terms of 1/1000’s of an ‘em’ (a.k.a. the current font size). Positive numbers will space out the text (e.g., 100 is a good value for setting all-caps) while negative values will pull the letters closer together (this is only rarely a good idea).

The tracking value defaults to 0 and settings will persist across changes to the .font property.

.textWrap

The standard canvas has a rather impoverished typesetting system, allowing for only a single line of text and an approach to width-management that horizontally scales the letterforms (a type-crime if ever there was one). Skia Canvas allows you to opt-out of this single-line world by setting the .textWrap property to true. Doing so affects the behavior of the fillText(), strokeText(), and measureText() methods as described below.

fillText(str, x, y, [width]) & strokeText(str, x, y, [width])

The text-drawing methods’ behavior is mostly standard unless .textWrap has been set to true, in which case there are 3 main effects:

  1. Manual line breaking via "\n" escapes will be honored rather than converted to spaces
  2. The optional width argument accepted by fillText, strokeText and measureText will be interpreted as a ‘column width’ and used to word-wrap long lines
  3. The line-height setting in the .font value will be used to set the inter-line leading rather than simply being ignored.

Even when .textWrap is false, the text-drawing methods will never choose a more-condensed weight or otherwise attempt to squeeze your entire string into the measure specified by width. Instead the text will be typeset up through the last word that fits and the rest will be omitted. This can be used in conjunction with the .lines property of the object returned by measureText() to incrementally lay out a long string into, for example, a multi-column layout with an even number of lines in each.

measureText(str, [width])

The measureText() method returns a TextMetrics object describing the dimensions of a run of text without actually drawing it to the canvas. Skia Canvas adds an additional property to the metrics object called .lines which contains an array describing the geometry of each line individually.

Each element of the array contains an object of the form:

{x, y, width, height, baseline, startIndex, endIndex}

The x, y, width, and height values define a rectangle that fully encloses the text of a given line relative to the ‘origin’ point you would pass to fillText() or strokeText() (and reflecting the context’s current .textBaseline setting).

The baseline value is a y-axis offset from the text origin to that particular line’s baseline.

The startIndex and endIndex values are the indices into the string of the first and last character that were typeset on that line.

Utilities

loadImage()

The included Image object behaves just like the one in browsers, which is to say that loading images can be verbose, fiddly, and callback-heavy. The loadImage() utility method wraps image loading in a Promise, allowing for more concise initialization. For instance the following snippets are equivalent:

let img = new Image()
img.onload = function(){
  ctx.drawImage(img, 100, 100)
}
img.src = 'https://example.com/icon.png'
let img = await loadImage('https://example.com/icon.png')
ctx.drawImage(img, 100, 100)

In addition to HTTP URLs, both loadImage() and the Image.src attribute will also accept data URLs, local file paths, and Buffer objects.

FontLibrary

The FontLibrary is a static class which does not need to be instantiated with new. Instead you can access the properties and methods on the global FontLibrary you import from the module and its contents will be shared across all canvases you create.

.families

The .families property contains a list of family names, merging together all the fonts installed on the system and any fonts that have been added manually through the FontLibrary.use() method. Any of these names can be passed to FontLibrary.family() for more information.

family(name)

If the name argument is the name of a known font family, this method will return an object with information about the available weights and styles. For instance, on my system FontLibrary.family("Avenir Next") returns:

{
  family: 'Avenir Next',
  weights: [ 100, 400, 500, 600, 700, 800 ],
  widths: [ 'normal' ],
  styles: [ 'normal', 'italic' ]
}

Asking for details about an unknown family will return undefined.

has(familyName)

Returns true if the family is installed on the system or has been added via FontLibrary.use().

use(familyName, [...fontPaths])

The FontLibrary.use() method allows you to dynamically load local font files and use them with your canvases. By default it will use whatever family name is in the font metadata, but this can be overridden by an alias you provide. Since font-wrangling can be messy, use can be called in a number of different ways:

with a list of file paths
// with default family name
FontLibrary.use([
  "fonts/Oswald-Regular.ttf",
  "fonts/Oswald-SemiBold.ttf",
  "fonts/Oswald-Bold.ttf",
])

// with an alias
FontLibrary.use("Grizwald", [
  "fonts/Oswald-Regular.ttf",
  "fonts/Oswald-SemiBold.ttf",
  "fonts/Oswald-Bold.ttf",
])
with a list of ‘glob’ patterns
// with default family name
FontLibrary.use(['fonts/Crimson_Pro/*.ttf'])

// with an alias
FontLibrary.use("Stinson", ['fonts/Crimson_Pro/*.ttf'])
multiple families with aliases
FontLibrary.use({
  Nieuwveen: ['fonts/AmstelvarAlpha-VF.ttf', 'fonts/AmstelvarAlphaItalic-VF.ttf'],
  Fairway: 'fonts/Raleway/*.ttf'
})

The return value will be either a list or an object (matching the style in which it was called) with an entry describing each font file that was added. For instance, one of the entries from the first example could be:

{
  family: 'Grizwald',
  weight: 600,
  style: 'normal',
  width: 'normal',
  file: 'fonts/Oswald-SemiBold.ttf'
}

Acknowledgements

This project is deeply indebted to the work of the Rust Skia project whose Skia bindings provide a safe and idiomatic interface to the mess of C++ that lies underneath.

Many thanks to the node-canvas developers for their terrific set of unit tests. In the absence of an Acid Test for canvas, these routines were invaluable.