import { writeFileSync } from 'fs'; import { PNG } from 'pngjs'; import { IComparerOptions, IRGBColor } from './options'; export interface IRGBAColor extends IRGBColor { a: number; } function isNumber(element: unknown): element is number { return typeof element === 'number'; } function isRGBColor(element: unknown): element is IRGBColor { return typeof element === 'object' && Object.prototype.hasOwnProperty.call(element, 'r') && Object.prototype.hasOwnProperty.call(element, 'g') && Object.prototype.hasOwnProperty.call(element, 'b'); } function isRGBAColor(element: unknown): element is IRGBAColor { return typeof element === 'object' && Object.prototype.hasOwnProperty.call(element, 'r') && Object.prototype.hasOwnProperty.call(element, 'g') && Object.prototype.hasOwnProperty.call(element, 'b') && Object.prototype.hasOwnProperty.call(element, 'a'); } const rgba = { r: 0, g: 1, b: 2, a: 3, }; function rgbaColorFrom(color: number | IRGBColor | IRGBAColor): IRGBAColor { if (isNumber(color)) { return { r: color, g: color, b: color, a: 255, }; } if (isRGBAColor(color)) { return { r: color.r, g: color.g, b: color.b, a: color.a, }; } if (isRGBColor(color)) { return { r: color.r, g: color.g, b: color.b, a: 255, }; } throw new Error('Argument exception'); } export type ImageOptions = Pick; export class Image { private inner: { width: number, height: number, data: number[] }; private buffer: Buffer; private refreshRequired: boolean; private readonly options: ImageOptions; constructor(buffer: Buffer, options: ImageOptions = { ignoreSizeDifference: false }) { this.refreshRequired = false; this.buffer = buffer; this.inner = PNG.sync.read(buffer); this.options = options; } get width(): number { return this.inner.width; } get height(): number { return this.inner.height; } get length(): number { return this.inner.data.length; } getPixel(index: number): number { return this.inner.data[index]; } setPixel(index: number, value: number): void { this.refreshRequired = true; this.inner.data[index] = value; } getPixelXY(x: number, y: number): number { return this.getPixel(this.getPosition(x, y)); } setPixelXY(x: number, y: number, value: number): void { this.setPixel(this.getPosition(x, y), value); } getPosition(x: number, y: number): number { return (this.width * y + x) * 4; } private updateBuffer(): void { if (this.refreshRequired) { this.buffer = PNG.sync.write(this.inner); } this.refreshRequired = false; } write(fileName: string): void { writeFileSync(fileName, this.getBuffer()); } getBuffer(): Buffer { this.updateBuffer(); return this.buffer; } clone(): Image { return new Image(this.getBuffer().slice(), this.options); } fill(color: number | IRGBColor | IRGBAColor): void { const color2 = rgbaColorFrom(color); for (let i = 0; i < this.length; i += 4) { this.inner.data[i + rgba.r] = color2.r; this.inner.data[i + rgba.g] = color2.g; this.inner.data[i + rgba.b] = color2.b; this.inner.data[i + rgba.a] = color2.a; } this.refreshRequired = true; } invert(): void { for (let i = 0; i < this.length; i++) { if (i % 4 !== 3) { this.inner.data[i] = 255 - this.inner.data[i]; } } this.refreshRequired = true; } replace(source: Image, mask?: Image): void { function sizeEqual(image1: Image, image2: Image): boolean { return image1.height === image2.height && image1.width === image2.width; } function applyMaskImpl(fromImage: Image, toImage: Image, fromMaskImage: Image): Image { for (let y = 0; y < toImage.height; y += 1) { for (let x = 0; x < toImage.width; x += 1) { const index = (toImage.width * y + x) * 4; if (fromMaskImage.getPixel(index + rgba.r) < 255 && fromMaskImage.getPixel(index + rgba.g) < 255 && fromMaskImage.getPixel(index + rgba.b) < 255) { toImage.setPixel(index + rgba.r, fromImage.getPixel(index + rgba.r)); toImage.setPixel(index + rgba.g, fromImage.getPixel(index + rgba.g)); toImage.setPixel(index + rgba.b, fromImage.getPixel(index + rgba.b)); toImage.setPixel(index + rgba.a, fromImage.getPixel(index + rgba.a)); } } } return toImage; } if (!this.options.ignoreSizeDifference && !sizeEqual(source, this)) { throw new Error(`Source image size (W:${this.width}, H:${this.height}) does not match target size (W:${source.width}, H:${source.height})`); } if (!mask) { return; } if (!this.options.ignoreSizeDifference && !sizeEqual(source, mask)) { throw new Error('Mask size does not match screenshot size'); } applyMaskImpl(source, this, mask); } private traverse(func: (index: number, x: number, y: number) => void): void { for (let y = 0; y < this.height; y += 1) { for (let x = 0; x < this.width; x += 1) { func(this.getPosition(x, y), x, y); } } } filter({ r, g, b }: IRGBColor): void { this.traverse((index) => { const isHighlighted = true && this.inner.data[index + rgba.r] === r && this.inner.data[index + rgba.g] === g && this.inner.data[index + rgba.b] === b; const color = isHighlighted ? 0 : 255; this.refreshRequired = isHighlighted || this.refreshRequired; this.inner.data[index + rgba.r] = color; this.inner.data[index + rgba.g] = color; this.inner.data[index + rgba.b] = color; }); } spreadBlack(radius: number): void { if (!radius) return; this.traverse((index, x, y) => { const isHighlighted = this.inner.data[index] === 0; if (isHighlighted) { const yBegin = Math.max(0, y - radius); const xBegin = Math.max(0, x - radius); const yEnd = Math.min(this.height, y + radius); const xEnd = Math.min(this.width, x + radius); for (let ry = yBegin; ry < yEnd; ry += 1) { for (let rx = xBegin; rx < xEnd; rx += 1) { const roundIndex = this.getPosition(rx, ry); if (this.inner.data[roundIndex] === 255) { this.refreshRequired = isHighlighted || this.refreshRequired; this.inner.data[roundIndex + rgba.r] = 7; this.inner.data[roundIndex + rgba.g] = 7; this.inner.data[roundIndex + rgba.b] = 7; } } } } }); for (let i = 0; i < this.length; i++) { if (this.inner.data[i] === 7) { this.inner.data[i] = 0; } } } and(...from: Image[]): void { if (!from.length) return; from.forEach((image) => { if (!image) { return; } for (let pixel = 0; pixel < this.length; pixel++) { this.inner.data[pixel] &= image.inner.data[pixel]; } }); this.refreshRequired = true; } or(...from: Image[]): void { if (!from.length) return; from.forEach((image) => { if (!image) { return; } for (let pixel = 0; pixel < this.length; pixel++) { this.inner.data[pixel] |= image.inner.data[pixel]; } }); this.refreshRequired = true; } xor(...from: Image[]): void { // NOTE: Current XOR implementation does not use alpha-channel if (!from.length) return; from.forEach((image) => { if (!image) { return; } for (let pixel = 0; pixel < this.length; pixel++) { if (pixel % 4 === 3) { continue; } this.inner.data[pixel] ^= image.inner.data[pixel]; } }); this.refreshRequired = true; } normalize(): void { for (let pixel = 0; pixel < this.length; pixel += 4) { const value = +!!(this.inner.data[pixel + rgba.r] + this.inner.data[pixel + rgba.g] + this.inner.data[pixel + rgba.b]); this.inner.data[pixel + rgba.r] = value; this.inner.data[pixel + rgba.g] = value; this.inner.data[pixel + rgba.b] = value; this.inner.data[pixel + rgba.a] = 1; } this.refreshRequired = true; } multiply(color: number | IRGBColor | IRGBAColor): void { const rgbaColor = rgbaColorFrom(color); for (let pixel = 0; pixel < this.length; pixel += 4) { this.inner.data[pixel + rgba.r] = this.inner.data[pixel + rgba.r] * rgbaColor.r; this.inner.data[pixel + rgba.g] = this.inner.data[pixel + rgba.g] * rgbaColor.g; this.inner.data[pixel + rgba.b] = this.inner.data[pixel + rgba.b] * rgbaColor.b; this.inner.data[pixel + rgba.a] = this.inner.data[pixel + rgba.a] * rgbaColor.a; } this.refreshRequired = true; } do(callback: (x: Image) => Image | void, log?: (image: Image)=>void): Image { const result = callback(this) || this; if (log) { log(result); } return result; } as(callback: (x: Image) => Image | void, log?: (image: Image)=>void): Image { const result = this.clone().do(callback); if (log) { log(result); } return result; } with(callback: (x: Image) => T): T { return callback(this); } dataClone(): number[] { return this.inner.data.slice(); } } export function colorToString(color: IRGBColor): string { return `#${Object.values(color).map((n) => n.toString(16).padStart(2, '0')).join('')}`; }