Skip to main content

Chapter 21 - Making Graphs and Manipulating Images (JavaScript)

Here's a JavaScript-flavoured version of the same concepts, with small JS/Node examples for each idea.


Big picture: sharp + Canvas + Chart.js

In JavaScript:

  • sharp – high-performance image manipulation (resize, crop, rotate, composite/watermark). The go-to Node library for batch image processing.
  • canvas (@napi-rs/canvas or canvas) – HTML Canvas API for Node, used for drawing shapes, text, and per-pixel manipulation.
  • Chart.js (with chartjs-node-canvas) – generates charts/graphs as images server-side.
npm install sharp @napi-rs/canvas

Image fundamentals

Colors and RGBA

Same concept: RGBA with each channel 0–255. In JavaScript, colors are typically CSS strings or hex values:

// CSS color strings work in canvas contexts
"rgba(255, 0, 0, 1)"; // red, fully opaque
"rgba(0, 0, 255, 0.5)"; // blue, 50% transparent
"#FF0000"; // red in hex
"chocolate"; // named CSS color

Note: CSS alpha is 0–1 (float), not 0–255 like Pillow.

Coordinates and box regions

Same as Python: origin (0, 0) is top-left, x grows right, y grows down. sharp uses { left, top, width, height } objects instead of box tuples:

// Pillow box tuple (left, top, right, bottom) = (3, 1, 9, 6)
// sharp equivalent:
{ left: 3, top: 1, width: 6, height: 5 }

Basic sharp usage

Loading, inspecting, saving, converting images

const sharp = require("sharp");

const image = sharp("zophie.png");

// Get metadata
const meta = await image.metadata();
console.log(meta.width, meta.height); // 816, 1088
console.log(meta.format); // 'png'

// Save as different format
await sharp("zophie.png").toFile("zophie.jpg");

Creating new blank images

const sharp = require("sharp");

// Solid purple image
const purpleImage = sharp({
create: {
width: 100,
height: 200,
channels: 4,
background: { r: 128, g: 0, b: 128, alpha: 1 },
},
});
await purpleImage.png().toFile("purpleImage.png");

// Transparent image
const transparentImage = sharp({
create: {
width: 20,
height: 20,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
});
await transparentImage.png().toFile("transparentImage.png");

Core image operations

Cropping

sharp uses extract with { left, top, width, height }:

await sharp("zophie.png")
.extract({ left: 335, top: 345, width: 230, height: 215 })
.toFile("cropped.png");

Copying and pasting (compositing)

sharp uses composite to paste images on top of others:

// Crop face, then paste it onto a copy
const face = await sharp("zophie.png")
.extract({ left: 335, top: 345, width: 230, height: 215 })
.toBuffer();

await sharp("zophie.png")
.composite([
{ input: face, left: 0, top: 0 },
{ input: face, left: 400, top: 500 },
])
.toFile("pasted.png");

Tiling requires building the composite array programmatically:

const meta = await sharp("zophie.png").metadata();
const faceWidth = 230, faceHeight = 215;

const overlays = [];
for (let left = 0; left < meta.width; left += faceWidth) {
for (let top = 0; top < meta.height; top += faceHeight) {
overlays.push({ input: face, left, top });
}
}

await sharp("zophie.png")
.composite(overlays)
.toFile("tiled.png");

Resizing

const meta = await sharp("zophie.png").metadata();

// Quarter size
await sharp("zophie.png")
.resize(Math.round(meta.width / 2), Math.round(meta.height / 2))
.toFile("quartersized.png");

// Resize to specific width, auto height
await sharp("zophie.png")
.resize(300) // width=300, height auto-calculated to preserve aspect ratio
.toFile("resized300.png");

sharp.resize() preserves aspect ratio by default (fits within the given dimensions).

Rotating and flipping

// Rotate by 90/180/270
await sharp("zophie.png").rotate(90).toFile("rotated90.png");
await sharp("zophie.png").rotate(180).toFile("rotated180.png");
await sharp("zophie.png").rotate(270).toFile("rotated270.png");

// Small angle (auto-expands canvas)
await sharp("zophie.png").rotate(6).toFile("rotated6.png");

// Flips
await sharp("zophie.png").flop().toFile("horizontal_flip.png"); // left-right
await sharp("zophie.png").flip().toFile("vertical_flip.png"); // top-bottom

Per-pixel access with canvas

For pixel-level manipulation, use canvas:

const { createCanvas, loadImage } = require("@napi-rs/canvas");
const fs = require("fs");

const canvas = createCanvas(100, 100);
const ctx = canvas.getContext("2d");

// Top half light gray
ctx.fillStyle = "rgb(210, 210, 210)";
ctx.fillRect(0, 0, 100, 50);

// Bottom half dark gray
ctx.fillStyle = "darkgray";
ctx.fillRect(0, 50, 100, 50);

// Read a pixel
const pixel = ctx.getImageData(0, 0, 1, 1).data;
console.log(pixel); // Uint8Array [210, 210, 210, 255]

// Save
const buffer = canvas.toBuffer("image/png");
fs.writeFileSync("putPixel.png", buffer);

Project 16: Add a Logo (batch resize + watermark)

Same goal: batch-process images, resize if larger than 300x300, paste logo at bottom-right.

const sharp = require("sharp");
const fs = require("fs");
const path = require("path");

const SQUARE_FIT_SIZE = 300;
const LOGO_FILENAME = "catlogo.png";

async function main() {
const logoMeta = await sharp(LOGO_FILENAME).metadata();
const logoBuffer = await sharp(LOGO_FILENAME).toBuffer();

fs.mkdirSync("withLogo", { recursive: true });

const files = fs.readdirSync(".").filter(
(f) =>
(f.endsWith(".png") || f.endsWith(".jpg")) && f !== LOGO_FILENAME
);

for (const filename of files) {
let image = sharp(filename);
let meta = await image.metadata();
let { width, height } = meta;

// Resize if both dimensions exceed limit
if (width > SQUARE_FIT_SIZE && height > SQUARE_FIT_SIZE) {
if (width > height) {
height = Math.round((SQUARE_FIT_SIZE / width) * height);
width = SQUARE_FIT_SIZE;
} else {
width = Math.round((SQUARE_FIT_SIZE / height) * width);
height = SQUARE_FIT_SIZE;
}
console.log(`Resizing ${filename}...`);
image = sharp(await image.resize(width, height).toBuffer());
}

// Paste logo at bottom-right
console.log(`Adding logo to ${filename}...`);
await image
.composite([
{
input: logoBuffer,
left: width - logoMeta.width,
top: height - logoMeta.height,
},
])
.toFile(path.join("withLogo", filename));
}
}

main();

sharp handles transparency in compositing automatically — no special third argument needed like Pillow.


Drawing on images (Canvas)

For drawing shapes, use the Canvas API:

const { createCanvas } = require("@napi-rs/canvas");
const fs = require("fs");

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext("2d");

// White background
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 200, 200);

// Border
ctx.strokeStyle = "black";
ctx.strokeRect(0, 0, 200, 200);

// Blue rectangle
ctx.fillStyle = "blue";
ctx.fillRect(20, 30, 40, 30);

// Red ellipse
ctx.fillStyle = "red";
ctx.beginPath();
ctx.ellipse(140, 45, 20, 15, 0, 0, Math.PI * 2);
ctx.fill();

// Brown polygon
ctx.fillStyle = "brown";
ctx.beginPath();
ctx.moveTo(57, 87);
ctx.lineTo(79, 62);
ctx.lineTo(94, 85);
ctx.lineTo(120, 90);
ctx.lineTo(103, 113);
ctx.closePath();
ctx.fill();

// Green diagonal lines
ctx.strokeStyle = "green";
for (let i = 100; i < 200; i += 10) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(200, i - 100);
ctx.stroke();
}

fs.writeFileSync("drawing.png", canvas.toBuffer("image/png"));

Shape methods comparison

PillowCanvas API
draw.point(xy, fill)ctx.fillRect(x, y, 1, 1)
draw.line(xy, fill, width)ctx.beginPath(); ctx.moveTo/lineTo; ctx.stroke()
draw.rectangle(xy, fill, outline)ctx.fillRect() / ctx.strokeRect()
draw.ellipse(xy, fill, outline)ctx.ellipse(); ctx.fill() / ctx.stroke()
draw.polygon(xy, fill, outline)ctx.beginPath(); ctx.moveTo/lineTo; ctx.closePath(); ctx.fill()

For text drawing, use ctx.font = '24px Arial' and ctx.fillText('Hello', x, y).


Overall idea of the chapter

Chapter 21 in JavaScript: image manipulation with sharp (resize, crop, rotate, flip, composite/watermark — all async and high-performance), per-pixel access and shape drawing with @napi-rs/canvas (HTML Canvas API for Node), and chart generation with chartjs-node-canvas. sharp handles batch processing elegantly with method chaining, while canvas provides the drawing primitives (rectangles, ellipses, polygons, lines, text) equivalent to Pillow's ImageDraw.