skia-canvas
Version:
A multi-threaded, GPU-accelerated, Canvas API for Node
328 lines (268 loc) • 11.1 kB
JavaScript
//
// Canvas object & export options
//
const {fileURLToPath} = require('url'),
{RustClass, core, inspect, argc, REPR} = require('./neon'),
{Image, ImageData, pixelSize, getSharp} = require('./imagery'),
{Path2D} = require('./path'),
{toSkMatrix} = require('./geometry')
class Canvas extends RustClass{
#contexts
constructor(width, height, {textContrast=0, textGamma=1.4, gpu=true}={}){
super(Canvas).alloc({textContrast, textGamma, gpu:!!gpu})
this.#contexts = []
Object.assign(this, {width, height})
}
getContext(kind){
return (kind=="2d") ? this.#contexts[0] || this.newPage() : null
}
get gpu(){ return this.prop('engine')=='gpu' }
set gpu(mode){ this.prop('engine', !!mode ? 'gpu' : 'cpu') }
get engine(){ return JSON.parse(this.prop('engine_status')) }
get width(){ return this.prop('width') }
set width(w){
this.prop('width', !Number.isNaN(+w) && +w>=0 ? w : 300)
if (this.#contexts[0]) this.getContext("2d").ƒ('resetSize', core(this))
}
get height(){ return this.prop('height') }
set height(h){
this.prop('height', !Number.isNaN(+h) && +h>=0 ? h : 150)
if (this.#contexts[0]) this.getContext("2d").ƒ('resetSize', core(this))
}
newPage(width, height){
const {CanvasRenderingContext2D} = require('./context')
let ctx = new CanvasRenderingContext2D(this)
this.#contexts.unshift(ctx)
if (arguments.length==2){
Object.assign(this, {width, height})
}
return ctx
}
get pages(){
return this.#contexts.slice().reverse()
}
get raw(){ return this.toBuffer("raw") }
get png(){ return this.toBuffer("png") }
get jpg(){ return this.toBuffer("jpg") }
get pdf(){ return this.toBuffer("pdf") }
get svg(){ return this.toBuffer("svg") }
get webp(){ return this.toBuffer("webp") }
// Warn about renamed methods but map them to the new names (for now)
saveAs(){ _deprecated('Canvas.saveAs()'); this.toFile(...arguments) }
saveAsSync(){ _deprecated('Canvas.saveAsSync()'); this.toFileSync(...arguments) }
toDataURLSync(){ _deprecated('Canvas.toDataURLSync()'); this.toURLSync(...arguments) }
toFile(filename, opts={}){
let {pages, padding, pattern, ...rest} = exportOptions(this, {filename}, opts),
args = [pages.map(core), pattern, padding, rest]
return this.ƒ("save", ...args)
}
toFileSync(filename, opts={}){
let {pages, padding, pattern, ...rest} = exportOptions(this, {filename}, opts)
this.ƒ("saveSync", pages.map(core), pattern, padding, rest)
}
toBuffer(extension="png", opts={}){
let {pages, ...rest} = exportOptions(this, {extension}, opts)
return this.ƒ("toBuffer", pages.map(core), rest)
}
toBufferSync(extension="png", opts={}){
let {pages, ...rest} = exportOptions(this, {extension}, opts)
return this.ƒ("toBufferSync", pages.map(core), rest)
}
toURL(extension="png", opts={}){
let {mime} = exportOptions(this, {extension}, opts),
buffer = this.toBuffer(extension, opts);
return buffer.then(data => `data:${mime};base64,${data.toString('base64')}`)
}
toURLSync(extension="png", opts={}){
let {mime} = exportOptions(this, {extension}, opts),
buffer = this.toBufferSync(extension, opts);
return `data:${mime};base64,${buffer.toString('base64')}`
}
// Match the browser API in only accepting a single optional quality argument
toDataURL(extension="png", quality){
if (quality!==undefined && typeof quality!=='number'){
throw TypeError("Expected a number in the range 0–1 for `quality` (use toURL() for additional rendering options)")
}
return this.toURLSync(extension, {quality})
}
toSharp({page, matte, msaa, density=1}={}){
const {Readable} = require('node:stream'),
sharp = getSharp(),
buffer = this.toBuffer("raw", {page, matte, density, msaa})
return Readable.from(
(async function * (){ yield buffer })()
).pipe(sharp({
raw: {width:this.width*density, height:this.height*density, channels:4}
}).withMetadata({density:density * 72}))
}
[REPR](depth, options) {
let {width, height, gpu, engine, pages} = this
return `Canvas ${inspect({width, height, gpu, engine, pages}, options)}`
}
}
class CanvasGradient extends RustClass{
constructor(style, ...coords){
super(CanvasGradient)
style = (style || "").toLowerCase()
if (['linear', 'radial', 'conic'].includes(style)) this.init(style, ...coords)
else throw new Error(`Function is not a constructor (use CanvasRenderingContext2D's "createConicGradient", "createLinearGradient", and "createRadialGradient" methods instead)`)
}
addColorStop(offset, color){
this.ƒ('addColorStop', ...arguments)
}
[REPR](depth, options) {
return `CanvasGradient (${this.ƒ("repr")})`
}
}
class CanvasPattern extends RustClass{
constructor(canvas, src, repeat){
repeat = [...arguments].slice(2)
super(CanvasPattern)
if (src instanceof Image){
let {width, height} = canvas
this.init('from_image', core(src), width, height, ...repeat)
}else if (src instanceof ImageData){
this.init('from_image_data', src, ...repeat)
}else if (src instanceof Canvas){
let ctx = src.getContext('2d')
this.init('from_canvas', core(ctx), ...repeat)
}else{
throw new Error("CanvasPatterns require a source Image or a Canvas")
}
}
setTransform(matrix) { this.ƒ('setTransform', toSkMatrix.apply(null, arguments)) }
[REPR](depth, options) {
return `CanvasPattern (${this.ƒ("repr")})`
}
}
class CanvasTexture extends RustClass{
constructor(spacing, {path, color, angle, line, cap="butt", outline=false, offset=0}={}){
super(CanvasTexture)
argc(arguments, 1)
let [x, y] = Array.isArray(offset) ? offset.concat(offset).slice(0, 2) : [offset, offset]
let [h, v] = Array.isArray(spacing) ? spacing.concat(spacing).slice(0, 2) : [spacing, spacing]
if (path!==undefined && !(path instanceof Path2D)){
throw TypeError("Expected a Path2D object for `path`")
}
path = core(path)
line = line != null ? line : (path ? 0 : 1)
angle = angle != null ? angle : (path ? 0 : -Math.PI / 4)
this.alloc(path, color, line, cap, angle, !!outline, h, v, x, y)
}
[REPR](depth, options) {
return `CanvasTexture (${this.ƒ("repr")})`
}
}
//
// Mime type <-> File extension mappings
//
class Format{
constructor(){
let png = "image/png",
jpg = "image/jpeg",
jpeg = "image/jpeg",
webp = "image/webp",
pdf = "application/pdf",
svg = "image/svg+xml",
raw = "application/octet-stream"
Object.assign(this, {
toMime: this.toMime.bind(this),
fromMime: this.fromMime.bind(this),
expected: `"png", "jpg", "webp", "raw", "pdf", or "svg"`,
formats: {png, jpg, jpeg, webp, raw, pdf, svg},
mimes: {[png]: "png", [jpg]: "jpg", [webp]: "webp", [raw]: "raw", [pdf]: "pdf", [svg]: "svg"},
})
}
toMime(ext){
return this.formats[(ext||'').replace(/^\./, '').toLowerCase()]
}
fromMime(mime){
return this.mimes[mime]
}
}
//
// Validation of the options dict shared by the `saveAs`, `toBuffer`, and `toDataURL` methods
//
const {basename, extname} = require('path')
function exportOptions(canvas, {filename='', extension=''}, opts){
// a single number will be interpreted as a quality setting
if (typeof opts=='number') opts = {quality:opts}
// unpack common export options
let {page, quality, matte, density, msaa, outline, downsample, colorType} = opts
// only allow format overrides in toFile()
let imageFormat = !!filename ? opts.format : undefined
if (filename instanceof URL){
if (filename.protocol=='file:') filename = fileURLToPath(filename)
else throw Error(`URLs must use 'file' protocol (got '${filename.protocol.replace(':', '')}')`)
}
// ensure the canvas has a context (so it can at least generate an empty image)
if (!canvas.pages.length) canvas.getContext("2d")
var {fromMime, toMime, expected} = new Format(),
ext = imageFormat || extension.replace(/@\d+x$/i,'') || extname(filename),
format = fromMime(toMime(ext) || ext),
mime = toMime(format),
pages = canvas.pages,
pp = pages.length
if (!ext) throw new Error(`Cannot determine image format (use a filename extension or 'format' argument)`)
if (!format) throw new Error(`Unsupported file format "${ext}" (expected ${expected})`)
let padding, isSequence, pattern = filename.replace(/{(\d*)}/g, (_, width) => {
isSequence = true
width = parseInt(width, 10)
padding = isFinite(width) ? width : isFinite(padding) ? padding : -1
return "{}"
})
// allow negative indexing if a specific page is specified
let idx = page > 0 ? page - 1
: page < 0 ? pp + page
: undefined;
if (isFinite(idx) && idx < 0 || idx >= pp) throw new RangeError(
pp == 1 ? `Canvas only has a ‘page 1’ (${idx} is out of bounds)`
: `Canvas has pages 1–${pp} (${idx} is out of bounds)`
)
pages = isFinite(idx) ? [pages[idx]]
: isSequence || format=='pdf' ? pages
: pages.slice(-1) // default to the 'current' context
// inherit text settings from the canvas (since they can't be changed on a per-render basis due to glyph caching)
const {textContrast, textGamma} = canvas.engine
if (quality===undefined){
quality = 0.92
}else{
if (typeof quality!='number' || !isFinite(quality) || quality<0 || quality>1){
throw new TypeError("Expected a number between 0.0–1.0 for `quality`")
}
}
if (density===undefined){
let m = (extension || basename(filename, ext)).match(/@(\d+)x$/i)
density = m ? parseInt(m[1], 10) : 1
}else if (typeof density!='number' || !Number.isInteger(density) || density<1){
throw new TypeError("Expected a non-negative integer for `density`")
}
if (msaa===undefined || msaa===true) {
msaa = undefined // use the default 4x msaa
}else if (!isFinite(+msaa) || +msaa<0){
throw new TypeError("The number of MSAA samples must be an integer ≥0")
}
if (colorType!==undefined){
pixelSize(colorType) // throw an error if invalid
}
// default to false, otherwise detect truthy
downsample = !!downsample
outline = !!outline
return {
filename, pattern, format, mime, pages, padding, quality, matte,
density, msaa, outline, textContrast, textGamma, downsample, colorType
}
}
// emit a deprecation warning, once per API per process
let _warnings = {
"Canvas.saveAs()": "Canvas.toFile()",
"Canvas.saveAsSync()": "Canvas.toFileSync()",
"Canvas.toDataURLSync()": "Canvas.toURLSync() (see also Canvas.toDataURL() which is now synchronous)",
}
function _deprecated(oldAPI){
let newAPI = _warnings[oldAPI]
if (newAPI) console.error(`Deprecation warning: ${oldAPI} has been renamed to ${newAPI} and will stop working in a future release.`)
delete _warnings[oldAPI]
}
module.exports = {Canvas, CanvasGradient, CanvasPattern, CanvasTexture, getSharp}