/** * Unicode half-block color thumbnails for terminals without Kitty/iTerm/Sixel. * Works on Windows Terminal + WSL (truecolor ANSI). */ import { PNG } from "pngjs"; import jpeg from "jpeg-js"; export interface RgbaImage { width: number; height: number; data: Uint8Array; // RGBA } function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } export function decodeBase64Image( base64: string, mimeType: string, ): RgbaImage | null { try { const buf = Buffer.from(base64, "base64"); if (mimeType === "image/png" || (buf[0] === 0x89 && buf[1] === 0x50)) { const png = PNG.sync.read(buf); return { width: png.width, height: png.height, data: png.data }; } if (mimeType === "image/jpeg" || (buf[0] === 0xff && buf[1] === 0xd8)) { const jpg = jpeg.decode(buf, { useTArray: true, formatAsRGBA: true }); return { width: jpg.width, height: jpg.height, data: jpg.data as Uint8Array, }; } // gif/webp: no pure-js lightweight decoder here return null; } catch { return null; } } function sample( img: RgbaImage, x: number, y: number, ): [number, number, number, number] { const sx = clamp(Math.floor(x), 0, img.width - 1); const sy = clamp(Math.floor(y), 0, img.height - 1); const i = (sy * img.width + sx) * 4; return [img.data[i]!, img.data[i + 1]!, img.data[i + 2]!, img.data[i + 3]!]; } function avg2( a: [number, number, number, number], b: [number, number, number, number], ): [number, number, number] { // alpha-aware against dark background const aa = a[3] / 255; const ba = b[3] / 255; const ar = a[0] * aa; const ag = a[1] * aa; const ab = a[2] * aa; const br = b[0] * ba; const bg = b[1] * ba; const bb = b[2] * ba; return [ Math.round((ar + br) / 2), Math.round((ag + bg) / 2), Math.round((ab + bb) / 2), ]; } function fg(r: number, g: number, b: number, ch: string): string { return `\x1b[38;2;${r};${g};${b}m${ch}\x1b[39m`; } function bg(r: number, g: number, b: number, ch: string): string { return `\x1b[48;2;${r};${g};${b}m${ch}\x1b[49m`; } function cell( top: [number, number, number], bottom: [number, number, number], ): string { // Use half block: upper color = fg, lower color = bg, char = â–€ return `\x1b[38;2;${top[0]};${top[1]};${top[2]}m\x1b[48;2;${bottom[0]};${bottom[1]};${bottom[2]}mâ–€\x1b[0m`; } /** * Render image to half-block ANSI lines. * Each terminal row covers 2 image rows. */ export function renderHalfBlock( img: RgbaImage, maxCols: number, maxRows: number, ): string[] { const maxWidth = Math.max(4, Math.floor(maxCols)); const maxHeightCells = Math.max(2, Math.floor(maxRows)); // Each cell is 1 col wide and 2 image-pixels tall. const scale = Math.min( maxWidth / img.width, (maxHeightCells * 2) / img.height, 1, ); const outW = Math.max(1, Math.floor(img.width * scale)); const outHPx = Math.max(2, Math.floor(img.height * scale)); // even height const hPx = outHPx % 2 === 0 ? outHPx : outHPx + 1; const rows = hPx / 2; const lines: string[] = []; for (let row = 0; row < rows; row++) { let line = ""; const y0 = (row * 2 * img.height) / hPx; const y1 = ((row * 2 + 1) * img.height) / hPx; for (let col = 0; col < outW; col++) { const x = ((col + 0.5) * img.width) / outW; const topPx = sample(img, x, y0 + 0.25); const botPx = sample(img, x, y1 + 0.25); // composite alpha on black const top: [number, number, number] = [ Math.round((topPx[0] * topPx[3]) / 255), Math.round((topPx[1] * topPx[3]) / 255), Math.round((topPx[2] * topPx[3]) / 255), ]; const bot: [number, number, number] = [ Math.round((botPx[0] * botPx[3]) / 255), Math.round((botPx[1] * botPx[3]) / 255), Math.round((botPx[2] * botPx[3]) / 255), ]; line += cell(top, bot); } lines.push(line); } return lines; } export function thumbnailLinesFromBase64( base64: string, mimeType: string, maxCols: number, maxRows: number, ): string[] | null { const img = decodeBase64Image(base64, mimeType); if (!img) return null; return renderHalfBlock(img, maxCols, maxRows); } // silence unused import helpers if tree-shaken void fg; void bg; void avg2;