skia-canvas
Version:
A GPU-accelerated Canvas Graphics API for Node
383 lines (315 loc) • 12.2 kB
JavaScript
//
// Browser equivalents of the skia-canvas convenience initializers and polyfills for
// the Canvas object’s newPage & export methods
//
const _toURL_ = Symbol.for("toDataURL")
const loadImage = src => {
let img = Object.assign(new Image(), {crossOrigin:'Anonymous', src})
return img.decode().then(() => img)
}
const loadImageData = (src, width, height, settings) => fetch(src)
.then(resp => resp.arrayBuffer())
.then(buf => new ImageData(new Uint8ClampedArray(buf), width, height, settings))
class Canvas{
constructor(width, height){
let elt = document.createElement('canvas'),
pages = []
Object.defineProperty(elt, "async", {value:true, writable:false, enumerable:true})
for (var [prop, get] of Object.entries({
png: () => asBuffer(elt, 'image/png'),
jpg: () => asBuffer(elt, 'image/jpeg'),
pages: () => pages.concat(elt).map(c => c.getContext("2d")),
})) Object.defineProperty(elt, prop, {get})
return Object.assign(elt, {
width, height,
newPage(...size){
var {width, height} = elt,
page = Object.assign(document.createElement('canvas'), {width, height})
page.getContext("2d").drawImage(elt, 0, 0)
pages.push(page)
var [width, height] = size.length ? size : [width, height]
return Object.assign(elt, {width, height}).getContext("2d")
},
saveAs(filename, args){
args = typeof args=='number' ? {quality:args} : args
let opts = exportOptions(this.pages, {filename, ...args}),
{pattern, padding, mime, quality, matte, density, archive} = opts,
pages = atScale(opts.pages, density);
return padding==undefined ? asDownload(pages[0], mime, quality, matte, filename)
: asZipDownload(pages, mime, quality, matte, archive, pattern, padding)
},
toBuffer(extension="png", args={}){
args = typeof args=='number' ? {quality:args} : args
let opts = exportOptions(this.pages, {extension, ...args}),
{mime, quality, matte, pages, density} = opts,
canvas = atScale(pages, density, matte)[0]
return asBuffer(canvas, mime, quality, matte)
},
[_toURL_]: elt.toDataURL.bind(elt),
toDataURL(extension="png", args={}){
args = typeof args=='number' ? {quality:args} : args
let opts = exportOptions(this.pages, {extension, ...args}),
{mime, quality, matte, pages, density} = opts,
canvas = atScale(pages, density, matte)[0],
url = canvas[canvas===elt ? _toURL_ : 'toDataURL'](mime, quality);
return Promise.resolve(url)
}
})
}
}
//
// Zip (pace Phil Katz & q.v. https://github.com/jimmywarting/StreamSaver.js)
//
class Crc32 {
static for(data){
return new Crc32().append(data).get()
}
constructor(){ this.crc = -1 }
get(){ return ~this.crc }
append(data){
var crc = this.crc | 0,
table = this.table
for (var offset = 0, len = data.length | 0; offset < len; offset++) {
crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF]
}
this.crc = crc
return this
}
}
Crc32.prototype.table = (() => {
var i, j, t, table = []
for (i = 0; i < 256; i++) {
t = i
for (j = 0; j < 8; j++) {
t = (t & 1)
? (t >>> 1) ^ 0xEDB88320
: t >>> 1
}
table[i] = t
}
return table
})()
function calloc(size){
let array = new Uint8Array(size),
view = new DataView(array.buffer),
buf = {
array, view, size,
set8(at, to){ view.setUint8(at, to); return buf },
set16(at, to){ view.setUint16(at, to, true); return buf },
set32(at, to){ view.setUint32(at, to, true); return buf },
bytes(at, to){ array.set(to, at); return buf },
}
return buf
}
class Zip{
static encoder = new TextEncoder()
constructor(directory){
let now = new Date()
Object.assign(this, {
directory,
offset: 0,
files: [],
time: (((now.getHours() << 6) | now.getMinutes()) << 5) | now.getSeconds() / 2,
date: ((((now.getFullYear() - 1980) << 4) | (now.getMonth() + 1)) << 5) | now.getDate(),
})
this.add(directory)
}
async add(filename, blob){
let folder = !blob,
name = Zip.encoder.encode(`${this.directory}/${folder ? '' : filename}`),
data = new Uint8Array(folder ? 0 : await blob.arrayBuffer()),
preamble = 30 + name.length,
descriptor = preamble + data.length,
postamble = 16,
{offset} = this
let header = calloc(26)
.set32(0, 0x08080014) // zip version
.set16(6, this.time) // time
.set16(8, this.date) // date
.set32(10, Crc32.for(data)) // checksum
.set32(14, data.length) // compressed size (w/ zero compression)
.set32(18, data.length) // un-compressed size
.set16(22, name.length) // filename length (utf8 bytes)
offset += preamble
let payload = calloc(preamble + data.length + postamble)
.set32(0, 0x04034b50) // local header signature
.bytes(4, header.array) // ...header fields...
.bytes(30, name) // filename
.bytes(preamble, data) // blob bytes
offset += data.length
payload
.set32(descriptor, 0x08074b50) // signature
.bytes(descriptor + 4, header.array.slice(10,22)) // length & filemame
offset += postamble
this.files.push({offset, folder, name, header, payload})
this.offset = offset
}
toBuffer(){
// central directory record
let length = this.files.reduce((len, {name}) => 46 + name.length + len, 0),
cdr = calloc(length + 22),
index = 0
for (var {offset, name, header, folder} of this.files){
cdr.set32(index, 0x02014b50) // archive file signature
.set16(index + 4, 0x0014) // version
.bytes(index + 6, header.array) // ...header fields...
.set8(index + 38, folder ? 0x10 : 0) // is_dir flag
.set32(index + 42, offset) // file offset
.bytes(index + 46, name) // filename
index += 46 + name.length
}
cdr.set32(index, 0x06054b50) // signature
.set16(index + 8, this.files.length) // № files per-segment
.set16(index + 10, this.files.length) // № files this segment
.set32(index + 12, length) // central directory length
.set32(index + 16, this.offset) // file-offset of directory
// concatenated zipfile data
let output = new Uint8Array(this.offset + cdr.size),
cursor = 0;
for (var {payload} of this.files){
output.set(payload.array, cursor)
cursor += payload.size
}
output.set(cdr.array, cursor)
return output
}
get blob(){
return new Blob([this.toBuffer()], {type:"application/zip"})
}
}
//
// Browser helpers for converting canvas elements to blobs/buffers/files/zips
//
const asBlob = (canvas, mime, quality, matte) => {
if (matte){
let {width, height} = canvas,
comp = Object.assign(document.createElement('canvas'), {width, height}),
ctx = comp.getContext("2d")
ctx.fillStyle = matte
ctx.fillRect(0, 0, width, height)
ctx.drawImage(canvas, 0, 0)
canvas = comp
}
return new Promise((res, rej) => canvas.toBlob(res, mime, quality))
}
const asBuffer = (...args) => asBlob(...args).then(b => b.arrayBuffer())
const asDownload = async (canvas, mime, quality, matte, filename) => {
_download(filename, await asBlob(canvas, mime, quality, matte))
}
const asZipDownload = async (pages, mime, quality, matte, archive, pattern, padding) => {
let filenames = i => pattern.replace('{}', String(i+1).padStart(padding, '0')),
folder = basename(archive, '.zip') || 'archive',
zip = new Zip(folder)
await Promise.all(pages.map(async (page, i) => {
let filename = filenames(i) // serialize filename(s) before awaiting
await zip.add(filename, await asBlob(page, mime, quality, matte))
}))
_download(`${folder}.zip`, zip.blob)
}
const _download = (filename, blob) => {
const href = window.URL.createObjectURL(blob),
link = document.createElement('a')
link.style.display = 'none'
link.href = href
link.setAttribute('download', filename)
if (typeof link.download === 'undefined') {
link.setAttribute('target', '_blank')
}
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
setTimeout(() => window.URL.revokeObjectURL(href), 100)
}
const atScale = (pages, density, matte) => pages.map(page => {
if (density == 1 && !matte) return page.canvas
let scaled = document.createElement('canvas'),
ctx = scaled.getContext("2d"),
src = page.canvas ? page.canvas : page
scaled.width = src.width * density
scaled.height = src.height * density
if (matte){
ctx.fillStyle = matte
ctx.fillRect(0, 0, scaled.width, scaled.height)
}
ctx.scale(density, density)
ctx.drawImage(src, 0, 0)
return scaled
})
//
// Mime type <-> File extension mappings
//
class Format{
constructor(){
let png = "image/png",
jpg = "image/jpeg",
jpeg = "image/jpeg",
webp = "image/webp"
Object.assign(this, {
toMime: this.toMime.bind(this),
fromMime: this.fromMime.bind(this),
expected: `"png", "jpg", or "webp"`,
formats: {png, jpg, jpeg, webp},
mimes: {[png]: "png", [jpg]: "jpg", [webp]: "webp"},
})
}
toMime(ext){
return this.formats[(ext||'').replace(/^\./, '').toLowerCase()]
}
fromMime(mime){
return this.mimes[mime]
}
}
//
// Validation of the options dict shared by the Canvas saveAs, toBuffer, and toDataURL methods
//
import {basename, extname} from 'path'
function exportOptions(pages, {filename='', extension='', format, page, quality, matte, density, archive}={}){
var {fromMime, toMime, expected} = new Format(),
archive = archive || 'canvas',
ext = format || extension.replace(/@\d+x$/i,'') || extname(filename),
format = fromMime(toMime(ext) || ext),
mime = toMime(format),
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})`)
if (!pp) throw new RangeError(`Canvas has no associated contexts (try calling getContext or newPage first)`)
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
if (quality===undefined){
quality = 0.92
}else{
if (typeof quality!='number' || !isFinite(quality) || quality<0 || quality>1){
throw new TypeError("The quality option must be an number in the 0.0–1.0 range")
}
}
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("The density option must be a non-negative integer")
}
return {filename, pattern, format, mime, pages, padding, quality, matte, density, archive}
}
const {CanvasRenderingContext2D, CanvasGradient, CanvasPattern,
Image, ImageData, Path2D, DOMMatrix, DOMRect, DOMPoint} = window;
module.exports = {
Canvas, loadImage, loadImageData,
CanvasRenderingContext2D, CanvasGradient, CanvasPattern,
Image, ImageData, Path2D, DOMMatrix, DOMRect, DOMPoint
}