@shangxueink/koishi-plugin-puppeteer-without-canvas
Version:
233 lines (210 loc) • 6.7 kB
text/typescript
import CanvasService, { Canvas, CanvasRenderingContext2D, Image } from '@koishijs/canvas'
import { Awaitable, Binary, Context, h } from 'koishi'
import { ElementHandle, Page } from 'puppeteer-core'
import { pathToFileURL } from 'node:url'
import { resolve } from 'node:path'
const kElement = Symbol('element')
class BaseElement {
public [kElement] = true
constructor(protected page: Page, protected id: string) { }
get selector() {
return `document.querySelector("#${this.id}")`
}
async dispose() {
await this.page.evaluate(`${this.selector}?.remove()`)
this.id = null
}
}
class CanvasElement extends BaseElement implements Canvas {
private stmts: string[] = []
private ctx = new Proxy({
canvas: this,
direction: 'inherit',
fillStyle: '#000000',
filter: 'none',
font: '10px sans-serif',
fontKerning: 'auto',
fontStretch: 'normal',
fontVariantCaps: 'normal',
globalAlpha: 1,
globalCompositeOperation: 'source-over',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'low',
letterSpacing: '0px',
lineCap: 'butt',
lineDashOffset: 0,
lineJoin: 'miter',
lineWidth: 1,
miterLimit: 10,
shadowBlur: 0,
shadowColor: 'rgba(0, 0, 0, 0)',
shadowOffsetX: 0,
shadowOffsetY: 0,
strokeStyle: '#000000',
textAlign: 'start',
textBaseline: 'alphabetic',
textRendering: 'auto',
wordSpacing: '0px',
} as unknown as CanvasRenderingContext2D, {
get: (target, prop, receiver) => {
if (Reflect.has(target, prop) || typeof prop === 'symbol') {
return Reflect.get(target, prop, receiver)
}
return new Proxy(() => { }, {
apply: (target, thisArg, argArray) => {
this.stmts.push(`ctx.${prop}(${argArray.map((value) => {
if (value[kElement]) return value.selector
return JSON.stringify(value)
}).join(', ')});`)
},
})
},
set: (target, prop, value, receiver) => {
if (Reflect.has(target, prop)) {
if (typeof prop !== 'symbol') {
this.stmts.push(`ctx.${prop} = ${JSON.stringify(value)};`)
}
return Reflect.set(target, prop, value, receiver)
}
},
})
constructor(
page: Page,
id: string,
public width: number,
public height: number,
private fontFaceSet: FontFace[] = [],
private styleHandles: ElementHandle<Element>[] = [],
) {
super(page, id)
}
getContext(type: '2d') {
return this.ctx
}
async toDataURL(type: 'image/png') {
if (!this.id) throw new Error('canvas has been disposed')
try {
this.stmts.unshift(`(async (ctx) => {`)
const expr = this.stmts.join('\n ') + `\n})(${this.selector}.getContext('2d'))`
this.stmts = []
await this.page.evaluate(expr)
return await this.page.evaluate(`${this.selector}.toDataURL(${JSON.stringify(type)})`) as string
} catch (err) {
await this.dispose()
throw err
}
}
async toBuffer(type: 'image/png') {
const url = await this.toDataURL(type)
return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64')
}
async dispose() {
await super.dispose()
await Promise.all(this.fontFaceSet.map(async (fontFace) => {
await this.page.evaluate((fontFace) => {
document.fonts.delete(fontFace)
}, fontFace)
}))
await Promise.all(this.styleHandles.map(async (handle) => {
try {
await handle.evaluate(node => node.remove())
await handle.dispose()
} catch (e) { }
}))
}
}
class ImageElement extends BaseElement implements Image {
public naturalHeight: number
public naturalWidth: number
constructor(
private ctx: Context,
page: Page,
id: string,
private source: string | URL | Buffer | ArrayBufferLike,
private type?: string,
) {
super(page, id)
}
async initialize() {
let base64: string
if (this.source instanceof URL) {
this.source = this.source.href
}
if (typeof this.source === 'string') {
const file = await this.ctx.http.file(this.source)
base64 = Binary.toBase64(file.data)
} else if (Buffer.isBuffer(this.source)) {
base64 = this.source.toString('base64')
} else {
base64 = Binary.toBase64(this.source)
}
const size = await this.page.evaluate(`loadImage(${JSON.stringify(this.id)}, ${JSON.stringify(base64)}, ${JSON.stringify(this.type)})`) as any
this.naturalWidth = size.width
this.naturalHeight = size.height
}
}
export default class extends CanvasService {
static inject = ['puppeteer', 'http']
private page: Page
private counter = 0
async start() {
const page = await this.ctx.puppeteer.page()
try {
await page.goto(pathToFileURL(resolve(__dirname, '../index.html')).href)
this.page = page
} catch (err) {
await page.close()
throw err
}
}
async stop() {
await this.page?.close()
this.page = null
}
async createCanvas(width: number, height: number) {
const fontFaceSet = []
const styleHandles = []
try {
const name = `canvas_${++this.counter}`
await this.page.evaluate([
`const ${name} = document.createElement('canvas');`,
`${name}.width = ${width};`,
`${name}.height = ${height};`,
`${name}.id = ${JSON.stringify(name)};`,
`document.body.appendChild(${name});`,
].join('\n'))
return new CanvasElement(this.page, name, width, height, fontFaceSet, styleHandles)
} catch (err) {
this.ctx.logger('puppeteer').warn(err)
throw err
}
}
async render(
width: number,
height: number,
callback: (ctx: CanvasRenderingContext2D) => Awaitable<void>,
) {
let canvas: CanvasElement
try {
canvas = await this.createCanvas(width, height)
await callback(canvas.getContext('2d'))
const buffer = await canvas.toBuffer('image/png')
return h.image(buffer, 'image/png')
} catch (err) {
this.ctx.logger('puppeteer').warn(err)
throw err
} finally {
try {
await canvas.dispose()
} catch (err) {
this.ctx.logger('puppeteer').warn(err)
}
}
}
async loadImage(source: string | URL | Buffer | ArrayBufferLike, type?: string): Promise<Image> {
const id = `image_${++this.counter}`
const image = new ImageElement(this.ctx, this.page, id, source, type)
await image.initialize()
return image
}
}