From 7d3747ddcb5681ae9455fce7a1481552f8badbb1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:36:43 +0000 Subject: [PATCH 1/3] feat: add image format selection (BMP/PNG/JPEG) to settings Agent-Logs-Url: https://github.com/zhihongl/ClipPath/sessions/e7a9937d-dc0c-40a3-89f4-ba2980ba5b17 Co-authored-by: zhihongl <4370435+zhihongl@users.noreply.github.com> --- bun.lock | 5 + clippath/config.json | 7 ++ package.json | 3 + src/app/hotkey-handler.ts | 18 ++-- src/config.test.ts | 9 ++ src/config.ts | 4 + src/image/convert.ts | 174 +++++++++++++++++++++++++++++++++++ src/image/temp-files.test.ts | 36 +++++++- src/image/temp-files.ts | 38 +++++--- src/settings/api.ts | 13 ++- src/settings/page.ts | 29 +++++- 11 files changed, 310 insertions(+), 26 deletions(-) create mode 100644 clippath/config.json create mode 100644 src/image/convert.ts diff --git a/bun.lock b/bun.lock index a86cd6a..2ebc2dd 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "claude-copy-image", + "dependencies": { + "jpeg-js": "^0.4.4", + }, "devDependencies": { "@biomejs/biome": "^2.3.15", "@types/bun": "latest", @@ -14,6 +17,8 @@ }, }, "packages": { + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "@biomejs/biome": ["@biomejs/biome@2.3.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="], diff --git a/clippath/config.json b/clippath/config.json new file mode 100644 index 0000000..cb0b8d1 --- /dev/null +++ b/clippath/config.json @@ -0,0 +1,7 @@ +{ + "cleanupSchedule": "1h", + "dailyHour": 3, + "wslMode": null, + "shortcut": "Ctrl+Shift+V", + "imageFormat": "bmp" +} \ No newline at end of file diff --git a/package.json b/package.json index 9402631..720717a 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ }, "peerDependencies": { "typescript": "^5" + }, + "dependencies": { + "jpeg-js": "^0.4.4" } } diff --git a/src/app/hotkey-handler.ts b/src/app/hotkey-handler.ts index 7f3190d..edf8aee 100644 --- a/src/app/hotkey-handler.ts +++ b/src/app/hotkey-handler.ts @@ -7,15 +7,16 @@ import { existsSync } from "node:fs"; import { extractClipboardDIB } from "../clipboard/extract.ts"; import { hasClipboardImage } from "../clipboard/monitor.ts"; -import { saveDibAsBmp } from "../image/temp-files.ts"; +import { getConfig } from "../config.ts"; +import { saveDibAsImage } from "../image/temp-files.ts"; import { pasteString } from "../input/clipboard-paste.ts"; import { getPathForTerminal } from "../wsl.ts"; -let cachedBmpPath: string | null = null; +let cachedImagePath: string | null = null; /** Clear the cached path (e.g. after cleanup deletes all files). */ export function clearCachedPath(): void { - cachedBmpPath = null; + cachedImagePath = null; } /** Handle the Ctrl+Shift+V hotkey press. */ @@ -28,8 +29,9 @@ export function handleHotkey(): void { if (dibData) { console.log(`[hotkey] DIB data extracted, size=${dibData.length} bytes`); try { - cachedBmpPath = saveDibAsBmp(dibData); - console.log(`[hotkey] Saved: ${cachedBmpPath}`); + const format = getConfig().imageFormat; + cachedImagePath = saveDibAsImage(dibData, format); + console.log(`[hotkey] Saved: ${cachedImagePath}`); } catch (e) { console.error("[hotkey] Error saving image:", e); } @@ -37,8 +39,8 @@ export function handleHotkey(): void { } // Use cached path if available and file still exists - if (cachedBmpPath && existsSync(cachedBmpPath)) { - const pathToType = getPathForTerminal(cachedBmpPath); + if (cachedImagePath && existsSync(cachedImagePath)) { + const pathToType = getPathForTerminal(cachedImagePath); console.log(`[hotkey] Pasting: ${pathToType}`); setTimeout(() => { @@ -46,7 +48,7 @@ export function handleHotkey(): void { console.log(`[hotkey] pasteString result: ${ok}`); }, 0); } else { - cachedBmpPath = null; + cachedImagePath = null; console.log("[hotkey] No image available."); } } diff --git a/src/config.test.ts b/src/config.test.ts index ce55e1a..d01f3ec 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -19,6 +19,7 @@ describe("config", () => { expect(config).toHaveProperty("cleanupSchedule"); expect(config).toHaveProperty("dailyHour"); expect(config).toHaveProperty("wslMode"); + expect(config).toHaveProperty("imageFormat"); }); it("getConfig returns the same object as loadConfig produced", () => { @@ -52,6 +53,13 @@ describe("config", () => { expect(getConfig().cleanupSchedule).toBe(schedule); } }); + + it("updateConfig supports all imageFormat values", () => { + for (const format of ["bmp", "png", "jpeg"] as const) { + updateConfig({ imageFormat: format }); + expect(getConfig().imageFormat).toBe(format); + } + }); }); describe("errors", () => { @@ -79,6 +87,7 @@ describe("config", () => { expect(config.cleanupSchedule).not.toBeUndefined(); expect(config.dailyHour).not.toBeUndefined(); expect(config.wslMode !== undefined).toBe(true); + expect(config.imageFormat).not.toBeUndefined(); }); }); }); diff --git a/src/config.ts b/src/config.ts index c7f9c00..84b5b64 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,11 +3,14 @@ import { join } from "node:path"; export type CleanupSchedule = "off" | "30m" | "1h" | "6h" | "daily"; +export type ImageFormat = "bmp" | "png" | "jpeg"; + export interface AppConfig { cleanupSchedule: CleanupSchedule; dailyHour: number; wslMode: boolean | null; shortcut: string; + imageFormat: ImageFormat; } const DEFAULT_CONFIG: AppConfig = { @@ -15,6 +18,7 @@ const DEFAULT_CONFIG: AppConfig = { dailyHour: 3, wslMode: null, shortcut: "Ctrl+Shift+V", + imageFormat: "bmp", }; const CONFIG_DIR = join(process.env.APPDATA || "", "clippath"); diff --git a/src/image/convert.ts b/src/image/convert.ts new file mode 100644 index 0000000..5e2bea4 --- /dev/null +++ b/src/image/convert.ts @@ -0,0 +1,174 @@ +/** + * @file Image format conversion — converts DIB clipboard data to PNG or JPEG. + */ +import { deflateRawSync } from "node:zlib"; +import jpeg from "jpeg-js"; +import { BI_BITFIELDS } from "../win32/constants.ts"; +import { dibToBmp } from "./bmp.ts"; + +export type ImageFormat = "bmp" | "png" | "jpeg"; + +/** Calculate the color table size from BITMAPINFOHEADER fields (mirrors bmp.ts logic). */ +function getColorTableSize(dib: Buffer): number { + const biBitCount = dib.readUInt16LE(14); + const biCompression = dib.readUInt32LE(16); + const biClrUsed = dib.readUInt32LE(32); + + if (biCompression === BI_BITFIELDS) { + if (biBitCount === 16 || biBitCount === 32) return 12; + } + + if (biBitCount <= 8) { + const numColors = biClrUsed > 0 ? biClrUsed : 1 << biBitCount; + return numColors * 4; + } + + return 0; +} + +/** + * Extract raw RGBA pixel data (top-down row order) from a DIB buffer. + * Handles 32-bit and 24-bit DIBs; returns an opaque black image for unsupported formats. + */ +/** Extract one row of 32-bit BGRA pixels into RGBA output. */ +function extract32BitRow(dib: Buffer, rgba: Buffer, srcBase: number, dstBase: number, width: number): void { + for (let x = 0; x < width; x++) { + const src = srcBase + x * 4; + const dst = dstBase + x * 4; + rgba[dst] = dib[src + 2]; // R (from BGRA) + rgba[dst + 1] = dib[src + 1]; // G + rgba[dst + 2] = dib[src]; // B + rgba[dst + 3] = dib[src + 3] > 0 ? dib[src + 3] : 255; // A (treat 0 as opaque) + } +} + +/** Extract one row of 24-bit BGR pixels into RGBA output. */ +function extract24BitRow(dib: Buffer, rgba: Buffer, srcBase: number, dstBase: number, width: number): void { + for (let x = 0; x < width; x++) { + const src = srcBase + x * 3; + const dst = dstBase + x * 4; + rgba[dst] = dib[src + 2]; // R (from BGR) + rgba[dst + 1] = dib[src + 1]; // G + rgba[dst + 2] = dib[src]; // B + rgba[dst + 3] = 255; // A (fully opaque) + } +} + +export function dibToRgba(dib: Buffer): { width: number; height: number; rgba: Buffer } { + const biSize = dib.readUInt32LE(0); + const biWidth = dib.readInt32LE(4); + const biHeight = dib.readInt32LE(8); + const biBitCount = dib.readUInt16LE(14); + + const width = Math.abs(biWidth); + const height = Math.abs(biHeight); + const bottomUp = biHeight > 0; + + const colorTableSize = getColorTableSize(dib); + const pixelOffset = biSize + colorTableSize; + + const rgba = Buffer.alloc(width * height * 4); + + if (biBitCount === 32) { + const rowStride = width * 4; + for (let row = 0; row < height; row++) { + const srcRow = bottomUp ? height - 1 - row : row; + extract32BitRow(dib, rgba, pixelOffset + srcRow * rowStride, row * width * 4, width); + } + } else if (biBitCount === 24) { + const rowStride = (width * 3 + 3) & ~3; // padded to 4 bytes + for (let row = 0; row < height; row++) { + const srcRow = bottomUp ? height - 1 - row : row; + extract24BitRow(dib, rgba, pixelOffset + srcRow * rowStride, row * width * 4, width); + } + } + // Other bit depths: rgba stays zeroed (black — fallback to BMP for unsupported formats) + + return { width, height, rgba }; +} + +// ---- PNG encoder (pure TypeScript, uses built-in node:zlib) ---- + +/** Precomputed CRC32 lookup table for PNG chunk checksums. */ +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c; + } + return table; +})(); + +function crc32(buf: Buffer): number { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff]; + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, "ascii"); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([lenBuf, typeBuf, data, crcBuf]); +} + +/** Encode raw RGBA pixels as a PNG buffer (no external dependencies). */ +export function encodePng(width: number, height: number, rgba: Buffer): Buffer { + const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(width, 0); + ihdrData.writeUInt32BE(height, 4); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 6; // color type: RGBA + // compression(0), filter(0), interlace(0) are already 0 + + // Build raw scanlines: 1 filter byte (None=0) + RGBA row + const scanlineSize = 1 + width * 4; + const rawData = Buffer.alloc(height * scanlineSize); + for (let y = 0; y < height; y++) { + rawData[y * scanlineSize] = 0; // filter type: None + rgba.copy(rawData, y * scanlineSize + 1, y * width * 4, (y + 1) * width * 4); + } + + const compressed = deflateRawSync(rawData, { level: 6 }); + + return Buffer.concat([ + PNG_SIG, + pngChunk("IHDR", ihdrData), + pngChunk("IDAT", compressed), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +/** Encode raw RGBA pixels as a JPEG buffer using jpeg-js. */ +export function encodeJpeg(width: number, height: number, rgba: Buffer): Buffer { + const { data } = jpeg.encode({ data: rgba, width, height }, 92); + return data as Buffer; +} + +/** + * Convert DIB data to the target image format. + * Returns the encoded buffer and the file extension to use. + */ +export function dibToImageBuffer(dib: Buffer, format: ImageFormat): { data: Buffer; ext: string } { + if (format === "bmp") { + return { data: dibToBmp(dib), ext: "bmp" }; + } + + const { width, height, rgba } = dibToRgba(dib); + + if (format === "png") { + return { data: encodePng(width, height, rgba), ext: "png" }; + } + + // jpeg + return { data: encodeJpeg(width, height, rgba), ext: "jpg" }; +} diff --git a/src/image/temp-files.test.ts b/src/image/temp-files.test.ts index 6c9cb64..e20eb9f 100644 --- a/src/image/temp-files.test.ts +++ b/src/image/temp-files.test.ts @@ -1,6 +1,13 @@ import { afterAll, describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; -import { cleanAllFiles, cleanupOldFiles, countTempFiles, getTempDir, saveDibAsBmp } from "./temp-files.ts"; +import { + cleanAllFiles, + cleanupOldFiles, + countTempFiles, + getTempDir, + saveDibAsBmp, + saveDibAsImage, +} from "./temp-files.ts"; function makeTestDib(): Buffer { const dib = Buffer.alloc(44); @@ -32,12 +39,39 @@ describe("temp-files", () => { expect(existsSync(path)).toBe(true); }); + it("saveDibAsImage with format bmp creates a .bmp file", () => { + const path = saveDibAsImage(makeTestDib(), "bmp"); + expect(path.endsWith(".bmp")).toBe(true); + expect(existsSync(path)).toBe(true); + }); + + it("saveDibAsImage with format png creates a .png file", () => { + const path = saveDibAsImage(makeTestDib(), "png"); + expect(path.endsWith(".png")).toBe(true); + expect(existsSync(path)).toBe(true); + }); + + it("saveDibAsImage with format jpeg creates a .jpg file", () => { + const path = saveDibAsImage(makeTestDib(), "jpeg"); + expect(path.endsWith(".jpg")).toBe(true); + expect(existsSync(path)).toBe(true); + }); + it("saveDibAsBmp generates unique filenames", () => { const path1 = saveDibAsBmp(makeTestDib()); const path2 = saveDibAsBmp(makeTestDib()); expect(path1).not.toBe(path2); }); + it("countTempFiles counts bmp, png and jpg files", () => { + cleanAllFiles(); + expect(countTempFiles()).toBe(0); + saveDibAsImage(makeTestDib(), "bmp"); + saveDibAsImage(makeTestDib(), "png"); + saveDibAsImage(makeTestDib(), "jpeg"); + expect(countTempFiles()).toBe(3); + }); + it("countTempFiles increases after saving", () => { cleanAllFiles(); expect(countTempFiles()).toBe(0); diff --git a/src/image/temp-files.ts b/src/image/temp-files.ts index fa0b38e..282b607 100644 --- a/src/image/temp-files.ts +++ b/src/image/temp-files.ts @@ -1,14 +1,21 @@ /** - * @file Temp file management — save, clean, and count BMP files in the temp directory. + * @file Temp file management — save, clean, and count image files in the temp directory. */ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { dibToBmp } from "./bmp.ts"; +import type { ImageFormat } from "../config.ts"; +import { dibToImageBuffer } from "./convert.ts"; const TEMP_DIR = join(tmpdir(), "clippath"); let fileCounter = 0; +const IMAGE_EXTENSIONS = [".bmp", ".png", ".jpg"]; + +function isImageFile(filename: string): boolean { + return IMAGE_EXTENSIONS.some((ext) => filename.endsWith(ext)); +} + /** Get the temp directory path. */ export function getTempDir(): string { return TEMP_DIR; @@ -20,17 +27,22 @@ function ensureTempDir(): void { } } -/** Convert DIB data to BMP and save to temp directory. Returns the file path. */ -export function saveDibAsBmp(dibData: Buffer): string { +/** Convert DIB data to the given format and save to the temp directory. Returns the file path. */ +export function saveDibAsImage(dibData: Buffer, format: ImageFormat): string { ensureTempDir(); - const bmpData = dibToBmp(dibData); - const filename = `clipboard-${Date.now()}-${fileCounter++}.bmp`; + const { data, ext } = dibToImageBuffer(dibData, format); + const filename = `clipboard-${Date.now()}-${fileCounter++}.${ext}`; const filePath = join(TEMP_DIR, filename); - writeFileSync(filePath, bmpData); + writeFileSync(filePath, data); return filePath; } -/** Clean up temp files older than 1 hour. */ +/** Convert DIB data to BMP and save to temp directory. Returns the file path. */ +export function saveDibAsBmp(dibData: Buffer): string { + return saveDibAsImage(dibData, "bmp"); +} + +/** Clean up image files older than 1 hour. */ export function cleanupOldFiles(): void { try { if (!existsSync(TEMP_DIR)) return; @@ -38,7 +50,7 @@ export function cleanupOldFiles(): void { const now = Date.now(); for (const file of readdirSync(TEMP_DIR)) { - if (!file.endsWith(".bmp")) continue; + if (!isImageFile(file)) continue; try { const filePath = join(TEMP_DIR, file); if (now - statSync(filePath).mtimeMs > ONE_HOUR) { @@ -53,13 +65,13 @@ export function cleanupOldFiles(): void { } } -/** Delete ALL .bmp files in the temp directory. Returns count deleted. */ +/** Delete ALL image files in the temp directory. Returns count deleted. */ export function cleanAllFiles(): number { let count = 0; try { if (!existsSync(TEMP_DIR)) return 0; for (const file of readdirSync(TEMP_DIR)) { - if (!file.endsWith(".bmp")) continue; + if (!isImageFile(file)) continue; try { unlinkSync(join(TEMP_DIR, file)); count++; @@ -73,11 +85,11 @@ export function cleanAllFiles(): number { return count; } -/** Count how many .bmp files exist in the temp directory. */ +/** Count how many image files exist in the temp directory. */ export function countTempFiles(): number { try { if (!existsSync(TEMP_DIR)) return 0; - return readdirSync(TEMP_DIR).filter((f) => f.endsWith(".bmp")).length; + return readdirSync(TEMP_DIR).filter(isImageFile).length; } catch { return 0; } diff --git a/src/settings/api.ts b/src/settings/api.ts index 4090351..3b3546a 100644 --- a/src/settings/api.ts +++ b/src/settings/api.ts @@ -1,10 +1,11 @@ /** * @file Settings API — REST handlers bridging to existing modules. */ -import { clearCachedPath } from "../app/hotkey-handler.ts"; + import { rescheduleCleanup } from "../app/cleanup-scheduler.ts"; +import { clearCachedPath } from "../app/hotkey-handler.ts"; import { disableAutoStart, enableAutoStart, isAutoStartEnabled } from "../autostart.ts"; -import { type CleanupSchedule, getConfig, updateConfig } from "../config.ts"; +import { type CleanupSchedule, getConfig, type ImageFormat, updateConfig } from "../config.ts"; import { getHotkeyModifiers, getHotkeyVk } from "../hotkey.ts"; import { cleanAllFiles, countTempFiles, getTempDir } from "../image/temp-files.ts"; import { shortcutToString } from "../input/shortcut.ts"; @@ -30,6 +31,7 @@ export function handleGetConfig(): Response { cleanupSchedule: config.cleanupSchedule, autostart: isAutoStartEnabled(), fileCount: countTempFiles(), + imageFormat: config.imageFormat, }); } @@ -54,6 +56,13 @@ export async function handlePostConfig(req: Request): Promise { console.log(`[settings] Auto-clean schedule: ${schedule}`); } + // Image format + if ("imageFormat" in body) { + const format = body.imageFormat as ImageFormat; + updateConfig({ imageFormat: format }); + console.log(`[settings] Image format: ${format}`); + } + // Autostart if ("autostart" in body) { if (body.autostart) enableAutoStart(); diff --git a/src/settings/page.ts b/src/settings/page.ts index 60898ad..7e251c7 100644 --- a/src/settings/page.ts +++ b/src/settings/page.ts @@ -263,6 +263,22 @@ export function getSettingsHtml(): string { + +
+
+ +
+
+ + + + + + +
+
Format used when saving clipboard images to disk.
+
+
@@ -334,6 +350,8 @@ async function loadConfig() { document.getElementById("cleanupSchedule").value = cfg.cleanupSchedule; document.getElementById("autostart").checked = cfg.autostart; + const fmtRadio = document.querySelector('input[name="imageFormat"][value="' + (cfg.imageFormat || "bmp") + '"]'); + if (fmtRadio) fmtRadio.checked = true; updateFileCount(cfg.fileCount); } @@ -350,6 +368,13 @@ document.querySelectorAll('input[name="pathMode"]').forEach(radio => { }); }); +document.querySelectorAll('input[name="imageFormat"]').forEach(radio => { + radio.addEventListener("change", async (e) => { + await api("/api/config", "POST", { imageFormat: e.target.value }); + showToast("Image format updated"); + }); +}); + document.getElementById("cleanupSchedule").addEventListener("change", async (e) => { await api("/api/config", "POST", { cleanupSchedule: e.target.value }); showToast("Schedule updated"); @@ -417,12 +442,12 @@ document.getElementById("btnOpenFolder").addEventListener("click", async () => { }); // Ensure correct size (Edge may ignore --window-size if already running) -window.resizeTo(320, 410); +window.resizeTo(320, 470); // Center window on screen window.moveTo( (screen.width - 320) / 2, - (screen.height - 410) / 2 + (screen.height - 470) / 2 ); loadConfig(); From 3d07da2e9628da0380adac9730501b663c642202 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:39:29 +0000 Subject: [PATCH 2/3] fix: address code review feedback (type dedup, validation, complexity) Agent-Logs-Url: https://github.com/zhihongl/ClipPath/sessions/e7a9937d-dc0c-40a3-89f4-ba2980ba5b17 Co-authored-by: zhihongl <4370435+zhihongl@users.noreply.github.com> --- src/image/convert.ts | 3 +-- src/settings/api.ts | 19 +++++++++++++++---- src/settings/page.ts | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/image/convert.ts b/src/image/convert.ts index 5e2bea4..a110ca1 100644 --- a/src/image/convert.ts +++ b/src/image/convert.ts @@ -3,11 +3,10 @@ */ import { deflateRawSync } from "node:zlib"; import jpeg from "jpeg-js"; +import type { ImageFormat } from "../config.ts"; import { BI_BITFIELDS } from "../win32/constants.ts"; import { dibToBmp } from "./bmp.ts"; -export type ImageFormat = "bmp" | "png" | "jpeg"; - /** Calculate the color table size from BITMAPINFOHEADER fields (mirrors bmp.ts logic). */ function getColorTableSize(dib: Buffer): number { const biBitCount = dib.readUInt16LE(14); diff --git a/src/settings/api.ts b/src/settings/api.ts index 3b3546a..7139277 100644 --- a/src/settings/api.ts +++ b/src/settings/api.ts @@ -5,7 +5,7 @@ import { rescheduleCleanup } from "../app/cleanup-scheduler.ts"; import { clearCachedPath } from "../app/hotkey-handler.ts"; import { disableAutoStart, enableAutoStart, isAutoStartEnabled } from "../autostart.ts"; -import { type CleanupSchedule, getConfig, type ImageFormat, updateConfig } from "../config.ts"; +import { type CleanupSchedule, getConfig, updateConfig } from "../config.ts"; import { getHotkeyModifiers, getHotkeyVk } from "../hotkey.ts"; import { cleanAllFiles, countTempFiles, getTempDir } from "../image/temp-files.ts"; import { shortcutToString } from "../input/shortcut.ts"; @@ -35,6 +35,19 @@ export function handleGetConfig(): Response { }); } +const VALID_IMAGE_FORMATS = ["bmp", "png", "jpeg"] as const; + +/** Apply image format update from a settings POST body. */ +function applyImageFormat(format: unknown): void { + const f = format as string; + if (VALID_IMAGE_FORMATS.includes(f as (typeof VALID_IMAGE_FORMATS)[number])) { + updateConfig({ imageFormat: f as (typeof VALID_IMAGE_FORMATS)[number] }); + console.log(`[settings] Image format: ${f}`); + } else { + console.warn(`[settings] Ignoring unknown imageFormat: ${f}`); + } +} + /** POST /api/config — update one or more config fields. */ export async function handlePostConfig(req: Request): Promise { const body = (await req.json()) as Record; @@ -58,9 +71,7 @@ export async function handlePostConfig(req: Request): Promise { // Image format if ("imageFormat" in body) { - const format = body.imageFormat as ImageFormat; - updateConfig({ imageFormat: format }); - console.log(`[settings] Image format: ${format}`); + applyImageFormat(body.imageFormat); } // Autostart diff --git a/src/settings/page.ts b/src/settings/page.ts index 7e251c7..48e48fc 100644 --- a/src/settings/page.ts +++ b/src/settings/page.ts @@ -350,7 +350,7 @@ async function loadConfig() { document.getElementById("cleanupSchedule").value = cfg.cleanupSchedule; document.getElementById("autostart").checked = cfg.autostart; - const fmtRadio = document.querySelector('input[name="imageFormat"][value="' + (cfg.imageFormat || "bmp") + '"]'); + const fmtRadio = document.querySelector('input[name="imageFormat"][value="' + cfg.imageFormat + '"]'); if (fmtRadio) fmtRadio.checked = true; updateFileCount(cfg.fileCount); } From 1f75f0e97b3e28ed179463fc6ecf0496247176c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:46:07 +0000 Subject: [PATCH 3/3] test: add convert.test.ts covering dibToRgba, encodePng, encodeJpeg, dibToImageBuffer Agent-Logs-Url: https://github.com/zhihongl/ClipPath/sessions/5129bec4-45b4-4cf0-860a-b3a49693e549 Co-authored-by: zhihongl <4370435+zhihongl@users.noreply.github.com> --- package-lock.json | 254 +++++++++++++++++++++++++++++ package.json | 2 +- src/image/convert.test.ts | 330 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 package-lock.json create mode 100644 src/image/convert.test.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f3113e2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,254 @@ +{ + "name": "clippath", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clippath", + "version": "1.0.0", + "dependencies": { + "jpeg-js": "^0.4.4" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.11.tgz", + "integrity": "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.11", + "@biomejs/cli-darwin-x64": "2.4.11", + "@biomejs/cli-linux-arm64": "2.4.11", + "@biomejs/cli-linux-arm64-musl": "2.4.11", + "@biomejs/cli-linux-x64": "2.4.11", + "@biomejs/cli-linux-x64-musl": "2.4.11", + "@biomejs/cli-win32-arm64": "2.4.11", + "@biomejs/cli-win32-x64": "2.4.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.11.tgz", + "integrity": "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.11.tgz", + "integrity": "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.11.tgz", + "integrity": "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.11.tgz", + "integrity": "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.11.tgz", + "integrity": "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.11.tgz", + "integrity": "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.11.tgz", + "integrity": "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.11.tgz", + "integrity": "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@types/bun": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.12.tgz", + "integrity": "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.12" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/bun-types": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.12.tgz", + "integrity": "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 720717a..284381b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "bun test" }, "devDependencies": { - "@biomejs/biome": "^2.3.15", + "@biomejs/biome": "^2.4.11", "@types/bun": "latest" }, "peerDependencies": { diff --git a/src/image/convert.test.ts b/src/image/convert.test.ts new file mode 100644 index 0000000..f4d165a --- /dev/null +++ b/src/image/convert.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "bun:test"; +import { dibToImageBuffer, dibToRgba, encodeJpeg, encodePng } from "./convert.ts"; + +// ---- DIB builder helpers ---- + +/** + * Build a minimal BITMAPINFOHEADER (40 bytes) followed by pixel data. + * biHeight > 0 means bottom-up (the Windows default). + */ +function makeDib( + width: number, + height: number, + bitCount: number, + pixels: Buffer, + opts: { topDown?: boolean; compression?: number; clrUsed?: number } = {}, +): Buffer { + const HEADER_SIZE = 40; + const biHeight = opts.topDown ? -height : height; + const dib = Buffer.alloc(HEADER_SIZE + pixels.length); + dib.writeUInt32LE(HEADER_SIZE, 0); // biSize + dib.writeInt32LE(width, 4); // biWidth + dib.writeInt32LE(biHeight, 8); // biHeight + dib.writeUInt16LE(1, 12); // biPlanes + dib.writeUInt16LE(bitCount, 14); // biBitCount + dib.writeUInt32LE(opts.compression ?? 0, 16); // biCompression + dib.writeUInt32LE(pixels.length, 20); // biSizeImage + dib.writeUInt32LE(0, 32); // biClrUsed + pixels.copy(dib, HEADER_SIZE); + return dib; +} + +/** Build a single 32-bit BGRA pixel buffer. */ +function bgra(b: number, g: number, r: number, a: number): Buffer { + return Buffer.from([b, g, r, a]); +} + +/** Build a single 24-bit BGR pixel with padding to 4-byte row stride. */ +function bgr24(b: number, g: number, r: number): Buffer { + return Buffer.from([b, g, r, 0]); // 1 padding byte for 1-pixel row +} + +// ---- dibToRgba ---- + +describe("dibToRgba", () => { + describe("success", () => { + it("returns the correct width and height for a 32-bit DIB", () => { + const dib = makeDib(3, 5, 32, Buffer.alloc(3 * 5 * 4)); + const { width, height } = dibToRgba(dib); + expect(width).toBe(3); + expect(height).toBe(5); + }); + + it("returns correct dimensions from a top-down 32-bit DIB", () => { + const dib = makeDib(2, 4, 32, Buffer.alloc(2 * 4 * 4), { topDown: true }); + const { width, height } = dibToRgba(dib); + expect(width).toBe(2); + expect(height).toBe(4); + }); + + it("swaps BGRA to RGBA channels for a 32-bit pixel", () => { + // pixel in file: B=0x11, G=0x22, R=0x33, A=0xFF + const dib = makeDib(1, 1, 32, bgra(0x11, 0x22, 0x33, 0xff)); + const { rgba } = dibToRgba(dib); + expect(rgba[0]).toBe(0x33); // R + expect(rgba[1]).toBe(0x22); // G + expect(rgba[2]).toBe(0x11); // B + expect(rgba[3]).toBe(0xff); // A + }); + + it("treats a 32-bit pixel with alpha=0 as fully opaque (A=255)", () => { + const dib = makeDib(1, 1, 32, bgra(0x10, 0x20, 0x30, 0x00)); + const { rgba } = dibToRgba(dib); + expect(rgba[3]).toBe(255); + }); + + it("preserves non-zero alpha values for 32-bit pixels", () => { + const dib = makeDib(1, 1, 32, bgra(0, 0, 0, 0x80)); + const { rgba } = dibToRgba(dib); + expect(rgba[3]).toBe(0x80); + }); + + it("reverses bottom-up row order for a 1×2 32-bit DIB", () => { + // file order: bottom row first, then top row + const bottomPx = bgra(0x01, 0x02, 0x03, 0xff); // B=0x01 → R=0x03 + const topPx = bgra(0x04, 0x05, 0x06, 0xff); // B=0x04 → R=0x06 + const dib = makeDib(1, 2, 32, Buffer.concat([bottomPx, topPx])); + const { rgba } = dibToRgba(dib); + // output row 0 should be the top pixel (file row 1) + expect(rgba[0]).toBe(0x06); // R of top pixel + // output row 1 should be the bottom pixel (file row 0) + expect(rgba[4]).toBe(0x03); // R of bottom pixel + }); + + it("preserves top-down row order for a 1×2 32-bit DIB", () => { + const firstPx = bgra(0x01, 0x02, 0x03, 0xff); + const secondPx = bgra(0x04, 0x05, 0x06, 0xff); + const dib = makeDib(1, 2, 32, Buffer.concat([firstPx, secondPx]), { topDown: true }); + const { rgba } = dibToRgba(dib); + // output row 0 should be firstPx → R=0x03 + expect(rgba[0]).toBe(0x03); + // output row 1 should be secondPx → R=0x06 + expect(rgba[4]).toBe(0x06); + }); + + it("swaps BGR to RGBA channels for a 24-bit pixel", () => { + // pixel in file: B=0xAA, G=0xBB, R=0xCC, then 1 padding byte + const dib = makeDib(1, 1, 24, bgr24(0xaa, 0xbb, 0xcc)); + const { rgba } = dibToRgba(dib); + expect(rgba[0]).toBe(0xcc); // R + expect(rgba[1]).toBe(0xbb); // G + expect(rgba[2]).toBe(0xaa); // B + expect(rgba[3]).toBe(255); // A always 255 for 24-bit + }); + + it("sets alpha to 255 for all 24-bit pixels", () => { + // 2-pixel row: stride = (2*3+3)&~3 = 8, so 8 bytes with 2 padding bytes + const stride = 8; + const raw = Buffer.alloc(stride); + raw[0] = 1; + raw[1] = 2; + raw[2] = 3; // pixel 0: BGR + raw[3] = 4; + raw[4] = 5; + raw[5] = 6; // pixel 1: BGR + const dib = makeDib(2, 1, 24, raw); + const { rgba } = dibToRgba(dib); + expect(rgba[3]).toBe(255); + expect(rgba[7]).toBe(255); + }); + + it("returns a buffer of width × height × 4 bytes", () => { + const dib = makeDib(3, 4, 32, Buffer.alloc(3 * 4 * 4)); + const { rgba, width, height } = dibToRgba(dib); + expect(rgba.length).toBe(width * height * 4); + }); + }); + + describe("errors", () => { + it("returns a zeroed buffer for unsupported bit depth (1-bit)", () => { + const dib = makeDib(1, 1, 1, Buffer.alloc(4)); + const { rgba } = dibToRgba(dib); + expect(rgba.every((b) => b === 0)).toBe(true); + }); + + it("returns correct width and height even for unsupported bit depth", () => { + const dib = makeDib(2, 3, 1, Buffer.alloc(12)); + const { width, height } = dibToRgba(dib); + expect(width).toBe(2); + expect(height).toBe(3); + }); + + it("does not throw on a header-only DIB with no pixel data", () => { + const dib = makeDib(0, 0, 32, Buffer.alloc(0)); + expect(() => dibToRgba(dib)).not.toThrow(); + }); + }); +}); + +// ---- encodePng ---- + +describe("encodePng", () => { + describe("success", () => { + it("starts with the 8-byte PNG signature", () => { + const rgba = Buffer.alloc(4); // 1×1 opaque black + const png = encodePng(1, 1, rgba); + expect(png[0]).toBe(137); + expect(png[1]).toBe(80); + expect(png[2]).toBe(78); + expect(png[3]).toBe(71); + expect(png[4]).toBe(13); + expect(png[5]).toBe(10); + expect(png[6]).toBe(26); + expect(png[7]).toBe(10); + }); + + it("has IHDR chunk type at byte 12", () => { + const png = encodePng(1, 1, Buffer.alloc(4)); + expect(png.subarray(12, 16).toString("ascii")).toBe("IHDR"); + }); + + it("encodes the image width in IHDR at byte 16 (big-endian)", () => { + const png = encodePng(7, 3, Buffer.alloc(7 * 3 * 4)); + expect(png.readUInt32BE(16)).toBe(7); + }); + + it("encodes the image height in IHDR at byte 20 (big-endian)", () => { + const png = encodePng(7, 3, Buffer.alloc(7 * 3 * 4)); + expect(png.readUInt32BE(20)).toBe(3); + }); + + it("sets bit depth to 8 in IHDR at byte 24", () => { + const png = encodePng(1, 1, Buffer.alloc(4)); + expect(png[24]).toBe(8); + }); + + it("sets color type to 6 (RGBA) in IHDR at byte 25", () => { + const png = encodePng(1, 1, Buffer.alloc(4)); + expect(png[25]).toBe(6); + }); + + it("ends with an IEND chunk", () => { + const png = encodePng(1, 1, Buffer.alloc(4)); + // IEND chunk: 4-byte length(=0) + "IEND" + 4-byte CRC = 12 bytes at the end + expect(png.subarray(png.length - 8, png.length - 4).toString("ascii")).toBe("IEND"); + }); + + it("produces a non-empty buffer for a 1×1 image", () => { + const png = encodePng(1, 1, Buffer.alloc(4)); + expect(png.length).toBeGreaterThan(0); + }); + + it("produces a larger buffer for larger images", () => { + const small = encodePng(1, 1, Buffer.alloc(4)); + const large = encodePng(10, 10, Buffer.alloc(400)); + expect(large.length).toBeGreaterThan(small.length); + }); + }); + + describe("errors", () => { + it("does not throw for a 0×0 image", () => { + expect(() => encodePng(0, 0, Buffer.alloc(0))).not.toThrow(); + }); + + it("returns a buffer that still starts with the PNG signature for a 0×0 image", () => { + const png = encodePng(0, 0, Buffer.alloc(0)); + expect(png[0]).toBe(137); + expect(png[1]).toBe(80); + }); + }); +}); + +// ---- encodeJpeg ---- + +describe("encodeJpeg", () => { + describe("success", () => { + it("returns a non-empty buffer for a 1×1 image", () => { + const rgba = Buffer.from([255, 0, 0, 255]); // red pixel + const jpg = encodeJpeg(1, 1, rgba); + expect(jpg.length).toBeGreaterThan(0); + }); + + it("starts with the JPEG SOI marker (0xFF 0xD8)", () => { + const rgba = Buffer.alloc(4 * 2 * 2, 128); // 2×2 grey + const jpg = encodeJpeg(2, 2, rgba); + expect(jpg[0]).toBe(0xff); + expect(jpg[1]).toBe(0xd8); + }); + + it("returns a Buffer instance", () => { + const jpg = encodeJpeg(1, 1, Buffer.alloc(4)); + expect(Buffer.isBuffer(jpg)).toBe(true); + }); + + it("produces a larger output for larger images", () => { + const small = encodeJpeg(1, 1, Buffer.alloc(4)); + // use a non-trivial 32×32 image with varying content to exceed JPEG header minimum + const largeRgba = Buffer.allocUnsafe(32 * 32 * 4); + for (let i = 0; i < largeRgba.length; i++) largeRgba[i] = (i * 7) & 0xff; + const large = encodeJpeg(32, 32, largeRgba); + expect(large.length).toBeGreaterThan(small.length); + }); + }); + + describe("errors", () => { + it("does not throw for an all-zero RGBA buffer", () => { + expect(() => encodeJpeg(2, 2, Buffer.alloc(2 * 2 * 4))).not.toThrow(); + }); + }); +}); + +// ---- dibToImageBuffer ---- + +describe("dibToImageBuffer", () => { + function makeSimpleDib(): Buffer { + return makeDib(1, 1, 32, bgra(0x10, 0x20, 0x30, 0xff)); + } + + describe("success", () => { + it("returns ext 'bmp' for format bmp", () => { + const { ext } = dibToImageBuffer(makeSimpleDib(), "bmp"); + expect(ext).toBe("bmp"); + }); + + it("returns ext 'png' for format png", () => { + const { ext } = dibToImageBuffer(makeSimpleDib(), "png"); + expect(ext).toBe("png"); + }); + + it("returns ext 'jpg' for format jpeg", () => { + const { ext } = dibToImageBuffer(makeSimpleDib(), "jpeg"); + expect(ext).toBe("jpg"); + }); + + it("returns a BMP buffer with 'BM' signature for format bmp", () => { + const { data } = dibToImageBuffer(makeSimpleDib(), "bmp"); + expect(data.readUInt16LE(0)).toBe(0x4d42); // 'BM' + }); + + it("returns a PNG buffer with correct signature for format png", () => { + const { data } = dibToImageBuffer(makeSimpleDib(), "png"); + expect(data[0]).toBe(137); + expect(data[1]).toBe(80); // 'P' + }); + + it("returns a JPEG buffer with SOI marker for format jpeg", () => { + const { data } = dibToImageBuffer(makeSimpleDib(), "jpeg"); + expect(data[0]).toBe(0xff); + expect(data[1]).toBe(0xd8); + }); + + it("returns a non-empty data buffer for all formats", () => { + for (const format of ["bmp", "png", "jpeg"] as const) { + const { data } = dibToImageBuffer(makeSimpleDib(), format); + expect(data.length).toBeGreaterThan(0); + } + }); + }); + + describe("errors", () => { + it("does not throw for a 0×0 DIB with png format", () => { + const dib = makeDib(0, 0, 32, Buffer.alloc(0)); + expect(() => dibToImageBuffer(dib, "png")).not.toThrow(); + }); + + it("does not throw for a 0×0 DIB with bmp format", () => { + const dib = makeDib(0, 0, 32, Buffer.alloc(0)); + expect(() => dibToImageBuffer(dib, "bmp")).not.toThrow(); + }); + }); +});