UNPKG

skia-canvas

Version:

A GPU-accelerated Canvas Graphics API for Node

181 lines (156 loc) 6.46 kB
// // Image & ImageData // "use strict" const {RustClass, core, readOnly, inspect, neon, REPR} = require('./neon'), {EventEmitter} = require('events'), {readFile} = require('fs/promises'), get = require('simple-get') const loadImage = src => Object.assign(new Image(), {src}).decode() const loadImageData = (src, ...args) => new Promise((res,rej) => Image.fetchData(src, ({data}) => res(new ImageData(data, ...args)), err => rej(err)) ) class Image extends RustClass { #fetch #err constructor() { super(Image).alloc() } get complete(){ return this.prop('complete') } get height(){ return this.prop('height') } get width(){ return this.prop('width') } #onload get onload(){ return this.#onload } set onload(cb){ if (this.#onload) this.off('load', this.#onload) this.#onload = typeof cb=='function' ? cb : null if (this.#onload) this.on('load', this.#onload) } #onerror get onerror(){ return this.#onerror } set onerror(cb){ if (this.#onerror) this.off('error', this.#onerror) this.#onerror = typeof cb=='function' ? cb : null if (this.#onerror) this.on('error', this.#onerror) } get src(){ return this.prop('src') } set src(src){ const request = this.#fetch = {}, // use an empty object as a unique token loaded = ({data, src}) => { if (request === this.#fetch){ // confirm this is the most recent request with === this.#fetch = undefined this.prop("src", src) this.#err = this.prop("data", data) ? null : new Error("Could not decode image data") if (this.#err) this.emit('error', this.#err) else this.emit('load', this) } }, failed = (err) => { this.#fetch = undefined this.#err = err this.prop("data", Buffer.alloc(0)) this.emit('error', err) } this.prop("src", typeof src=='string' ? src : '') Image.fetchData(src, loaded, failed) } static fetchData(src, ok, fail){ if (Buffer.isBuffer(src)) { // already loaded ok({data:src, src:''}) } else if (typeof src != 'string') { fail(new Error("'src' property value is neither string nor Buffer type.'")) } else if (src.startsWith('data:')) { // data URI let [header, mime, enc] = src.slice(0, 40).match(/^\s*data:(?<mime>[^;]*);(?:charset=)?(?<enc>[^,]*),/) || [] if (!mime || !enc){ throw new Error(`Invalid data URI header`) } else { let content = src.slice(header.length) if (enc.toLowerCase() != 'base64'){ content = decodeURIComponent(content) } ok({data:Buffer.from(content, enc), src:''}) } } else if (/^\s*https?:\/\//.test(src)) { // remote URL get.concat(src, (err, res, data) => { let code = (res || {}).statusCode if (err) fail(err) else if (code < 200 || code >= 300) { fail(new Error(`Failed to load image from "${src}" (error ${code})`)) } else { ok({data, src}) } }) } else { // local file path readFile(src).then(data => ok({data, src})).catch(e => fail(e)) } } decode(){ return this.#fetch ? new Promise((res, rej) => this.once('load', res).once('error', rej) ) : this.#err ? Promise.reject(this.#err) : this.complete ? Promise.resolve(this) : Promise.reject(new Error("Image source not set")) } [REPR](depth, options) { let {width, height, complete, src} = this options.maxStringLength = src.match(/^data:/) ? 128 : Infinity; return `Image ${inspect({width, height, complete, src}, options)}` } } // Mix the EventEmitter properties into Image Object.assign(Image.prototype, EventEmitter.prototype) class ImageData{ constructor(...args){ if (args[0] instanceof ImageData){ var {data, width, height, colorSpace, colorType, bytesPerPixel} = args[0] }else if (args[0] instanceof Image){ var [image, {colorSpace='srgb', colorType='rgba'}={}] = args, {width, height} = image, bytesPerPixel = pixelSize(colorType), buffer = neon.Image.pixels(core(image), {colorType}), data = new Uint8ClampedArray(buffer) }else if (args[0] instanceof Uint8ClampedArray || args[0] instanceof Buffer){ var [data, width, height, {colorSpace='srgb', colorType='rgba'}={}] = args, bytesPerPixel = pixelSize(colorType) // validates the string as side effect height = height || data.length / width / bytesPerPixel data = data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data) if (data.length / bytesPerPixel != width * height){ throw new Error("ImageData dimensions must match buffer length") } }else{ var [width, height, {colorSpace='srgb', colorType='rgba'}={}] = args, bytesPerPixel = pixelSize(colorType) } if (!['srgb'].includes(colorSpace)){ // TODO: add display-p3 when supported… throw new Error(`Unsupported colorSpace: ${colorSpace}`) } if (!Number.isInteger(width) || !Number.isInteger(height) || width < 0 || height < 0){ throw new Error("ImageData dimensions must be positive integers") } readOnly(this, "colorSpace", colorSpace) readOnly(this, "colorType", colorType) readOnly(this, "width", width) readOnly(this, "height", height) readOnly(this, 'bytesPerPixel', bytesPerPixel) readOnly(this, "data", data || new Uint8ClampedArray(width * height * bytesPerPixel)) } [REPR](depth, options) { let {width, height, colorType, bytesPerPixel, data} = this return `ImageData ${inspect({width, height, colorType, bytesPerPixel, data}, options)}` } } function pixelSize(colorType){ const bpp = ["Alpha8", "Gray8", "R8UNorm"].includes(colorType) ? 1 : ["A16Float", "A16UNorm", "ARGB4444", "R8G8UNorm", "RGB565"].includes(colorType) ? 2 : [ "rgb", "rgba", "bgra", "BGR101010x", "BGRA1010102", "BGRA8888", "R16G16Float", "R16G16UNorm", "RGB101010x", "RGB888x", "RGBA1010102", "RGBA8888", "RGBA8888", "SRGBA8888" ].includes(colorType) ? 4 : ["R16G16B16A16UNorm", "RGBAF16", "RGBAF16Norm"].includes(colorType) ? 8 : colorType=="RGBAF32" ? 16 : 0 if (!bpp) throw new TypeError(`Unknown colorType: ${colorType}`) return bpp } module.exports = {Image, ImageData, loadImage, loadImageData, pixelSize}