refactor: bitmapper core as node package

This commit is contained in:
ful1e5 2021-11-21 16:00:16 +05:30
parent 8b37211109
commit 6c10cbf2f6
12 changed files with 35 additions and 0 deletions

View file

@ -0,0 +1,22 @@
{
"name": "core",
"version": "1.2.2",
"description": "Apple Cursor bitmapper's core modules",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"author": "Kaiz Khatri",
"license": "GPL-3.0",
"private": true,
"dependencies": {
"pixelmatch": "^5.2.1",
"pngjs": "^6.0.0",
"puppeteer": "^7.1.0"
},
"devDependencies": {
"@types/pixelmatch": "^5.2.2",
"@types/pngjs": "^6.0.0",
"@types/puppeteer": "^5.4.3",
"ts-node": "^9.1.1",
"typescript": "^4.1.5"
}
}

View file

@ -0,0 +1,155 @@
import fs from "fs";
import path from "path";
import puppeteer, { Browser, ElementHandle, Page } from "puppeteer";
import { frameNumber } from "./util/frameNumber";
import { matchImages } from "./util/matchImages";
import { toHTML } from "./util/toHTML";
class BitmapsGenerator {
/**
* Generate Png files from svg code.
* @param themeName Give name, So all bitmaps files are organized in one directory.
* @param bitmapsDir `absolute` or `relative` path, Where `.png` files will store.
*/
constructor(private bitmapsDir: string) {
this.bitmapsDir = path.resolve(bitmapsDir);
this.createDir(this.bitmapsDir);
}
/**
* Create directory if it doesn't exists.
* @param dirPath directory `absolute` path.
*/
private createDir(dirPath: string) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
/**
* Prepare headless browser.
*/
public async getBrowser(): Promise<Browser> {
return await puppeteer.launch({
ignoreDefaultArgs: ["--no-sandbox"],
headless: true,
});
}
private async getSvgElement(
page: Page,
content: string
): Promise<ElementHandle<Element>> {
if (!content) {
throw new Error(`${content} File Read error`);
}
const html = toHTML(content);
await page.setContent(html, { timeout: 0 });
const svg = await page.$("#container svg");
if (!svg) {
throw new Error("svg element not found!");
}
return svg;
}
public async generateStatic(browser: Browser, content: string, key: string) {
const page = await browser.newPage();
const svg = await this.getSvgElement(page, content);
const out = path.resolve(this.bitmapsDir, `${key}.png`);
await svg.screenshot({ omitBackground: true, path: out });
await page.close();
}
private async screenshot(
element: ElementHandle<Element>
): Promise<Buffer | string> {
const buffer = await element.screenshot({
encoding: "binary",
omitBackground: true,
});
if (!buffer) {
throw new Error("SVG element screenshot not working");
}
return buffer;
}
private async stopAnimation(page: Page) {
const client = await page.target().createCDPSession();
await client.send("Animation.setPlaybackRate", {
playbackRate: 0,
});
}
private async resumeAnimation(page: Page, playbackRate: number) {
const client = await page.target().createCDPSession();
await client.send("Animation.setPlaybackRate", {
playbackRate,
});
}
private async saveFrameImage(key: string, frame: Buffer | string) {
const out_path = path.resolve(this.bitmapsDir, key);
fs.writeFileSync(out_path, frame);
}
public async generateAnimated(
browser: Browser,
content: string,
key: string,
options?: {
playbackRate?: number;
diff?: number;
frameLimit?: number;
framePadding?: number;
}
) {
const opt = Object.assign(
{ playbackRate: 0.1, diff: 0, frameLimit: 300, framePadding: 4 },
options
);
const page = await browser.newPage();
const svg = await this.getSvgElement(page, content);
await this.stopAnimation(page);
let index = 1;
let breakRendering = false;
let prevImg: Buffer | string;
// Rendering frames till `imgN` matched to `imgN-1` (When Animation is done)
while (!breakRendering) {
if (index > opt.frameLimit) {
throw new Error("Reached the frame limit.");
}
await this.resumeAnimation(page, opt.playbackRate);
const img: string | Buffer = await this.screenshot(svg);
await this.stopAnimation(page);
if (index > 1) {
// @ts-ignore
const diff = matchImages(prevImg, img);
if (diff <= opt.diff) {
breakRendering = !breakRendering;
}
}
const number = frameNumber(index, opt.framePadding);
const frame = `${key}-${number}.png`;
this.saveFrameImage(frame, img);
prevImg = img;
++index;
}
await page.close();
}
}
export { BitmapsGenerator };

View file

@ -0,0 +1,77 @@
import fs from "fs";
import path from "path";
interface Svg {
key: string;
content: string;
}
class SvgDirectoryParser {
/**
* Manage and Parse SVG file path in `absolute` fashion.
* This Parser look svg files as below fashion:
* `
* <@svgDir>/static
* <@svgDir>/animated
* `
* @param svgDir is relative/absolute path, Where `SVG` files are stored.
*/
semiAnimated: boolean = false;
constructor(private svgDir: string) {
if (!fs.existsSync(this.svgDir)) {
throw new Error(`SVG files not found in ${this.svgDir}`);
}
}
private readData(f: string): Svg {
const content = fs.readFileSync(f, "utf-8");
const key = path.basename(f, ".svg");
return { content, key };
}
/**
* Return absolute paths array of SVG files data located inside '@svgDir/static'
*/
public getStatic(): Svg[] {
const staticDir = path.resolve(this.svgDir, "static");
if (!fs.existsSync(staticDir)) {
console.log(`${this.svgDir} contains semi-animated .svg files`);
this.semiAnimated = true;
return [];
} else {
const svgs = fs
.readdirSync(staticDir)
.map((f) => this.readData(path.resolve(staticDir, f)));
if (svgs.length == 0) {
throw new Error("Static Cursors directory is empty");
}
return svgs;
}
}
/**
* Return absolute paths array of SVG files data located inside '@svgDir/animated'
*/
public getAnimated(): Svg[] {
const animatedDir = path.resolve(this.svgDir, "animated");
if (!fs.existsSync(animatedDir)) {
throw new Error("Animated Cursors directory not found");
}
const svgs = fs
.readdirSync(animatedDir)
.map((f) => this.readData(path.resolve(animatedDir, f)));
if (svgs.length == 0 && this.semiAnimated) {
throw new Error(
`Can't parse svg directory ${this.svgDir} as semi-animated theme`
);
}
return svgs;
}
}
export { SvgDirectoryParser };

View file

@ -0,0 +1,52 @@
import { Colors } from "../types";
/**
* Default Key Colors for generating colored svg.
* base="#00FF00" (Green)
* outline="#0000FF" (Blue)
* watch.background="#FF0000" (Red)
* */
const defaultKeyColors: Colors = {
base: "#00FF00",
outline: "#0000FF",
watch: {
background: "#FF0000",
},
};
/**
* Customize colors of svg code.
* @param {string} content SVG code.
* @param {Colors} colors Customize colors.
* @param {Colors} [keys] Colors Key, That was written SVG code.
* @returns {string} SVG code with colors.
*/
const colorSvg = (
content: string,
colors: Colors,
keys: Colors = defaultKeyColors
): string => {
content = content
.replace(new RegExp(keys.base, "ig"), colors.base)
.replace(new RegExp(keys.outline, "ig"), colors.outline);
try {
// === trying to replace `watch` color ===
if (!colors.watch?.background) {
throw new Error("");
}
const { background: b } = colors.watch;
content = content.replace(new RegExp(keys.watch!.background, "ig"), b); // Watch Background
} catch (error) {
// === on error => replace `watch` color as `base` ===
content = content.replace(
new RegExp(keys.watch!.background, "ig"),
colors.base
);
}
return content;
};
export { colorSvg };

View file

@ -0,0 +1,4 @@
import { colorSvg } from "./colorSvg";
import { SvgDirectoryParser } from "./SvgDirectoryParser";
export { colorSvg, SvgDirectoryParser };

View file

@ -0,0 +1,4 @@
import { BitmapsGenerator } from "./BitmapsGenerator";
import * as SVGHandler from "./SVGHandler";
export { BitmapsGenerator, SVGHandler };

View file

@ -0,0 +1,20 @@
/**
* Hex Colors in string Format.
*
* `Example: `"#FFFFFF"
*/
type HexColor = string;
/**
* @Colors expect `base`, `outline` & `watch-background` colors in **HexColor** Format.
* @default background is `base` color.
*/
type Colors = {
base: HexColor;
outline: HexColor;
watch?: {
background: HexColor;
};
};
export { Colors };

View file

@ -0,0 +1,7 @@
export const frameNumber = (index: number, padding: number) => {
let result = "" + index;
while (result.length < padding) {
result = "0" + result;
}
return result;
};

View file

@ -0,0 +1,11 @@
import Pixelmatch from "pixelmatch";
import { PNG } from "pngjs";
export const matchImages = (img1: Buffer, img2: Buffer): number => {
const { data: img1Data, width, height } = PNG.sync.read(img1);
const { data: imgNData } = PNG.sync.read(img2);
return Pixelmatch(img1Data, imgNData, null, width, height, {
threshold: 0.1,
});
};

View file

@ -0,0 +1,18 @@
export const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Render Template</title>
</head>
<body>
<div id="container">
<svginjection>
</div>
</body>
</html>
`;
export const toHTML = (svgData: string): string =>
template.replace("<svginjection>", svgData);

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}