diff --git a/README.md b/README.md index 0ac1b70..08c8e44 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,12 @@ dependencies. - 📦 **Cross-runtime** - Works on Deno, Node.js (18+), and Bun - 🎨 **Multiple formats** - PNG, APNG, JPEG, WebP, GIF, TIFF, BMP, ICO, DNG, PAM, PPM, PCX and ASCII support -- ✂️ **Image manipulation** - Resize, crop, composite, and more +- ✂️ **Image manipulation** - Resize, crop, composite, border, and more - 🎛️ **Image processing** - Chainable filters including `brightness`, `contrast`, `saturation`, `hue`, `exposure`, `blur`, `sharpen`, `sepia`, and more -- 🖌️ **Drawing operations** - Create, fill, and manipulate pixels +- 🖌️ **Drawing operations** - Draw lines, circles, rectangles, and manipulate + pixels - 🧩 **Multi-frame** - Decode/encode animated GIFs, APNGs and multi-page TIFFs - 🔧 **Simple API** - Easy to use, intuitive interface @@ -66,16 +67,22 @@ console.log(`Image size: ${image.width}x${image.height}`); // Create a new blank image const canvas = Image.create(800, 600, 255, 255, 255); // white background +// Draw some shapes +canvas + .drawCircle(400, 300, 100, 255, 0, 0, 255, true) // red filled circle + .drawLine(0, 0, 800, 600, 0, 0, 255); // blue diagonal line + // Composite the loaded image on top canvas.composite(image, 50, 50); -// Apply image processing filters +// Apply image processing filters and add border canvas .brightness(0.1) .contrast(0.2) .saturation(-0.1) .blur(1) - .sharpen(0.3); + .sharpen(0.3) + .border(10, 0, 0, 0); // 10px black border // Encode in a different format const jpeg = await canvas.encode("jpeg"); diff --git a/docs/src/processing/manipulation.md b/docs/src/processing/manipulation.md index f4457aa..03a0ce6 100644 --- a/docs/src/processing/manipulation.md +++ b/docs/src/processing/manipulation.md @@ -367,3 +367,246 @@ image.resize({ width: 512, height: 512 }); await Deno.writeFile("square.png", await image.encode("png")); ``` + +## Border + +Add a uniform or custom border around an image. + +### Signature + +```ts +border(width: number, r?: number, g?: number, b?: number, a?: number): this +borderSides(top: number, right: number, bottom: number, left: number, r?: number, g?: number, b?: number, a?: number): this +``` + +### Parameters + +- `width` - Border width in pixels (all sides) for `border()` +- `top`, `right`, `bottom`, `left` - Border widths per side for `borderSides()` +- `r` - Red component (0-255, default: 0) +- `g` - Green component (0-255, default: 0) +- `b` - Blue component (0-255, default: 0) +- `a` - Alpha component (0-255, default: 255) + +### Example + +```ts +import { Image } from "@cross/image"; + +const data = await Deno.readFile("photo.jpg"); +const image = await Image.decode(data); + +// Add a 10-pixel black border +image.border(10); + +// Add a 5-pixel white border +image.border(5, 255, 255, 255); + +// Add a 20-pixel semi-transparent blue border +image.border(20, 0, 0, 255, 128); + +// Add different borders per side (top=10, right=5, bottom=10, left=5) +image.borderSides(10, 5, 10, 5, 0, 0, 0, 255); + +const output = await image.encode("png"); +await Deno.writeFile("bordered.png", output); +``` + +### Use Cases + +- Creating framed thumbnails +- Adding spacing around images +- Creating polaroid-style effects +- Preparing images for display with consistent padding +- Adding decorative borders + +### Tips + +- Border increases image dimensions by `2 * width` (or sum of sides) +- Use transparent borders (a=0) for padding without visible border +- Chain with other operations: `image.border(10).resize(...)` +- Metadata (DPI, physical dimensions) is preserved and updated + +## Draw Line + +Draw a straight line between two points using Bresenham's algorithm. + +### Signature + +```ts +drawLine(x0: number, y0: number, x1: number, y1: number, r: number, g: number, b: number, a?: number): this +``` + +### Parameters + +- `x0`, `y0` - Starting coordinates +- `x1`, `y1` - Ending coordinates +- `r` - Red component (0-255) +- `g` - Green component (0-255) +- `b` - Blue component (0-255) +- `a` - Alpha component (0-255, default: 255) + +### Example + +```ts +import { Image } from "@cross/image"; + +// Create a blank canvas +const canvas = Image.create(400, 400, 255, 255, 255); + +// Draw a red horizontal line +canvas.drawLine(50, 200, 350, 200, 255, 0, 0); + +// Draw a blue vertical line +canvas.drawLine(200, 50, 200, 350, 0, 0, 255); + +// Draw a green diagonal line +canvas.drawLine(50, 50, 350, 350, 0, 255, 0); + +// Draw a semi-transparent yellow line +canvas.drawLine(50, 350, 350, 50, 255, 255, 0, 128); + +const output = await canvas.encode("png"); +await Deno.writeFile("lines.png", output); +``` + +### Use Cases + +- Creating grid overlays +- Drawing coordinate axes +- Adding guidelines or annotations +- Creating geometric patterns +- Technical drawings + +### Tips + +- Lines are drawn with pixel precision using Bresenham's algorithm +- Coordinates are automatically clipped to image bounds +- Supports any angle (horizontal, vertical, diagonal) +- Can be chained with other operations +- Use alpha channel for semi-transparent lines + +## Draw Circle + +Draw a circle (outline or filled) at a specified position. + +### Signature + +```ts +drawCircle(centerX: number, centerY: number, radius: number, r: number, g: number, b: number, a?: number, filled?: boolean): this +``` + +### Parameters + +- `centerX`, `centerY` - Circle center coordinates +- `radius` - Circle radius in pixels +- `r` - Red component (0-255) +- `g` - Green component (0-255) +- `b` - Blue component (0-255) +- `a` - Alpha component (0-255, default: 255) +- `filled` - Whether to fill the circle (default: false, outline only) + +### Example + +```ts +import { Image } from "@cross/image"; + +// Create a blank canvas +const canvas = Image.create(400, 400, 255, 255, 255); + +// Draw a red circle outline +canvas.drawCircle(100, 100, 50, 255, 0, 0); + +// Draw a filled blue circle +canvas.drawCircle(300, 100, 50, 0, 0, 255, 255, true); + +// Draw a filled semi-transparent green circle +canvas.drawCircle(200, 300, 75, 0, 255, 0, 128, true); + +// Draw multiple concentric circles +for (let r = 10; r <= 100; r += 20) { + canvas.drawCircle(200, 200, r, 0, 0, 0); +} + +const output = await canvas.encode("png"); +await Deno.writeFile("circles.png", output); +``` + +### Use Cases + +- Creating dot markers or indicators +- Drawing targets or focus areas +- Creating geometric patterns +- Adding decorative elements +- Highlighting regions of interest + +### Tips + +- Use `filled: false` for circle outline (default) +- Use `filled: true` for solid filled circle +- Circle is automatically clipped to image bounds +- Outline uses midpoint circle algorithm for efficiency +- Can be chained with other operations + +## Drawing Workflow Examples + +### Creating a Simple Chart + +```ts +const chart = Image.create(400, 300, 255, 255, 255); + +// Draw axes +chart.drawLine(50, 250, 350, 250, 0, 0, 0); // X-axis +chart.drawLine(50, 50, 50, 250, 0, 0, 0); // Y-axis + +// Plot data points +const data = [100, 150, 120, 200, 180]; +for (let i = 0; i < data.length; i++) { + const x = 80 + i * 60; + const y = 250 - data[i]; + chart.drawCircle(x, y, 5, 255, 0, 0, 255, true); +} + +// Connect points with lines +for (let i = 0; i < data.length - 1; i++) { + const x0 = 80 + i * 60; + const y0 = 250 - data[i]; + const x1 = 80 + (i + 1) * 60; + const y1 = 250 - data[i + 1]; + chart.drawLine(x0, y0, x1, y1, 0, 0, 255); +} + +await Deno.writeFile("chart.png", await chart.encode("png")); +``` + +### Creating a Frame Effect + +```ts +const image = await Image.decode(await Deno.readFile("photo.jpg")); + +// Add a white border +image.border(20, 255, 255, 255); + +// Draw decorative corners +const w = image.width; +const h = image.height; +const len = 30; + +// Top-left corner +image.drawLine(0, 0, len, 0, 200, 150, 0, 255); +image.drawLine(0, 0, 0, len, 200, 150, 0, 255); + +// Top-right corner +image.drawLine(w - 1, 0, w - len - 1, 0, 200, 150, 0, 255); +image.drawLine(w - 1, 0, w - 1, len, 200, 150, 0, 255); + +// Bottom-left corner +image.drawLine(0, h - 1, len, h - 1, 200, 150, 0, 255); +image.drawLine(0, h - 1, 0, h - len - 1, 200, 150, 0, 255); + +// Bottom-right corner +image.drawLine(w - 1, h - 1, w - len - 1, h - 1, 200, 150, 0, 255); +image.drawLine(w - 1, h - 1, w - 1, h - len - 1, 200, 150, 0, 255); + +await Deno.writeFile("framed.png", await image.encode("png")); +``` diff --git a/src/image.ts b/src/image.ts index 5789c94..1bb3aa5 100644 --- a/src/image.ts +++ b/src/image.ts @@ -11,6 +11,8 @@ import { resizeNearest, } from "./utils/resize.ts"; import { + addBorder, + addBorderSides, adjustBrightness, adjustContrast, adjustExposure, @@ -19,6 +21,8 @@ import { boxBlur, composite, crop, + drawCircle, + drawLine, fillRect, flipHorizontal, flipVertical, @@ -1103,4 +1107,183 @@ export class Image { return this; } + + /** + * Add a border around the image + * @param width Border width in pixels (applied to all sides) + * @param r Red component (0-255, default: 0) + * @param g Green component (0-255, default: 0) + * @param b Blue component (0-255, default: 0) + * @param a Alpha component (0-255, default: 255) + * @returns This image instance for chaining + */ + border(width: number, r = 0, g = 0, b = 0, a = 255): this { + if (!this.imageData) throw new Error("No image loaded"); + + const result = addBorder( + this.imageData.data, + this.imageData.width, + this.imageData.height, + width, + r, + g, + b, + a, + ); + + this.imageData.width = result.width; + this.imageData.height = result.height; + this.imageData.data = result.data; + + // Update physical dimensions if DPI is set + if (this.imageData.metadata) { + const metadata = this.imageData.metadata; + if (metadata.dpiX) { + this.imageData.metadata.physicalWidth = result.width / metadata.dpiX; + } + if (metadata.dpiY) { + this.imageData.metadata.physicalHeight = result.height / metadata.dpiY; + } + } + + return this; + } + + /** + * Add a border with different widths for each side + * @param top Top border width in pixels + * @param right Right border width in pixels + * @param bottom Bottom border width in pixels + * @param left Left border width in pixels + * @param r Red component (0-255, default: 0) + * @param g Green component (0-255, default: 0) + * @param b Blue component (0-255, default: 0) + * @param a Alpha component (0-255, default: 255) + * @returns This image instance for chaining + */ + borderSides( + top: number, + right: number, + bottom: number, + left: number, + r = 0, + g = 0, + b = 0, + a = 255, + ): this { + if (!this.imageData) throw new Error("No image loaded"); + + const result = addBorderSides( + this.imageData.data, + this.imageData.width, + this.imageData.height, + top, + right, + bottom, + left, + r, + g, + b, + a, + ); + + this.imageData.width = result.width; + this.imageData.height = result.height; + this.imageData.data = result.data; + + // Update physical dimensions if DPI is set + if (this.imageData.metadata) { + const metadata = this.imageData.metadata; + if (metadata.dpiX) { + this.imageData.metadata.physicalWidth = result.width / metadata.dpiX; + } + if (metadata.dpiY) { + this.imageData.metadata.physicalHeight = result.height / metadata.dpiY; + } + } + + return this; + } + + /** + * Draw a line from (x0, y0) to (x1, y1) + * @param x0 Starting X coordinate + * @param y0 Starting Y coordinate + * @param x1 Ending X coordinate + * @param y1 Ending Y coordinate + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @param a Alpha component (0-255, default: 255) + * @returns This image instance for chaining + */ + drawLine( + x0: number, + y0: number, + x1: number, + y1: number, + r: number, + g: number, + b: number, + a = 255, + ): this { + if (!this.imageData) throw new Error("No image loaded"); + + this.imageData.data = drawLine( + this.imageData.data, + this.imageData.width, + this.imageData.height, + x0, + y0, + x1, + y1, + r, + g, + b, + a, + ); + + return this; + } + + /** + * Draw a circle at the specified position + * @param centerX Center X coordinate + * @param centerY Center Y coordinate + * @param radius Circle radius in pixels + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @param a Alpha component (0-255, default: 255) + * @param filled Whether to fill the circle (default: false, outline only) + * @returns This image instance for chaining + */ + drawCircle( + centerX: number, + centerY: number, + radius: number, + r: number, + g: number, + b: number, + a = 255, + filled = false, + ): this { + if (!this.imageData) throw new Error("No image loaded"); + + this.imageData.data = drawCircle( + this.imageData.data, + this.imageData.width, + this.imageData.height, + centerX, + centerY, + radius, + r, + g, + b, + a, + filled, + ); + + return this; + } } diff --git a/src/utils/image_processing.ts b/src/utils/image_processing.ts index b4bbf74..2c95e15 100644 --- a/src/utils/image_processing.ts +++ b/src/utils/image_processing.ts @@ -879,3 +879,268 @@ export function flipVertical( return result; } + +/** + * Add a border around an image + * @param data Image data (RGBA) + * @param width Image width + * @param height Image height + * @param borderWidth Border width in pixels (all sides) + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @param a Alpha component (0-255, default: 255) + * @returns Object with new dimensions and bordered image data + */ +export function addBorder( + data: Uint8Array, + width: number, + height: number, + borderWidth: number, + r: number, + g: number, + b: number, + a = 255, +): { data: Uint8Array; width: number; height: number } { + const newWidth = width + borderWidth * 2; + const newHeight = height + borderWidth * 2; + const result = new Uint8Array(newWidth * newHeight * 4); + + // Fill entire image with border color + for (let i = 0; i < result.length; i += 4) { + result[i] = r; + result[i + 1] = g; + result[i + 2] = b; + result[i + 3] = a; + } + + // Copy original image to center + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = (y * width + x) * 4; + const dstX = x + borderWidth; + const dstY = y + borderWidth; + const dstIdx = (dstY * newWidth + dstX) * 4; + + result[dstIdx] = data[srcIdx]; + result[dstIdx + 1] = data[srcIdx + 1]; + result[dstIdx + 2] = data[srcIdx + 2]; + result[dstIdx + 3] = data[srcIdx + 3]; + } + } + + return { data: result, width: newWidth, height: newHeight }; +} + +/** + * Add a border with different widths for each side + * @param data Image data (RGBA) + * @param width Image width + * @param height Image height + * @param top Top border width in pixels + * @param right Right border width in pixels + * @param bottom Bottom border width in pixels + * @param left Left border width in pixels + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @param a Alpha component (0-255, default: 255) + * @returns Object with new dimensions and bordered image data + */ +export function addBorderSides( + data: Uint8Array, + width: number, + height: number, + top: number, + right: number, + bottom: number, + left: number, + r: number, + g: number, + b: number, + a = 255, +): { data: Uint8Array; width: number; height: number } { + const newWidth = width + left + right; + const newHeight = height + top + bottom; + const result = new Uint8Array(newWidth * newHeight * 4); + + // Fill entire image with border color + for (let i = 0; i < result.length; i += 4) { + result[i] = r; + result[i + 1] = g; + result[i + 2] = b; + result[i + 3] = a; + } + + // Copy original image to position + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = (y * width + x) * 4; + const dstX = x + left; + const dstY = y + top; + const dstIdx = (dstY * newWidth + dstX) * 4; + + result[dstIdx] = data[srcIdx]; + result[dstIdx + 1] = data[srcIdx + 1]; + result[dstIdx + 2] = data[srcIdx + 2]; + result[dstIdx + 3] = data[srcIdx + 3]; + } + } + + return { data: result, width: newWidth, height: newHeight }; +} + +/** + * Draw a line from (x0, y0) to (x1, y1) using Bresenham's algorithm + * @param data Image data (RGBA) + * @param width Image width + * @param height Image height + * @param x0 Starting X coordinate + * @param y0 Starting Y coordinate + * @param x1 Ending X coordinate + * @param y1 Ending Y coordinate + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @param a Alpha component (0-255, default: 255) + * @returns Modified image data with line drawn + */ +export function drawLine( + data: Uint8Array, + width: number, + height: number, + x0: number, + y0: number, + x1: number, + y1: number, + r: number, + g: number, + b: number, + a = 255, +): Uint8Array { + const result = new Uint8Array(data); + + // Helper to set pixel with bounds checking + const setPixel = (x: number, y: number) => { + if (x >= 0 && x < width && y >= 0 && y < height) { + const idx = (y * width + x) * 4; + result[idx] = r; + result[idx + 1] = g; + result[idx + 2] = b; + result[idx + 3] = a; + } + }; + + // Bresenham's line algorithm + const dx = Math.abs(x1 - x0); + const dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + + let x = Math.round(x0); + let y = Math.round(y0); + const endX = Math.round(x1); + const endY = Math.round(y1); + + while (true) { + setPixel(x, y); + + if (x === endX && y === endY) break; + + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x += sx; + } + if (e2 < dx) { + err += dx; + y += sy; + } + } + + return result; +} + +/** + * Draw a circle at (centerX, centerY) with the given radius + * @param data Image data (RGBA) + * @param width Image width + * @param height Image height + * @param centerX Center X coordinate + * @param centerY Center Y coordinate + * @param radius Circle radius in pixels + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @param a Alpha component (0-255, default: 255) + * @param filled Whether to fill the circle (default: false) + * @returns Modified image data with circle drawn + */ +export function drawCircle( + data: Uint8Array, + width: number, + height: number, + centerX: number, + centerY: number, + radius: number, + r: number, + g: number, + b: number, + a = 255, + filled = false, +): Uint8Array { + const result = new Uint8Array(data); + + // Helper to set pixel with bounds checking + const setPixel = (x: number, y: number) => { + if (x >= 0 && x < width && y >= 0 && y < height) { + const idx = (y * width + x) * 4; + result[idx] = r; + result[idx + 1] = g; + result[idx + 2] = b; + result[idx + 3] = a; + } + }; + + const cx = Math.round(centerX); + const cy = Math.round(centerY); + const rad = Math.round(radius); + + if (filled) { + // Fill circle using bounding box approach + for (let y = -rad; y <= rad; y++) { + for (let x = -rad; x <= rad; x++) { + if (x * x + y * y <= rad * rad) { + setPixel(cx + x, cy + y); + } + } + } + } else { + // Midpoint circle algorithm for outline + let x = rad; + let y = 0; + let err = 0; + + while (x >= y) { + // Draw 8 octants + setPixel(cx + x, cy + y); + setPixel(cx + y, cy + x); + setPixel(cx - y, cy + x); + setPixel(cx - x, cy + y); + setPixel(cx - x, cy - y); + setPixel(cx - y, cy - x); + setPixel(cx + y, cy - x); + setPixel(cx + x, cy - y); + + y++; + err += 1 + 2 * y; + if (2 * (err - x) + 1 > 0) { + x--; + err += 1 - 2 * x; + } + } + } + + return result; +} diff --git a/test/border_drawing.test.ts b/test/border_drawing.test.ts new file mode 100644 index 0000000..ea6fcfa --- /dev/null +++ b/test/border_drawing.test.ts @@ -0,0 +1,342 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@cross/test"; +import { Image } from "../src/image.ts"; + +test("Image: border - adds uniform border to image", () => { + // Create a 2x2 red image + const data = new Uint8Array([ + 255, + 0, + 0, + 255, // red + 255, + 0, + 0, + 255, // red + 255, + 0, + 0, + 255, // red + 255, + 0, + 0, + 255, // red + ]); + + const image = Image.fromRGBA(2, 2, data); + + // Add a 1-pixel black border + image.border(1, 0, 0, 0, 255); + + // Image should now be 4x4 (2 + 1*2) + assertEquals(image.width, 4); + assertEquals(image.height, 4); + + // Check corners are black (border) + const topLeft = image.getPixel(0, 0); + assertEquals(topLeft, { r: 0, g: 0, b: 0, a: 255 }); + + const topRight = image.getPixel(3, 0); + assertEquals(topRight, { r: 0, g: 0, b: 0, a: 255 }); + + const bottomLeft = image.getPixel(0, 3); + assertEquals(bottomLeft, { r: 0, g: 0, b: 0, a: 255 }); + + const bottomRight = image.getPixel(3, 3); + assertEquals(bottomRight, { r: 0, g: 0, b: 0, a: 255 }); + + // Check center is still red (original image) + const center = image.getPixel(1, 1); + assertEquals(center, { r: 255, g: 0, b: 0, a: 255 }); +}); + +test("Image: border - adds colored border", () => { + const image = Image.create(2, 2, 255, 255, 255, 255); // white + + // Add a 2-pixel blue border + image.border(2, 0, 0, 255, 255); + + assertEquals(image.width, 6); + assertEquals(image.height, 6); + + // Check border is blue + const topLeft = image.getPixel(0, 0); + assertEquals(topLeft, { r: 0, g: 0, b: 255, a: 255 }); + + const topCenter = image.getPixel(3, 0); + assertEquals(topCenter, { r: 0, g: 0, b: 255, a: 255 }); + + // Check original image is still white + const center = image.getPixel(3, 3); + assertEquals(center, { r: 255, g: 255, b: 255, a: 255 }); +}); + +test("Image: border - adds transparent border", () => { + const image = Image.create(2, 2, 255, 0, 0, 255); // red + + // Add a 1-pixel transparent border + image.border(1, 0, 0, 0, 0); + + assertEquals(image.width, 4); + assertEquals(image.height, 4); + + // Check border is transparent + const topLeft = image.getPixel(0, 0); + assertEquals(topLeft, { r: 0, g: 0, b: 0, a: 0 }); + + // Check original image is still red + const center = image.getPixel(1, 1); + assertEquals(center, { r: 255, g: 0, b: 0, a: 255 }); +}); + +test("Image: borderSides - adds different border widths per side", () => { + const image = Image.create(2, 2, 255, 0, 0, 255); // red + + // Add borders: top=1, right=2, bottom=3, left=4 + image.borderSides(1, 2, 3, 4, 0, 255, 0, 255); // green border + + // Width: 2 + 4 (left) + 2 (right) = 8 + // Height: 2 + 1 (top) + 3 (bottom) = 6 + assertEquals(image.width, 8); + assertEquals(image.height, 6); + + // Check top border (1 pixel) + const top = image.getPixel(4, 0); + assertEquals(top, { r: 0, g: 255, b: 0, a: 255 }); + + // Check left border (4 pixels) + const left = image.getPixel(0, 2); + assertEquals(left, { r: 0, g: 255, b: 0, a: 255 }); + + // Check original image position (offset by left=4, top=1) + const original = image.getPixel(4, 1); + assertEquals(original, { r: 255, g: 0, b: 0, a: 255 }); +}); + +test("Image: borderSides - preserves metadata", () => { + const image = Image.create(100, 100, 255, 255, 255, 255); + image.setDPI(72); + + const originalDpiX = image.metadata?.dpiX; + const originalDpiY = image.metadata?.dpiY; + + image.borderSides(10, 10, 10, 10, 0, 0, 0, 255); + + assertEquals(image.metadata?.dpiX, originalDpiX); + assertEquals(image.metadata?.dpiY, originalDpiY); + // Physical dimensions should be updated + assertEquals(image.metadata?.physicalWidth, 120 / 72); + assertEquals(image.metadata?.physicalHeight, 120 / 72); +}); + +test("Image: drawLine - draws horizontal line", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw a red horizontal line from (2, 5) to (7, 5) + image.drawLine(2, 5, 7, 5, 255, 0, 0, 255); + + // Check pixels on the line + for (let x = 2; x <= 7; x++) { + const pixel = image.getPixel(x, 5); + assertEquals(pixel, { r: 255, g: 0, b: 0, a: 255 }, `Pixel at (${x}, 5)`); + } + + // Check pixels not on the line are still white + const above = image.getPixel(5, 4); + assertEquals(above, { r: 255, g: 255, b: 255, a: 255 }); + + const below = image.getPixel(5, 6); + assertEquals(below, { r: 255, g: 255, b: 255, a: 255 }); +}); + +test("Image: drawLine - draws vertical line", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw a blue vertical line from (5, 2) to (5, 7) + image.drawLine(5, 2, 5, 7, 0, 0, 255, 255); + + // Check pixels on the line + for (let y = 2; y <= 7; y++) { + const pixel = image.getPixel(5, y); + assertEquals(pixel, { r: 0, g: 0, b: 255, a: 255 }, `Pixel at (5, ${y})`); + } + + // Check pixels not on the line are still white + const left = image.getPixel(4, 5); + assertEquals(left, { r: 255, g: 255, b: 255, a: 255 }); + + const right = image.getPixel(6, 5); + assertEquals(right, { r: 255, g: 255, b: 255, a: 255 }); +}); + +test("Image: drawLine - draws diagonal line", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw a green diagonal line from (2, 2) to (7, 7) + image.drawLine(2, 2, 7, 7, 0, 255, 0, 255); + + // Check some pixels on the diagonal + const start = image.getPixel(2, 2); + assertEquals(start, { r: 0, g: 255, b: 0, a: 255 }); + + const middle = image.getPixel(4, 4); + assertEquals(middle, { r: 0, g: 255, b: 0, a: 255 }); + + const end = image.getPixel(7, 7); + assertEquals(end, { r: 0, g: 255, b: 0, a: 255 }); +}); + +test("Image: drawLine - handles negative direction", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw line from right to left + image.drawLine(7, 5, 2, 5, 255, 0, 0, 255); + + // Check pixels on the line + for (let x = 2; x <= 7; x++) { + const pixel = image.getPixel(x, 5); + assertEquals(pixel, { r: 255, g: 0, b: 0, a: 255 }); + } +}); + +test("Image: drawLine - clips to bounds", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw line that extends beyond image bounds + image.drawLine(-5, 5, 15, 5, 255, 0, 0, 255); + + // Check that only in-bounds pixels are drawn + const leftEdge = image.getPixel(0, 5); + assertEquals(leftEdge, { r: 255, g: 0, b: 0, a: 255 }); + + const rightEdge = image.getPixel(9, 5); + assertEquals(rightEdge, { r: 255, g: 0, b: 0, a: 255 }); +}); + +test("Image: drawCircle - draws circle outline", () => { + const image = Image.create(20, 20, 255, 255, 255, 255); // white + + // Draw a red circle outline at center with radius 5 + image.drawCircle(10, 10, 5, 255, 0, 0, 255, false); + + // Check that center is still white (not filled) + const center = image.getPixel(10, 10); + assertEquals(center, { r: 255, g: 255, b: 255, a: 255 }); + + // Check that some points on the circle are red + const top = image.getPixel(10, 5); + assertEquals(top, { r: 255, g: 0, b: 0, a: 255 }); + + const right = image.getPixel(15, 10); + assertEquals(right, { r: 255, g: 0, b: 0, a: 255 }); + + const bottom = image.getPixel(10, 15); + assertEquals(bottom, { r: 255, g: 0, b: 0, a: 255 }); + + const left = image.getPixel(5, 10); + assertEquals(left, { r: 255, g: 0, b: 0, a: 255 }); +}); + +test("Image: drawCircle - draws filled circle", () => { + const image = Image.create(20, 20, 255, 255, 255, 255); // white + + // Draw a filled blue circle at center with radius 5 + image.drawCircle(10, 10, 5, 0, 0, 255, 255, true); + + // Check that center is blue (filled) + const center = image.getPixel(10, 10); + assertEquals(center, { r: 0, g: 0, b: 255, a: 255 }); + + // Check that edge points are blue + const top = image.getPixel(10, 5); + assertEquals(top, { r: 0, g: 0, b: 255, a: 255 }); + + const right = image.getPixel(15, 10); + assertEquals(right, { r: 0, g: 0, b: 255, a: 255 }); + + // Check that points outside radius are still white + const outside = image.getPixel(10, 2); + assertEquals(outside, { r: 255, g: 255, b: 255, a: 255 }); +}); + +test("Image: drawCircle - clips to bounds", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw a circle partially outside bounds + image.drawCircle(5, 5, 10, 255, 0, 0, 255, true); + + // Should not throw, just clip to bounds + const topLeft = image.getPixel(0, 0); + assertEquals(topLeft, { r: 255, g: 0, b: 0, a: 255 }); + + const center = image.getPixel(5, 5); + assertEquals(center, { r: 255, g: 0, b: 0, a: 255 }); +}); + +test("Image: drawCircle - draws small circle", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw a very small circle (radius 1) + image.drawCircle(5, 5, 1, 0, 255, 0, 255, false); + + // Check that at least the center point is drawn + const center = image.getPixel(5, 5); + assertEquals(center?.g, 255); // Green component should be 255 +}); + +test("Image: border and drawing - chaining operations", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Chain operations: add border, draw line, draw circle + image + .border(2, 0, 0, 0, 255) // black border + .drawLine(5, 5, 10, 10, 255, 0, 0, 255) // red line + .drawCircle(7, 7, 3, 0, 255, 0, 255, false); // green circle + + // Image should be 14x14 after border + assertEquals(image.width, 14); + assertEquals(image.height, 14); + + // Check border is black + const corner = image.getPixel(0, 0); + assertEquals(corner, { r: 0, g: 0, b: 0, a: 255 }); +}); + +test("Image: drawLine - supports transparency", () => { + const image = Image.create(10, 10, 255, 255, 255, 255); // white + + // Draw a semi-transparent red line + image.drawLine(0, 5, 9, 5, 255, 0, 0, 128); + + const pixel = image.getPixel(5, 5); + assertEquals(pixel, { r: 255, g: 0, b: 0, a: 128 }); +}); + +test("Image: drawCircle - supports transparency", () => { + const image = Image.create(20, 20, 255, 255, 255, 255); // white + + // Draw a semi-transparent blue circle + image.drawCircle(10, 10, 5, 0, 0, 255, 128, true); + + const center = image.getPixel(10, 10); + assertEquals(center, { r: 0, g: 0, b: 255, a: 128 }); +}); + +test("Image: border - encode and decode roundtrip", async () => { + const image = Image.create(10, 10, 255, 0, 0, 255); // red + image.border(2, 0, 255, 0, 255); // green border + + const encoded = await image.encode("png"); + const decoded = await Image.decode(encoded); + + assertEquals(decoded.width, 14); + assertEquals(decoded.height, 14); + + // Check border + const corner = decoded.getPixel(0, 0); + assertEquals(corner, { r: 0, g: 255, b: 0, a: 255 }); + + // Check original image + const center = decoded.getPixel(7, 7); + assertEquals(center, { r: 255, g: 0, b: 0, a: 255 }); +});