Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"jest": "^28.1.2",
"jest-circus": "^28.1.2",
"puppeteer-core": "23.2.2",
"rimraf": "^3.0.2",
"rimraf": "^6.0.1",
"tesseract.js": "4.1.1",
"typescript": "^4.7.4"
}
Expand Down
84 changes: 47 additions & 37 deletions src/main.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,104 @@
import { existsSync, mkdirSync, readdirSync } from "fs";
import puppeteer from "puppeteer";
import puppeteerCore from "puppeteer-core";
import rimraf from "rimraf";
import * as rimraf from "rimraf";
import { createWorker } from "tesseract.js";

import { nodeHtmlToImage } from "./main";

describe("node-html-to-image", () => {
let mockExit;
let mockConsoleErr;
const originalConsoleError = console.error;
beforeEach(() => {
rimraf.sync("./generated");
mkdirSync("./generated");
mockExit = jest.spyOn(process, "exit").mockImplementation((number) => {
throw new Error("process.exit: " + number);
});
mockConsoleErr = jest
.spyOn(console, "error")
.mockImplementation((value) => originalConsoleError(value));
});

afterEach(() => {
mockExit.mockRestore();
mockConsoleErr.mockRestore();
});

afterAll(() => {
rimraf.sync("./generated");
});
describe("error", () => {
it("should stop the program properly", async () => {
/* eslint-disable @typescript-eslint/ban-ts-comment */
it("should throw due to invalid quality parameter", async () => {
await expect(async () => {
await nodeHtmlToImage({
html: "<html></html>",
type: "jpeg",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
quality: "wrong value",
puppeteerArgs: { args: ['--no-sandbox'] }
});
}).rejects.toThrow();

expect(mockExit).toHaveBeenCalledWith(1);
/* eslint-enable @typescript-eslint/ban-ts-comment */
});
});

describe("single image", () => {
it("should generate output file", async () => {
await nodeHtmlToImage({
it("should generate output file and return Buffer", async () => {
const imageBuffer = await nodeHtmlToImage({
output: "./generated/image.png",
html: "<html></html>",
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(existsSync("./generated/image.png")).toBe(true);
expect(imageBuffer).toBeInstanceOf(Buffer);
});

it("should return a buffer", async () => {
const result = await nodeHtmlToImage({
html: "<html></html>",
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(result).toBeInstanceOf(Buffer);
});

it("should return a base64 string", async () => {
const result = await nodeHtmlToImage({
html: "<html></html>",
encoding: "base64",
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(typeof result).toBe('string');
});

it("should throw an error if html is not provided", async () => {
await expect(async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await nodeHtmlToImage({
output: "./generated/image.png",
puppeteerArgs: { args: ['--no-sandbox'] }
});
}).rejects.toThrow();
expect(mockConsoleErr).toHaveBeenCalledWith(
new Error("You must provide an html property.")
);
}).rejects.toThrow("You must provide an html property.");
});

it("should throw timeout error", async () => {
await expect(async () => {
await nodeHtmlToImage({
timeout: 500,
html: "<html></html>"
html: "<html></html>",
puppeteerArgs: { args: ['--no-sandbox'] }
});
}).rejects.toThrow();
expect(mockConsoleErr).toHaveBeenCalledWith(
new Error("Timeout hit: 500")
);
}).rejects.toThrow("Timeout hit: 500");
});

it("should generate an jpeg image", async () => {
await nodeHtmlToImage({
it("should generate an jpeg image and return Buffer", async () => {
const imageBuffer = await nodeHtmlToImage({
output: "./generated/image.jpg",
html: "<html></html>",
type: "jpeg",
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(existsSync("./generated/image.jpg")).toBe(true);
expect(imageBuffer).toBeInstanceOf(Buffer);
});

it("should put html in output file", async () => {
await nodeHtmlToImage({
output: "./generated/image.png",
html: "<html><body>Hello world!</body></html>",
puppeteerArgs: { args: ['--no-sandbox'] }
});

const text = await getTextFromImage("./generated/image.png");
Expand All @@ -114,6 +110,7 @@ describe("node-html-to-image", () => {
output: "./generated/image.png",
html: "<html><body>Hello {{name}}!</body></html>",
content: { name: "Yvonnick" },
puppeteerArgs: { args: ['--no-sandbox'] }
});

const text = await getTextFromImage("./generated/image.png");
Expand All @@ -126,6 +123,7 @@ describe("node-html-to-image", () => {
html: '<html><body>Hello <div id="section">{{name}}!</div></body></html>',
content: { name: "Sangwoo" },
selector: "div#section",
puppeteerArgs: { args: ['--no-sandbox'] }
});

const text = await getTextFromImage("./generated/image.png");
Expand All @@ -134,17 +132,26 @@ describe("node-html-to-image", () => {
});

describe("batch", () => {
it("should create two images", async () => {
await nodeHtmlToImage({
it("should create two images and return two Buffers", async () => {
const imageBuffers = await nodeHtmlToImage({
type: "png",
quality: 300,
html: "<html><body>Hello {{name}}!</body></html>",
content: [
{ name: "Yvonnick", output: "./generated/image1.png" },
{ name: "World", output: "./generated/image2.png" },
],
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(imageBuffers?.length).toBe(2);

const buffer1 = imageBuffers?.[0];
const buffer2 = imageBuffers?.[1];

expect(buffer1).toBeInstanceOf(Buffer);
expect(buffer2).toBeInstanceOf(Buffer);

const text1 = await getTextFromImage("./generated/image1.png");
expect(text1.trim()).toBe("Hello Yvonnick!");

Expand All @@ -158,6 +165,7 @@ describe("node-html-to-image", () => {
quality: 300,
html: "<html><body>Hello {{name}}!</body></html>",
content: [{ name: "Yvonnick" }, { name: "World" }],
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(result?.[0]).toBeInstanceOf(Buffer);
Expand All @@ -175,6 +183,7 @@ describe("node-html-to-image", () => {
},
{ output: "./generated/image2.png", selector: "div#section2" },
],
puppeteerArgs: { args: ['--no-sandbox'] }
});

const text1 = await getTextFromImage("./generated/image1.png");
Expand All @@ -197,6 +206,7 @@ describe("node-html-to-image", () => {
quality: 300,
html: "<html><body>Hello {{name}}!</body></html>",
content,
puppeteerArgs: { args: ['--no-sandbox'] }
});

expect(readdirSync("./generated")).toHaveLength(NUMBER_OF_IMAGES);
Expand All @@ -209,7 +219,7 @@ describe("node-html-to-image", () => {
await nodeHtmlToImage({
output: "./generated/image.png",
html: "<html></html>",
puppeteerArgs: { executablePath },
puppeteerArgs: { executablePath, args: ['--no-sandbox'] },
puppeteer: puppeteerCore,
});

Expand All @@ -228,7 +238,7 @@ describe("node-html-to-image", () => {
});
});

async function getTextFromImage(path) {
async function getTextFromImage(path: string) {
const worker = await createWorker();
await worker.loadLanguage("eng");
await worker.initialize("eng");
Expand Down
38 changes: 28 additions & 10 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,28 @@ import { Cluster } from "puppeteer-cluster";

import { Screenshot } from "./models/Screenshot";
import { makeScreenshot } from "./screenshot";
import { Options, ScreenshotParams } from "./types";
import { Encoding, ScreenshotType, Options, ScreenshotParams, Content, ConditionalArray, ContentArrayItem, ContentObject } from "./types";

export async function nodeHtmlToImage(options: Options) {
/**
* Generates an image (or images) from HTML using Puppeteer, optionally supporting batch processing.
*
* @template TE - The encoding type mapped by 'encoding' parameter. Base64 or binary. Default: binary (undefined).
* @template TC - The content type mapped by 'content' parameter. This is either an object or object[].
* @param options - Configuration options for HTML rendering and screenshot generation.
* @param options.html - The HTML string to render.
* @param options.encoding - The encoding for the output image(s).
* @param options.transparent - Whether the background should be transparent.
* @param options.content - Content data or array of content data for batch processing.
* @param options.output - Output file path if content is an object. Else define output within the Content object[].
* @param options.selector - CSS selector to target a specific element for the screenshot if content is an object. Else declare the selector within the Content object[].
* @param options.type - The image format. Options: 'png' (default), 'jpeg'
* @param options.quality - The image quality (for JPEG).
* @param options.puppeteerArgs - Puppeteer configuration options.
* @param options.timeout - Timeout for Puppeteer operations in MS (default: 30000ms/30s).
* @param options.puppeteer - Use a custom puppeteer library (i.e puppeteer-core or puppeteer-extra)
* @returns String (base64) or Buffer (binary) depending on encoding type. If content is an array it will return an array instead of single a string or Buffer.
*/
export async function nodeHtmlToImage<TE extends Encoding | undefined = undefined, TC extends Content | undefined = undefined>(options: Options<TE, TC>): Promise<ConditionalArray<TC, ScreenshotType<TE>, ScreenshotType<TE>>> {
const {
html,
encoding,
Expand All @@ -19,7 +38,7 @@ export async function nodeHtmlToImage(options: Options) {
puppeteer = undefined,
} = options;

const cluster: Cluster<ScreenshotParams> = await Cluster.launch({
const cluster: Cluster<ScreenshotParams<TE, ContentObject>> = await Cluster.launch({
concurrency: Cluster.CONCURRENCY_CONTEXT,
maxConcurrency: 2,
timeout,
Expand All @@ -28,10 +47,10 @@ export async function nodeHtmlToImage(options: Options) {
});

const shouldBatch = Array.isArray(content);
const contents = shouldBatch ? content : [{ ...content, output, selector }];
const contents = (shouldBatch ? content : [{ ...content, output, selector }]) as ContentArrayItem[];

try {
const screenshots: Array<Screenshot> = await Promise.all(
const screenshots: Array<Screenshot<TE, TC>> = await Promise.all(
contents.map((content) => {
const { output, selector: contentSelector, ...pageContent } = content;
return cluster.execute(
Expand All @@ -48,7 +67,7 @@ export async function nodeHtmlToImage(options: Options) {
async ({ page, data }) => {
const screenshot = await makeScreenshot(page, {
...options,
screenshot: new Screenshot(data),
screenshot: new Screenshot<TE, ContentObject>(data),
});
return screenshot;
},
Expand All @@ -58,12 +77,11 @@ export async function nodeHtmlToImage(options: Options) {
await cluster.idle();
await cluster.close();

return shouldBatch
return (shouldBatch
? screenshots.map(({ buffer }) => buffer)
: screenshots[0].buffer;
: screenshots[0].buffer) as ConditionalArray<TC, ScreenshotType<TE>, ScreenshotType<TE>>;
} catch (err) {
console.error(err);
await cluster.close();
process.exit(1);
throw err;
}
}
6 changes: 3 additions & 3 deletions src/main.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { Cluster } from "puppeteer-cluster";

import { Screenshot } from "./models/Screenshot";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

describe("node-html-to-image | Unit", () => {
let mockExit;
let launchMock;
let mockExit: jest.SpyInstance;
let launchMock: jest.SpyInstance;
const buffer1 = Buffer.alloc(1);
const buffer2 = Buffer.alloc(1);
const html = "<html><body>{{message}}</body></html>";
Expand Down
2 changes: 2 additions & 0 deletions src/models/Screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("Screenshot", () => {
});

expect(() => {
// @ts-expect-error html has to be a string
screenshot.setHTML(null);
}).toThrow("You must provide an html property.");

Expand Down Expand Up @@ -158,6 +159,7 @@ describe("Screenshot", () => {

it("should throw an Error if html is null", () => {
expect(() => {
// @ts-expect-error html has to be a string
new Screenshot({ html: null });
}).toThrow("You must provide an html property.");
});
Expand Down
14 changes: 9 additions & 5 deletions src/models/Screenshot.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { ImageType, Encoding, Content, ScreenshotParams } from "../types";
import { ImageType, Encoding, Content, ScreenshotParams, ScreenshotType } from "../types";

export class Screenshot {
export class Screenshot<TE extends Encoding, TC extends Content> {
output: string;
content: Content;
selector: string;
html: string;
quality?: number;
buffer?: Buffer | string;
buffer?: ScreenshotType<TE>;
type?: ImageType;
encoding?: Encoding;
transparent?: boolean;

constructor(params: ScreenshotParams) {
constructor(params: ScreenshotParams<TE, TC>) {
if (!params || !params.html) {
throw Error("You must provide an html property.");
}
Expand Down Expand Up @@ -45,7 +45,11 @@ export class Screenshot {
}

setBuffer(buffer: Buffer | string) {
this.buffer = buffer;
if (this.encoding === 'base64') {
this.buffer = (typeof buffer === 'string' ? buffer : buffer.toString('base64')) as ScreenshotType<TE>;
} else {
this.buffer = (typeof buffer === 'string' ? Buffer.from(buffer) : buffer) as ScreenshotType<TE>;
}
}
}

Expand Down
Loading