@julusian/skia-canvas
Version:
A GPU-accelerated Canvas Graphics API for Node
1,070 lines (896 loc) • 36.5 kB
JavaScript
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
}