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:
- multi-line, word-wrapped text
- line-by-line text metrics
- small-caps, ligatures, and other opentype features accessible using standard font-variant syntax
- proportional letter-spacing (a.k.a. ‘tracking’) and leading
- support for variable fonts and transparent mapping of weight values
- use of non-system fonts loaded from local files
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
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:
- The Rust compiler and cargo package manager using
rustup
- Python 2.7 (Python 3 is not supported by neon)
- The GNU
make
tool - 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).
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
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")}">`)
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() |
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.
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.
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.
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
.
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.
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.
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.
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.
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.
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.
The text-drawing methods’ behavior is mostly standard unless .textWrap
has been set to true
, in which case there are 3 main effects:
- Manual line breaking via
"\n"
escapes will be honored rather than converted to spaces - The optional
width
argument accepted byfillText
,strokeText
andmeasureText
will be interpreted as a ‘column width’ and used to word-wrap long lines - 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.
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.
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.
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.
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.
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
.
Returns true
if the family is installed on the system or has been added via FontLibrary.use()
.
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 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 default family name
FontLibrary.use(['fonts/Crimson_Pro/*.ttf'])
// with an alias
FontLibrary.use("Stinson", ['fonts/Crimson_Pro/*.ttf'])
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'
}
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.