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/canvasorcanvas) – 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
| Pillow | Canvas 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.