UNPKG

@julusian/skia-canvas

Version:

A GPU-accelerated Canvas Graphics API for Node

1,070 lines (896 loc) 36.5 kB
"use strict" const fs = require('fs'), {EventEmitter} = require('events'), {inspect} = require('util'), {sync:glob, hasMagic} = require('glob'), get = require('simple-get'), geometry = require('./geometry'), css = require('./css'), io = require('./io'), REPR = inspect.custom; // // Neon <-> Node interface // const ø = Symbol.for('📦'), // the attr containing the boxed struct core = (obj) => (obj||{})[ø], // dereference the boxed struct wrap = (type, struct) => { // create new instance for struct let obj = internal(Object.create(type.prototype), ø, struct) return struct && internal(obj, 'native', neon[type.name]) }, neon = Object.entries(require('./v6')).reduce( (api, [name, fn]) => { let [_, struct, getset, attr] = name.match(/(.*?)_(?:([sg]et)_)?(.*)/), cls = api[struct] || (api[struct] = {}), slot = getset ? (cls[attr] || (cls[attr] = {})) : cls slot[getset || attr] = fn return api }, {}) class RustClass{ constructor(type){ internal(this, 'native', neon[type.name]) } alloc(...args){ return this.init('new', ...args) } init(fn, ...args){ return internal(this, ø, this.native[fn](null, ...args)) } ref(key, val){ return arguments.length > 1 ? this[Symbol.for(key)] = val : this[Symbol.for(key)] } prop(attr, ...vals){ let getset = arguments.length > 1 ? 'set' : 'get' return this.native[attr][getset](this[ø], ...vals) } ƒ(fn, ...args){ try{ return this.native[fn](this[ø], ...args) }catch(error){ Error.captureStackTrace(error, this.ƒ) throw error } } } // shorthands for attaching read-only attributes const readOnly = (obj, attr, value) => ( Object.defineProperty(obj, attr, {value, writable:false, enumerable:true}) ) const internal = (obj, attr, value) => ( Object.defineProperty(obj, attr, {value, writable:false, enumerable:false}) ) // convert arguments list to a string of type abbreviations function signature(args){ return args.map(v => (Array.isArray(v) ? 'a' : {string:'s', number:'n', object:'o'}[typeof v] || 'x')).join('') } const toString = val => typeof val=='string' ? val : new String(val).toString() // // Helpers to reconcile Skia and DOMMatrix’s disagreement about row/col orientation // function toSkMatrix(jsMatrix){ if (Array.isArray(jsMatrix) && jsMatrix.length==6){ var [a, b, c, d, e, f, m14, m24, m44] = jsMatrix.concat(0, 0, 1) }else if (jsMatrix instanceof geometry.DOMMatrix){ var {a, b, c, d, e, f, m14, m24, m44} = jsMatrix } return [a, c, e, b, d, f, m14, m24, m44] } function fromSkMatrix(skMatrix){ let [a, b, c, d, e, f, p0, p1, p2] = skMatrix return new geometry.DOMMatrix([ a, d, 0, p0, b, e, 0, p1, 0, 0, 1, 0, c, f, 0, p2 ]) } // // Windows & Event Handling // class App extends RustClass{ #running #fps constructor(){ super(App) this.#running = false this.#fps = 60 } get windows(){ return [...GUI.windows] } get running(){ return this.#running } get fps(){ return this.#fps } set fps(rate){ if (rate >= 1 && rate != this.#fps){ this.#fps = this.ƒ('setRate', rate) } } launch(){ if (this.#running) return console.error('Application is already running') this.#running = true clearTimeout(GUI.launcher) // begin event loop (and never return) this.ƒ("launch", args => { let {ui, state, geom} = JSON.parse(args) // in the initial roundtrip only, merge the autogenerated window locations with the specs for (const [id, {top, left}] of Object.entries(geom || {})){ GUI.getWindow(id, win => { win.left = win.left || left win.top = win.top || top }) } // update local state based on ui modifications (and evict GUI.windows that have been closed) if (state) GUI.windows = GUI.windows.filter(win => { return win.state.id in (state || {}) && Object.assign(win, state[win.state.id]) }) // deliver ui events to corresponding windows for (const [id, events] of Object.entries(ui || {})){ GUI.getWindow(id, (win, frame) => { let modifiers = {} for (const [[type, e]] of events.map(o => Object.entries(o))){ switch(type){ case 'modifiers': let {ctrl:ctrlKey, alt:altKey, logo:metaKey, shift:shiftKey} = e modifiers = {ctrlKey, altKey, metaKey, shiftKey} break case 'mouse': let {button, x, y, pageX, pageY} = e e.events.forEach(type => win.emit(type, {x, y, pageX, pageY, button, ...modifiers})) break case 'input': win.emit(type, {value:e, code:e.charCodeAt(), ...modifiers}) break case 'keyboard': let {event, key, code, repeat} = e, defaults = true; win.emit(event, {key, code, repeat, ...modifiers, preventDefault:() => defaults = false }) // apply default keybindings unless e.preventDefault() was run if (defaults && event=='keydown' && !repeat){ let {ctrlKey, altKey, metaKey} = modifiers if ( (metaKey && key=='W') || (ctrlKey && key=='C') || (altKey && key=='F4') ){ win.close() }else if ( (metaKey && key=='F') || (altKey && key=='F8') ){ win.fullscreen = !win.fullscreen } } break case 'focus': if (e) win.emit('focus') else win.emit('blur') break case 'resize': if (win.fit == 'resize'){ win.ctx.prop('size', e.width, e.height) win.canvas.prop('width', e.width) win.canvas.prop('height', e.height) } win.emit(type, e) break case 'move': case 'wheel': win.emit(type, e) break case 'fullscreen': win.emit(type, {enabled: e}) break default: console.log(type, e); } } }) } // provide frame updates to prompt redraws GUI.nextFrame((win, frame) => { if (frame==0) win.emit("setup") win.emit("frame", {frame}) if (win.listenerCount('draw')){ win.canvas.width = win.canvas.width win.emit("draw", {frame}) } }) // refresh lazily if not doing a flipbook animation this.ƒ('setRate', GUI.needsFrameUpdates() ? this.#fps : 0) // update the display return [ JSON.stringify( GUI.windows.map(win => win.state) ), GUI.windows.map(win => core(win.canvas.pages[win.page-1]) ) ] }) GUI.windows = [] // if the launch call exited, the last window was closed } quit(){ this.ƒ("quit") } } class Window extends EventEmitter{ static #kwargs = "left,top,width,height,title,page,background,fullscreen,cursor,fit,visible".split(/,/) #canvas #state // accept either ƒ(width, height, {…}) or ƒ({…}) constructor(width=512, height=512, opts={}){ if (!Number.isFinite(width) || !Number.isFinite(height)){ opts = [...arguments].slice(-1)[0] || {} width = opts.width || (opts.canvas || {}).width || 512 height = opts.height || (opts.canvas || {}).height || 512 } let canvas = (opts.canvas instanceof Canvas) ? opts.canvas : new Canvas(width, height) super(Window) this.#state = { title: "", visible: true, background: "white", fullscreen: false, page: canvas.pages.length, left: undefined, top: undefined, width, height, cursor: "default", cursorHidden: false, fit: "contain", id: Math.random().toString(16) } Object.assign(this, {canvas}, Object.fromEntries( Object.entries(opts).filter(([k, v]) => Window.#kwargs.includes(k) && v!==undefined) )) GUI.openWindow(this) } get state(){ return this.#state } get ctx(){ return this.#canvas.pages[this.page-1] } get canvas(){ return this.#canvas } set canvas(canvas){ if (canvas instanceof Canvas){ canvas.getContext("2d") // ensure it has at least one page this.#canvas = canvas this.#state.page = canvas.pages.length } } get visible(){ return this.#state.visible } set visible(flag){ this.#state.visible = !!flag } get fullscreen(){ return this.#state.fullscreen } set fullscreen(flag){ this.#state.fullscreen = !!flag } get title(){ return this.#state.title } set title(txt){ this.#state.title = (txt != null ? txt : '').toString() } get cursor(){ return this.#state.cursorHidden ? 'none' : this.#state.cursor } set cursor(icon){ if (css.cursor(icon)){ this.#state.cursorHidden = icon == 'none' if (icon != 'none') this.#state.cursor = icon } } get fit(){ return this.#state.fit } set fit(mode){ if (css.fit(mode)) this.#state.fit = mode } get left(){ return this.#state.left } set left(val){ if (Number.isFinite(val)) this.#state.left = val } get top(){ return this.#state.top } set top(val){ if (Number.isFinite(val)) this.#state.top = val } get width(){ return this.#state.width } set width(val){ if (Number.isFinite(val)) this.#state.width = val } get height(){ return this.#state.height } set height(val){ if (Number.isFinite(val)) this.#state.height = val } get page(){ return this.#state.page } set page(val){ if (val < 0) val += this.#canvas.pages.length + 1 let page = this.#canvas.pages[val-1] if (page && this.#state.page != val){ let [width, height] = page.prop('size') this.#canvas.prop('width', width) this.#canvas.prop('height', height) this.#state.page = val } } get background(){ return this.#state.background } set background(c){ this.#state.background = (c != null ? c : '').toString() } emit(type, e){ // report errors in event-handlers but don't crash try{ super.emit(type, Object.assign({target:this, type}, e)) } catch(err){ console.error(err) } } close(){ GUI.closeWindow(this) } [REPR](depth, options) { let info = Object.fromEntries(Window.#kwargs.map(k => [k, this.#state[k]])) return `Window ${inspect(info, options)}` } } const GUI = { App: new App(), windows: [], frames: new WeakMap(), launcher: null, nextFrame(callback){ GUI.windows.forEach(win => { let frame = GUI.frames.get(win) || 0 GUI.frames.set(win, frame + 1) callback(win, frame) }) }, needsFrameUpdates(){ let names = GUI.windows.map(win => win.eventNames()).flat() return (names.includes('frame') || names.includes('draw')) }, getWindow(id, callback){ GUI.windows.filter(w => w.state.id==id).forEach(win => callback(win)) }, openWindow(win){ GUI.windows.push(win) if (!GUI.launcher) GUI.launcher = setTimeout( () => GUI.App.launch() ) neon.App.openWindow(JSON.stringify(win.state), core(win.canvas.pages[win.state.page-1])) }, closeWindow(win){ GUI.windows = GUI.windows.filter(w => w !== win) neon.App.closeWindow(win.state.id) } } // // The Canvas API // class Canvas extends RustClass{ #contexts constructor(width, height){ super(Canvas).alloc() 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 width(){ return this.prop('width') } set width(w){ this.prop('width', (typeof w=='number' && !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', h = (typeof h=='number' && !Number.isNaN(h) && h>=0) ? h : 150) if (this.#contexts[0]) this.getContext("2d").ƒ('resetSize', core(this)) } newPage(width, height){ 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 png(){ return this.toBuffer("png") } get jpg(){ return this.toBuffer("jpg") } get pdf(){ return this.toBuffer("pdf") } get svg(){ return this.toBuffer("svg") } get async(){ return this.prop('async') } set async(flag){ if (!flag){ process.emitWarning("Use the saveAsSync, toBufferSync, and toDataURLSync methods instead of setting the Canvas `async` property to false", "DeprecationWarning") } this.prop('async', flag) } saveAs(filename, opts={}){ if (!this.async) return this.saveAsSync(...arguments) // support while deprecated opts = typeof opts=='number' ? {quality:opts} : opts let {format, quality, pages, padding, pattern, density, outline, matte} = io.options(this.pages, {filename, ...opts}), args = [pages.map(core), pattern, padding, format, quality, density, outline, matte] return this.ƒ("save", ...args) } saveAsSync(filename, opts={}){ opts = typeof opts=='number' ? {quality:opts} : opts let {format, quality, pages, padding, pattern, density, outline, matte} = io.options(this.pages, {filename, ...opts}) this.ƒ("saveSync", pages.map(core), pattern, padding, format, quality, density, outline, matte) } toBuffer(extension="png", opts={}){ if (!this.async) return this.toBufferSync(...arguments) // support while deprecated opts = typeof opts=='number' ? {quality:opts} : opts let {format, quality, pages, density, outline, matte} = io.options(this.pages, {extension, ...opts}), args = [pages.map(core), format, quality, density, outline, matte]; return this.ƒ("toBuffer", ...args) } toBufferSync(extension="png", opts={}){ opts = typeof opts=='number' ? {quality:opts} : opts let {format, quality, pages, density, outline, matte} = io.options(this.pages, {extension, ...opts}) return this.ƒ("toBufferSync", pages.map(core), format, quality, density, outline, matte) } toDataURL(extension="png", opts={}){ if (!this.async) return this.toDataURLSync(...arguments) // support while deprecated opts = typeof opts=='number' ? {quality:opts} : opts let {mime} = io.options(this.pages, {extension, ...opts}), buffer = this.toBuffer(extension, opts); return buffer.then(data => `data:${mime};base64,${data.toString('base64')}`) } toDataURLSync(extension="png", opts={}){ opts = typeof opts=='number' ? {quality:opts} : opts let {mime} = io.options(this.pages, {extension, ...opts}), buffer = this.toBufferSync(extension, opts); return `data:${mime};base64,${buffer.toString('base64')}` } [REPR](depth, options) { let {width, height, async, gpu, pages} = this return `Canvas ${inspect({width, height, async, gpu, 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){ if (offset>=0 && offset<=1) this.ƒ('addColorStop', offset, color) else throw new Error("Color stop offsets must be between 0.0 and 1.0") } [REPR](depth, options) { return `CanvasGradient (${this.ƒ("repr")})` } } class CanvasPattern extends RustClass{ constructor(src, repeat){ super(CanvasPattern) if (src instanceof Image){ this.init('from_image', core(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){ if (arguments.length>1) matrix = [...arguments] this.ƒ('setTransform', toSkMatrix(matrix)) } [REPR](depth, options) { return `CanvasPattern (${this.ƒ("repr")})` } } class CanvasTexture extends RustClass{ constructor(spacing, {path, line, color, angle, offset=0}={}){ super(CanvasTexture) let [x, y] = typeof offset=='number' ? [offset, offset] : offset.slice(0, 2) let [h, v] = typeof spacing=='number' ? [spacing, spacing] : spacing.slice(0, 2) path = core(path) line = line != null ? line : (path ? 0 : 1) angle = angle != null ? angle : (path ? 0 : -Math.PI / 4) this.alloc(path, color, line, angle, h, v, x, y) } [REPR](depth, options) { return `CanvasTexture (${this.ƒ("repr")})` } } class CanvasRenderingContext2D extends RustClass{ #canvas constructor(canvas){ try{ super(CanvasRenderingContext2D).alloc(core(canvas)) this.#canvas = new WeakRef(canvas) }catch(e){ throw new TypeError(`Function is not a constructor (use Canvas's "getContext" method instead)`) } } get canvas(){ return this.#canvas.deref() } // -- global state & content reset ------------------------------------------ reset(){ this.ƒ('reset') } // -- grid state ------------------------------------------------------------ save(){ this.ƒ('save') } restore(){ this.ƒ('restore') } get currentTransform(){ return fromSkMatrix( this.prop('currentTransform') ) } set currentTransform(matrix){ this.prop('currentTransform', toSkMatrix(matrix) ) } resetTransform(){ this.ƒ('resetTransform')} getTransform(){ return this.currentTransform } setTransform(matrix){ this.currentTransform = arguments.length > 1 ? [...arguments] : matrix } transform(a, b, c, d, e, f){ this.ƒ('transform', ...arguments)} translate(x, y){ this.ƒ('translate', ...arguments)} scale(x, y){ this.ƒ('scale', ...arguments)} rotate(angle){ this.ƒ('rotate', ...arguments)} createProjection(quad, basis){ return fromSkMatrix(this.ƒ("createProjection", [quad].flat(), [basis].flat())) } // -- bézier paths ---------------------------------------------------------- beginPath(){ this.ƒ('beginPath') } rect(x, y, width, height){ this.ƒ('rect', ...arguments) } arc(x, y, radius, startAngle, endAngle, isCCW){ this.ƒ('arc', ...arguments) } ellipse(x, y, xRadius, yRadius, rotation, startAngle, endAngle, isCCW){ this.ƒ('ellipse', ...arguments) } moveTo(x, y){ this.ƒ('moveTo', ...arguments) } lineTo(x, y){ this.ƒ('lineTo', ...arguments) } arcTo(x1, y1, x2, y2, radius){ this.ƒ('arcTo', ...arguments) } bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y){ this.ƒ('bezierCurveTo', ...arguments) } quadraticCurveTo(cpx, cpy, x, y){ this.ƒ('quadraticCurveTo', ...arguments) } conicCurveTo(cpx, cpy, x, y, weight){ this.ƒ("conicCurveTo", ...arguments) } closePath(){ this.ƒ('closePath') } isPointInPath(x, y){ return this.ƒ('isPointInPath', ...arguments) } isPointInStroke(x, y){ return this.ƒ('isPointInStroke', ...arguments) } roundRect(x, y, w, h, r){ let radii = css.radii(r) if (radii){ if (w < 0) radii = [radii[1], radii[0], radii[3], radii[2]] if (h < 0) radii = [radii[3], radii[2], radii[1], radii[0]] this.ƒ("roundRect", x, y, w, h, ...radii.map(({x, y}) => [x, y]).flat()) } } // -- using paths ----------------------------------------------------------- fill(path, rule){ if (path instanceof Path2D) this.ƒ('fill', core(path), rule) else this.ƒ('fill', path) // 'path' is the optional winding-rule } stroke(path, rule){ if (path instanceof Path2D) this.ƒ('stroke', core(path), rule) else this.ƒ('stroke', path) // 'path' is the optional winding-rule } clip(path, rule){ if (path instanceof Path2D) this.ƒ('clip', core(path), rule) else this.ƒ('clip', path) // 'path' is the optional winding-rule } // -- shaders --------------------------------------------------------------- createPattern(image, repetition){ return new CanvasPattern(...arguments) } createLinearGradient(x0, y0, x1, y1){ return new CanvasGradient("Linear", ...arguments) } createRadialGradient(x0, y0, r0, x1, y1, r1){ return new CanvasGradient("Radial", ...arguments) } createConicGradient(startAngle, x, y){ return new CanvasGradient("Conic", ...arguments) } createTexture(spacing, options){ return new CanvasTexture(spacing, options) } // -- fill & stroke --------------------------------------------------------- fillRect(x, y, width, height){ this.ƒ('fillRect', ...arguments) } strokeRect(x, y, width, height){ this.ƒ('strokeRect', ...arguments) } clearRect(x, y, width, height){ this.ƒ('clearRect', ...arguments) } set fillStyle(style){ let isShader = style instanceof CanvasPattern || style instanceof CanvasGradient || style instanceof CanvasTexture, [ref, val] = isShader ? [style, core(style)] : [null, style] this.ref('fill', ref) this.prop('fillStyle', val) } get fillStyle(){ let style = this.prop('fillStyle') return style===null ? this.ref('fill') : style } set strokeStyle(style){ let isShader = style instanceof CanvasPattern || style instanceof CanvasGradient || style instanceof CanvasTexture, [ref, val] = isShader ? [style, core(style)] : [null, style] this.ref('stroke', ref) this.prop('strokeStyle', val) } get strokeStyle(){ let style = this.prop('strokeStyle') return style===null ? this.ref('stroke') : style } // -- line style ------------------------------------------------------------ getLineDash(){ return this.ƒ("getLineDash") } setLineDash(segments){ this.ƒ("setLineDash", segments) } get lineCap(){ return this.prop("lineCap") } set lineCap(style){ this.prop("lineCap", style) } get lineDashFit(){ return this.prop("lineDashFit") } set lineDashFit(style){ this.prop("lineDashFit", style) } get lineDashMarker(){ return wrap(Path2D, this.prop("lineDashMarker")) } set lineDashMarker(path){ this.prop("lineDashMarker", path instanceof Path2D ? core(path) : path) } get lineDashOffset(){ return this.prop("lineDashOffset") } set lineDashOffset(offset){ this.prop("lineDashOffset", offset) } get lineJoin(){ return this.prop("lineJoin") } set lineJoin(style){ this.prop("lineJoin", style) } get lineWidth(){ return this.prop("lineWidth") } set lineWidth(width){ this.prop("lineWidth", width) } get miterLimit(){ return this.prop("miterLimit") } set miterLimit(limit){ this.prop("miterLimit", limit) } // -- imagery --------------------------------------------------------------- get imageSmoothingEnabled(){ return this.prop("imageSmoothingEnabled")} set imageSmoothingEnabled(flag){ this.prop("imageSmoothingEnabled", !!flag)} get imageSmoothingQuality(){ return this.prop("imageSmoothingQuality")} set imageSmoothingQuality(level){ this.prop("imageSmoothingQuality", level)} putImageData(imageData, ...coords){ this.ƒ('putImageData', imageData, ...coords) } createImageData(width, height){ return new ImageData(width, height) } getImageData(x, y, width, height){ let w = Math.floor(width), h = Math.floor(height), buffer = this.ƒ('getImageData', x, y, w, h); return new ImageData(buffer, w, h) } drawImage(image, ...coords){ if (image instanceof Canvas){ this.ƒ('drawImage', core(image.getContext('2d')), ...coords) }else if (image instanceof Image){ this.ƒ('drawImage', core(image), ...coords) }else{ throw new Error("Expected an Image or a Canvas argument") } } drawCanvas(image, ...coords){ if (image instanceof Canvas){ this.ƒ('drawCanvas', core(image.getContext('2d')), ...coords) }else{ this.drawImage(image, ...coords) } } // -- typography ------------------------------------------------------------ get font(){ return this.prop('font') } set font(str){ this.prop('font', css.font(str)) } get textAlign(){ return this.prop("textAlign") } set textAlign(mode){ this.prop("textAlign", mode) } get textBaseline(){ return this.prop("textBaseline") } set textBaseline(mode){ this.prop("textBaseline", mode) } get direction(){ return this.prop("direction") } set direction(mode){ this.prop("direction", mode) } measureText(text, maxWidth){ text = this.textWrap ? text : text + '\u200b' // include trailing whitespace by default let [metrics, ...lines] = this.ƒ('measureText', toString(text), maxWidth) return new TextMetrics(metrics, lines) } fillText(text, x, y, maxWidth){ this.ƒ('fillText', toString(text), x, y, maxWidth) } strokeText(text, x, y, maxWidth){ this.ƒ('strokeText', toString(text), x, y, maxWidth) } outlineText(text){ let path = this.ƒ('outlineText', toString(text)) return path ? wrap(Path2D, path) : null } // -- non-standard typography extensions -------------------------------------------- get fontVariant(){ return this.prop('fontVariant') } set fontVariant(str){ this.prop('fontVariant', css.variant(str)) } get textTracking(){ return this.prop("textTracking") } set textTracking(ems){ this.prop("textTracking", ems) } get textWrap(){ return this.prop("textWrap") } set textWrap(flag){ this.prop("textWrap", !!flag) } // -- effects --------------------------------------------------------------- get globalCompositeOperation(){ return this.prop("globalCompositeOperation") } set globalCompositeOperation(blend){ this.prop("globalCompositeOperation", blend) } get globalAlpha(){ return this.prop("globalAlpha") } set globalAlpha(alpha){ this.prop("globalAlpha", alpha) } get shadowBlur(){ return this.prop("shadowBlur") } set shadowBlur(level){ this.prop("shadowBlur", level) } get shadowColor(){ return this.prop("shadowColor") } set shadowColor(color){ this.prop("shadowColor", color) } get shadowOffsetX(){ return this.prop("shadowOffsetX") } set shadowOffsetX(x){ this.prop("shadowOffsetX", x) } get shadowOffsetY(){ return this.prop("shadowOffsetY") } set shadowOffsetY(y){ this.prop("shadowOffsetY", y) } get filter(){ return this.prop('filter') } set filter(str){ this.prop('filter', css.filter(str)) } [REPR](depth, options) { let props = [ "canvas", "currentTransform", "fillStyle", "strokeStyle", "font", "fontVariant", "direction", "textAlign", "textBaseline", "textTracking", "textWrap", "globalAlpha", "globalCompositeOperation", "imageSmoothingEnabled", "imageSmoothingQuality", "filter", "shadowBlur", "shadowColor", "shadowOffsetX", "shadowOffsetY", "lineCap", "lineDashOffset", "lineJoin", "lineWidth", "miterLimit" ] let info = {} if (depth > 0 ){ for (var prop of props){ try{ info[prop] = this[prop] } catch{ info[prop] = undefined } } } return `CanvasRenderingContext2D ${inspect(info, options)}` } } const _expand = paths => [paths].flat(2).map(pth => hasMagic(pth) ? glob(pth) : pth).flat() class FontLibrary extends RustClass { constructor(){ super(FontLibrary) } get families(){ return this.prop('families') } has(familyName){ return this.ƒ('has', familyName) } family(name){ return this.ƒ('family', name) } use(...args){ let sig = signature(args) if (sig=='o'){ let results = {} for (let [alias, paths] of Object.entries(args.shift())){ results[alias] = this.ƒ("addFamily", alias, _expand(paths)) } return results }else if (sig.match(/^s?[as]$/)){ let fonts = _expand(args.pop()) let alias = args.shift() return this.ƒ("addFamily", alias, fonts) }else{ throw new Error("Expected an array of file paths or an object mapping family names to font files") } } reset(){ return this.ƒ('reset') } } class Image extends RustClass { constructor(){ super(Image).alloc() } get complete(){ return this.prop('complete') } get height(){ return this.prop('height') } get width(){ return this.prop('width') } get src(){ return this.prop('src') } set src(src){ var noop = () => {}, onload = img => fetch.emit('ok', img), onerror = err => fetch.emit('err', err), passthrough = fn => arg => { (fn||noop)(arg); delete this._fetch }, data if (this._fetch) this._fetch.removeAllListeners() let fetch = this._fetch = new EventEmitter() .once('ok', passthrough(this.onload)) .once('err', passthrough(this.onerror)) if (Buffer.isBuffer(src)){ [data, src] = [src, ''] } else if (typeof src != 'string'){ return } else if (/^\s*data:/.test(src)) { // data URI let split = src.indexOf(','), enc = src.lastIndexOf('base64', split) !== -1 ? 'base64' : 'utf8', content = src.slice(split + 1); data = Buffer.from(content, enc); } else if (/^\s*https?:\/\//.test(src)) { // remote URL get.concat(src, (err, res, data) => { let code = (res || {}).statusCode if (err) onerror(err) else if (code < 200 || code >= 300) { onerror(new Error(`Failed to load image from "${src}" (error ${code})`)) }else{ if (this.prop("data", data)) onload(this) else onerror(new Error("Could not decode image data")) } }) } else { // local file path data = fs.readFileSync(src); } this.prop("src", src) if (data){ if (this.prop("data", data)) onload(this) else onerror(new Error("Could not decode image data")) } } decode(){ return this._fetch ? new Promise((res, rej) => this._fetch.once('ok', res).once('err', rej) ) : this.complete ? Promise.resolve(this) : Promise.reject(new Error("Missing Source URL")) } [REPR](depth, options) { let {width, height, complete, src} = this options.maxStringLength = src.match(/^data:/) ? 128 : Infinity; return `Image ${inspect({width, height, complete, src}, options)}` } } class ImageData{ constructor(...args){ if (args[0] instanceof ImageData){ var {data, width, height} = args[0] }else if (args[0] instanceof Uint8ClampedArray || args[0] instanceof Buffer){ var [data, width, height] = args height = height || data.length / width / 4 if (data.length / 4 != width * height){ throw new Error("ImageData dimensions must match buffer length") } }else{ var [width, height] = args } if (!Number.isInteger(width) || !Number.isInteger(height) || width < 0 || height < 0){ throw new Error("ImageData dimensions must be positive integers") } readOnly(this, "width", width) readOnly(this, "height", height) readOnly(this, "data", new Uint8ClampedArray(data && data.buffer || width * height * 4)) } [REPR](depth, options) { let {width, height, data} = this return `ImageData ${inspect({width, height, data}, options)}` } } class Path2D extends RustClass{ static op(operation, path, other){ return wrap(Path2D, path.ƒ("op", core(other), operation)) } static interpolate(path, other, weight){ return wrap(Path2D, path.ƒ("interpolate", core(other), weight)) } static effect(effect, path, ...args){ return wrap(Path2D, path.ƒ(effect, ...args)) } constructor(source){ super(Path2D) if (source instanceof Path2D) this.init('from_path', core(source)) else if (typeof source == 'string') this.init('from_svg', source) else this.alloc() } // dimensions & contents get bounds(){ return this.ƒ('bounds') } get edges(){ return this.ƒ("edges") } get d(){ return this.prop("d") } set d(svg){ return this.prop("d", svg) } contains(x, y){ return this.ƒ("contains", x, y)} points(step=1){ return this.jitter(step, 0).edges .map(([verb, ...pts]) => pts.slice(-2)) .filter(pt => pt.length) } // concatenation addPath(path, matrix){ if (!(path instanceof Path2D)) throw new Error("Expected a Path2D object") if (matrix) matrix = toSkMatrix(matrix) this.ƒ('addPath', core(path), matrix) } // line segments moveTo(x, y){ this.ƒ("moveTo", ...arguments) } lineTo(x, y){ this.ƒ("lineTo", ...arguments) } closePath(){ this.ƒ("closePath") } arcTo(x1, y1, x2, y2, radius){ this.ƒ("arcTo", ...arguments) } bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y){ this.ƒ("bezierCurveTo", ...arguments) } quadraticCurveTo(cpx, cpy, x, y){ this.ƒ("quadraticCurveTo", ...arguments) } conicCurveTo(cpx, cpy, x, y, weight){ this.ƒ("conicCurveTo", ...arguments) } // shape primitives ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, isCCW){ this.ƒ("ellipse", ...arguments) } rect(x, y, width, height){this.ƒ("rect", ...arguments) } arc(x, y, radius, startAngle, endAngle){ this.ƒ("arc", ...arguments) } roundRect(x, y, w, h, r){ let radii = css.radii(r) if (radii){ if (w < 0) radii = [radii[1], radii[0], radii[3], radii[2]] if (h < 0) radii = [radii[3], radii[2], radii[1], radii[0]] this.ƒ("roundRect", x, y, w, h, ...radii.map(({x, y}) => [x, y]).flat()) } } // tween similar paths interpolate(path, weight){ return Path2D.interpolate(this, path, weight) } // boolean operations complement(path){ return Path2D.op("complement", this, path) } difference(path){ return Path2D.op("difference", this, path) } intersect(path){ return Path2D.op("intersect", this, path) } union(path){ return Path2D.op("union", this, path) } xor(path){ return Path2D.op("xor", this, path) } // path effects jitter(len, amt, seed){ return Path2D.effect("jitter", this, ...arguments) } simplify(rule){ return Path2D.effect("simplify", this, rule) } unwind(){ return Path2D.effect("unwind", this) } round(radius){ return Path2D.effect("round", this, radius) } offset(dx, dy){ return Path2D.effect("offset", this, dx, dy) } transform(matrix){ let terms = arguments.length > 1 ? [...arguments] : matrix return Path2D.effect("transform", this, toSkMatrix(terms)) } trim(...rng){ if (typeof rng[1] != 'number'){ if (rng[0] > 0) rng.unshift(0) else if (rng[0] < 0) rng.splice(1, 0, 1) } if (rng[0] < 0) rng[0] = Math.max(-1, rng[0]) + 1 if (rng[1] < 0) rng[1] = Math.max(-1, rng[1]) + 1 return Path2D.effect("trim", this, ...rng) } [REPR](depth, options) { let {d, bounds, edges} = this return `Path2D ${inspect({d, bounds, edges}, options)}` } } class TextMetrics{ constructor([ width, left, right, ascent, descent, fontAscent, fontDescent, emAscent, emDescent, hanging, alphabetic, ideographic ], lines){ readOnly(this, "width", width) readOnly(this, "actualBoundingBoxLeft", left) readOnly(this, "actualBoundingBoxRight", right) readOnly(this, "actualBoundingBoxAscent", ascent) readOnly(this, "actualBoundingBoxDescent", descent) readOnly(this, "fontBoundingBoxAscent", fontAscent) readOnly(this, "fontBoundingBoxDescent", fontDescent) readOnly(this, "emHeightAscent", emAscent) readOnly(this, "emHeightDescent", emDescent) readOnly(this, "hangingBaseline", hanging) readOnly(this, "alphabeticBaseline", alphabetic) readOnly(this, "ideographicBaseline", ideographic) readOnly(this, "lines", lines.map( ([x, y, width, height, baseline, startIndex, endIndex]) => ( {x, y, width, height, baseline, startIndex, endIndex} ))) } } const loadImage = src => Object.assign(new Image(), {src}).decode() module.exports = { Canvas, CanvasGradient, CanvasPattern, CanvasRenderingContext2D, CanvasTexture, TextMetrics, Image, ImageData, Path2D, Window, loadImage, ...geometry, FontLibrary: new FontLibrary(), App: GUI.App }