From ad1609169e1361fbc84ae60a53a47236029b2a45 Mon Sep 17 00:00:00 2001 From: Flaki Date: Tue, 19 Mar 2024 13:56:51 +0200 Subject: [PATCH] Add canvas scale/translate transform support --- package.json | 8 +- rollup.config.js | 30 +++++--- src/js-canvas/RenderingContext.ts | 120 ++++++++++++++++++++++++++++-- src/js-canvas/WasmResize.ts | 88 ++++++++++++++++++++++ 4 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 src/js-canvas/WasmResize.ts diff --git a/package.json b/package.json index 2897d54..0214f21 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fauxdom-with-canvas", "description": "A fast and lightweight HTML5 parser and DOM with built-in canvas", - "version": "0.1.1", + "version": "0.1.2", "author": "Flaki ", "contributors": [ "Joe Stenger ", @@ -50,10 +50,13 @@ "prepare": "npm exec tsc && rollup -c --silent" }, "devDependencies": { + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-wasm": "^6.2.2", "compressing": "^1.5.0", "jest": "^29.3.0", "rollup": "^2.79.1", "rollup-plugin-strip-code": "^0.2.7", + "squoosh": "https://github.com/GoogleChromeLabs/squoosh/archive/refs/tags/v1.12.0.tar.gz", "typescript": "^5.3.3" }, "jest": { @@ -70,8 +73,5 @@ "/debug", "/src" ] - }, - "dependencies": { - "@rollup/plugin-terser": "^0.4.4" } } diff --git a/rollup.config.js b/rollup.config.js index 1122fd1..ee4bcde 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,6 @@ import terser from "@rollup/plugin-terser"; import stripCode from "rollup-plugin-strip-code"; +import { wasm } from "@rollup/plugin-wasm"; import {spawn} from "child_process"; import {zip} from "compressing"; import * as fs from "fs"; @@ -9,6 +10,8 @@ import * as pkg from "./package.json"; let DEBUG = true; +let EXTERNALS = [ "node:fs/promises" ]; + spawn( process.execPath, ["./scripts/entities.js"] ); export default args => @@ -27,24 +30,28 @@ export default args => start_comment: "@START_BROWSER_ONLY", end_comment: "@END_BROWSER_ONLY" } ), - modulePlugins = [debugStripper, browserStripper], - iifePlugins = [debugStripper, unitTestStripper], + wasmPlugin = wasm({ + sync: [ "node_modules/squoosh/codecs/resize/pkg/squoosh_resize_bg.wasm" ] + }), + modulePlugins = [debugStripper, browserStripper, wasmPlugin], + iifePlugins = [debugStripper, unitTestStripper, wasmPlugin], output = [ { onwarn, input: "src/document.js", plugins: modulePlugins, + external: EXTERNALS, output: [ module( "esm" ), - module( "cjs" ) + //module( "cjs" ) ] }, - { - onwarn, - input: "src/document.js", - plugins: iifePlugins, - output: module( "iife" ) - } + // { + // onwarn, + // input: "src/document.js", + // plugins: iifePlugins, + // output: module( "iife" ) + // } ]; if ( !DEBUG ) @@ -57,8 +64,9 @@ export default args => output.push( { onwarn, input: "src/document.js", - plugins: [browserStripper], - output: module( "cjs", "tests." ) + plugins: [browserStripper, wasmPlugin], + external: EXTERNALS, + output: module( "esm", "tests." ) } ); iifePlugins.push( terser( {compress: false, mangle: false, output: {beautify: true}, safari10: true} ) ); } diff --git a/src/js-canvas/RenderingContext.ts b/src/js-canvas/RenderingContext.ts index ecbd8a9..bc497e4 100644 --- a/src/js-canvas/RenderingContext.ts +++ b/src/js-canvas/RenderingContext.ts @@ -4,6 +4,7 @@ import type { HTMLCanvasElement } from "./HTMLCanvasElement.js"; import { CANVAS_DATA } from "./HTMLCanvasElement.js"; import { ImageData } from "./ImageData.js"; +import { resizeImage } from "./WasmResize.js" // Partial types via https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts export type RenderingContext = CanvasRenderingContext2D | ImageBitmapRenderingContext @@ -37,19 +38,52 @@ interface RGBAColor { a?: number } -const FILL_STYLE: unique symbol = Symbol("fill-style"); +interface Context2DState { + fillStyle: string + scaleX: number + scaleY: number + translateX: number + translateY: number +} + +const STATE: unique symbol = Symbol("context2d-state"); export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, CanvasImageData { readonly canvas: HTMLCanvasElement; - private [FILL_STYLE]: string; + private [STATE]: Context2DState; + + reset() { + this[STATE] = { + fillStyle: "#000", + scaleX: 1, + scaleY: 1, + translateX: 0, + translateY: 0, + }; + } get fillStyle(): string { - return this[FILL_STYLE]; + return this[STATE].fillStyle; } set fillStyle(newStyle: string) { console.log(`${this}→fillStyle = ${newStyle}`); - this[FILL_STYLE] = newStyle; + this[STATE].fillStyle = newStyle; + } + + get transformActive(): boolean { + const active = this[STATE].scaleX !== 1 || this[STATE].scaleY !== 1 || this[STATE].translateX !== 0 || this[STATE].translateY !== 0; + + if (active) { + const activeTransforms = []; + if (this[STATE].scaleX !== 1) activeTransforms.push(`scaleX: ${this[STATE].scaleX}`); + if (this[STATE].scaleY !== 1) activeTransforms.push(`scaleY: ${this[STATE].scaleY}`); + if (this[STATE].translateX !== 0) activeTransforms.push(`translateX: ${this[STATE].translateX}`); + if (this[STATE].translateY !== 0) activeTransforms.push(`translateY: ${this[STATE].translateY}`); + console.log(`${this}: context has active matrix transforms: ${activeTransforms.join(', ')}`); + } + + return active; } // CanvasRect @@ -58,6 +92,10 @@ export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, Ca } fillRect(x: number, y: number, w: number, h: number): void { + if (this[STATE].scaleX !== 1 || this[STATE].scaleY !== 1 || this[STATE].translateX !== 0 || this[STATE].translateY !== 0) { + console.log(`Warning: ${this}→fillRect( ${Array.from(arguments).join(', ')} ) canvas transform matrix not supported: ${Object.values(this[STATE]).map(([k,v]) => k+': '+v).join(', ')}`); + } + const { r, g, b, a } = this.fillStyleRGBA; const alpha = a*255|0; @@ -98,7 +136,7 @@ export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, Ca this.canvas = parentCanvas; // defaults - this.fillStyle = "#000"; + this.reset(); } // CanvasDrawImage @@ -109,29 +147,61 @@ export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, Ca if (image instanceof globalThis.HTMLCanvasElement) { w1 = w1 ?? image.width; h1 = h1 ?? image.height; + x2 = x2 ?? 0; + y2 = y2 ?? 0; if (w1 !== w2 || h1 !== h2) { console.log(`${this} Not implemented: image scaling in drawImage( <${image.constructor.name}> ${Array.from(arguments).join(', ')} )`); return; } - const srcImage = image.getContext("2d").getImageData(x1, y1, w1, h1); + let srcImage = image.getContext("2d").getImageData(x1, y1, w1, h1); + + // Scaling/translation needed + if (this.transformActive) { + // This is slightly inaccurate but we don't do subpixel drawing + const targetWidth = this[STATE].scaleX * w1 |0; + const targetHeight = this[STATE].scaleY * h1 |0; + + x2 = x2 + this[STATE].translateX |0; + y2 = y2 + this[STATE].translateY |0; + + srcImage = resizeImage(srcImage, targetWidth, targetHeight); + w1 = srcImage.width; + h1 = srcImage.height; + + console.log(`${this}→drawImage(): source image resized to: ${w1}x${h1} (${srcImage.data.length/4} pixels)`); + console.log(`${this}→drawImage(): drawing to translated coordinates: ( ${x2}, ${y2} )`); + } + const srcPixels = srcImage.data; const dstPixels = this.canvas[CANVAS_DATA]; + const canvasW = this.canvas.width; + const canvasH = this.canvas.height; const rows = h1; const cols = w1; + let ntp = 0; + let oob = 0; for (let row = 0; row < rows; ++row) { for (let col = 0; col < cols; ++col) { + // Index of the destination canvas pixel should be within bounds + const di = ((y2 + row) * canvasW + x2 + col) * 4; + + if (di < 0 || di >= dstPixels.length) { + ++oob; + continue; + } + // source pixel const si = ((y1 + row) * srcImage.width + x1 + col) * 4; const sr = srcPixels[ si ]; const sg = srcPixels[ si+1 ]; const sb = srcPixels[ si+2 ]; const sa = srcPixels[ si+3 ]; + if (sa > 0) ++ntp; // destination pixel - const di = ((y2 + row) * srcImage.width + x2 + col) * 4; const dr = dstPixels[ di ]; const dg = dstPixels[ di+1 ]; const db = dstPixels[ di+2 ]; @@ -148,6 +218,8 @@ export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, Ca } } console.log(`${this}→drawImage( <${image.constructor.name}> ${Array.from(arguments).join(', ')} )`); + console.log(`${this}→drawImage(): number of non-transparent source pixels drawn: ${ntp} (${ntp/(srcPixels.length/4)*100|0}%)`); + console.log(`${this}→drawImage(): skipped drawing of ${oob} out-of-bounds pixels on the canvas`); return; } @@ -233,7 +305,35 @@ export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, Ca setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void; setTransform(transform?: DOMMatrix2DInit): void; setTransform(matrixOrA?: any, b?, c?, d?, e?, f?) { - console.log(`${this} Not implemented: context2d.setTransform( ${Array.from(arguments).join(', ')} )`); + // Expand calls using a DOMMatrix2D object + if (typeof matrixOrA === 'object') { + if ('a' in matrixOrA || 'b' in matrixOrA || 'c' in matrixOrA || 'd' in matrixOrA || 'e' in matrixOrA || 'f' in matrixOrA || + 'm11' in matrixOrA || 'm12' in matrixOrA || 'm21' in matrixOrA || 'm22' in matrixOrA || 'm31' in matrixOrA || 'm32' in matrixOrA) { + return this.setTransform( + matrixOrA.a ?? matrixOrA.m11, matrixOrA.b ?? matrixOrA.m12, matrixOrA.c ?? matrixOrA.m21, + matrixOrA.dx ?? matrixOrA.m22, matrixOrA.e ?? matrixOrA.m31, matrixOrA.f ?? matrixOrA.m32 + ); + } + } else { + const a = matrixOrA; + + if ( b !== 0 || c !== 0) { + console.log(`${this} Not implemented: context2d.setTransform( ${Array.from(arguments).join(', ')} ) skew/rotate transforms`); + } + + this.scale(a,d); + this.translate(e,f); + + console.log(`${this}→setTransform( ${Array.from(arguments).join(', ')} )`); + } + } + scale(xScale: number, yScale: number) { + this[STATE].scaleX = xScale; + this[STATE].scaleY = yScale; + } + translate(x: number, y: number) { + this[STATE].translateX = x; + this[STATE].translateY = y; } // Stringifies the context object with its canvas & unique ID to ease debugging @@ -241,6 +341,10 @@ export class CanvasRenderingContext2D implements CanvasRect, CanvasDrawImage, Ca return `${this.canvas[Symbol.toStringTag]}::context2d`; } + private setPixel(x,y,r,g,b,a) { + + } + // https://developer.mozilla.org/en-US/docs/Web/CSS/color_value private get fillStyleRGBA(): RGBAColor { let c; diff --git a/src/js-canvas/WasmResize.ts b/src/js-canvas/WasmResize.ts new file mode 100644 index 0000000..597720e --- /dev/null +++ b/src/js-canvas/WasmResize.ts @@ -0,0 +1,88 @@ +// @ts-nocheck +// Based on squoosh_resize_bg.js at v0.12.0 +// import * as wasm from './squoosh_resize_bg.wasm'; + +import wasmInit from '../../node_modules/squoosh/codecs/resize/pkg/squoosh_resize_bg.wasm'; +// import { readFile } from 'node:fs/promises'; +// const wasmFile = await WebAssembly.compile( +// await readFile(new URL('../../node_modules/squoosh/codecs/resize/pkg/squoosh_resize_bg.wasm', import.meta.url)), +// ); +// const wasmInstance = await WebAssembly.instantiate(wasmFile, {}); +// const wasm = wasmInstance.exports; +const wasmInstance = wasmInit({}); +const wasm = wasmInstance.exports; +console.log('Wasm init:', wasmInstance, wasm); + +let cachegetUint8Memory0 = null; +function getUint8Memory0() { + if (cachegetUint8Memory0 === null || cachegetUint8Memory0.buffer !== wasm.memory.buffer) { + cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachegetUint8Memory0; +} + +let WASM_VECTOR_LEN = 0; + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1); + getUint8Memory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +let cachegetInt32Memory0 = null; +function getInt32Memory0() { + if (cachegetInt32Memory0 === null || cachegetInt32Memory0.buffer !== wasm.memory.buffer) { + cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachegetInt32Memory0; +} + +function getArrayU8FromWasm0(ptr, len) { + return getUint8Memory0().subarray(ptr / 1, ptr / 1 + len); +} +/** +* @param {Uint8Array} input_image +* @param {number} input_width +* @param {number} input_height +* @param {number} output_width +* @param {number} output_height +* @param {number} typ_idx +* @param {boolean} premultiply +* @param {boolean} color_space_conversion +* @returns {Uint8Array} +*/ +export function resize(input_image, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion) { + var ptr0 = passArray8ToWasm0(input_image, wasm.__wbindgen_malloc); + var len0 = WASM_VECTOR_LEN; + wasm.resize(8, ptr0, len0, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion); + var r0 = getInt32Memory0()[8 / 4 + 0]; + var r1 = getInt32Memory0()[8 / 4 + 1]; + var v1 = getArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_free(r0, r1 * 1); + return v1; +} + +export function resizeImage(image: ImageData, output_width: number, output_height: number): ImageData { + const input_image = image.data + const input_width = image.width + const input_height = image.height + + // https://github.com/GoogleChromeLabs/squoosh/blob/dev/codecs/resize/src/lib.rs + // 0 => Type::Triangle, + // 1 => Type::Catrom, + // 2 => Type::Mitchell, + // 3 => Type::Lanczos3, + const typ_idx = 3 + + const premultiply = true + const color_space_conversion = false + + const output_image = resize(input_image, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion); + + return { + width: output_width|0, + height: output_height|0, + data: output_image + } +}