claude-code/src/utils/ansiToPng.ts

335 lines
210 KiB
TypeScript
Raw Normal View History

2026-03-31 19:22:47 +08:00
/**
* Render ANSI-escaped terminal text directly to a PNG image.
*
* Replaces the previous ansiToSvg @resvg/resvg-wasm pipeline. The SVG was
* just a lossy intermediate format for what is fundamentally a grid of
* (char, fg-color, bold) cells on a flat background. This module skips SVG
* entirely: it blits a bundled 24×48 bitmap font directly into an RGBA
* Uint8Array, then encodes the result as a PNG using node:zlib.
*
* Why not resvg-wasm: 2.36MB of embedded WASM, a 2.1MB runtime font load
* from a hardcoded system path (returning [] blank screenshots when the
* font isn't found), and ~224ms per render. This path is ~515ms, zero
* external deps, identical output on mac/linux/windows.
*
* Font: Fira Code Regular rasterized at 24×48 with 8-bit anti-aliased alpha
* (SIL OFL 1.1 see scripts/LICENSE-FiraCode). Covers printable ASCII plus
* the unicode chars used by /stats output. Regenerate with:
* bun scripts/generate-bitmap-font.ts
*/
import { deflateSync } from 'zlib'
import { stringWidth } from '../ink/stringWidth.js'
import {
type AnsiColor,
DEFAULT_BG,
type ParsedLine,
parseAnsi,
} from './ansiToSvg.js'
// Glyph cell size — rasterized at output resolution so the default scale=1
// is crisp (no nearest-neighbor upscaling artifacts).
const GLYPH_W = 24
const GLYPH_H = 48
const GLYPH_BYTES = GLYPH_W * GLYPH_H
// Packed font rasterized from Fira Code Regular (SIL OFL 1.1).
// Copyright (c) 2014-2021 The Fira Code Project Authors.
// License: scripts/LICENSE-FiraCode
// Format: [count:u16le][codepoint:u32le, alpha:GLYPH_W*GLYPH_H bytes]...
const FONT_B64 =
'hQAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwQEBAEAAAAAAAAAAAAAAAAAAAAAAAAAC/////EAAAAAAAAAAAAAAAAAAAAAAAAAC/////AAAAAAAAAAAAAAAAAAAAAAAAAAC/////AAAAAAAAAAAAAAAAAAAAAAAAAAC/////AAAAAAAAAAAAAAAAAAAAAAAAAACP////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA///vAAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAABw//+/AAAAAAAAAAAAAAAAAAAAAAAAAABA//+/AAAAAAAAAAAAAAAAAAAAAAAAAABA//+/AAAAAAAAAAAAAAAAAAAAAAAAAAAwv7+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABg7/+/EAAAAAAAAAAAAAAAAAAAAAAAADD/////vwAAAAAAAAAAAAAAAAAAAAAAAID//////wAAAAAAAAAAAAAAAAAAAAAAAGD/////7wAAAAAAAAAAAAAAAAAAAAAAAADP////YAAAAAAAAAAAAAAAAAAAAAAAAAAAYIAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQEBAQAAAIEBAQEAAAAAAAAAAAAAAAABA/////wAAUP///88AAAAAAAAAAAAAAABA/////wAAQP///78AAAAAAAAAAAAAAAAg////3wAAQP///78AAAAAAAAAAAAAAAAA////vwAAQP///78AAAAAAAAAAAAAAAAA////vwAAIP///48AAAAAAAAAAAAAAAAA////vwAAAP///4AAAAAAAAAAAAAAAAAA3///nwAAAP///4AAAAAAAAAAAAAAAAAAv///gAAAAP///4AAAAAAAAAAAAAAAAAAv///gAAAAO///1AAAAAAAAAAAAAAAAAAv///gAAAAL///0AAAAAAAAAAAAAAAAAAMEBAIAAAADBAQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
// Dotted-box fallback for codepoints outside the bundled set.
const FALLBACK_GLYPH = makeFallbackGlyph()
function makeFallbackGlyph(): Uint8Array {
const g = new Uint8Array(GLYPH_BYTES)
for (let y = 2; y < GLYPH_H - 4; y++) {
for (let x = 1; x < GLYPH_W - 1; x++) {
const onBorder =
y === 2 || y === GLYPH_H - 5 || x === 1 || x === GLYPH_W - 2
if (onBorder && (x + y) % 2 === 0) g[y * GLYPH_W + x] = 255
}
}
return g
}
const FONT: Map<number, Uint8Array> = decodeFont()
function decodeFont(): Map<number, Uint8Array> {
const buf = Buffer.from(FONT_B64, 'base64')
const count = buf.readUInt16LE(0)
const map = new Map<number, Uint8Array>()
let off = 2
for (let i = 0; i < count; i++) {
const cp = buf.readUInt32LE(off)
off += 4
map.set(cp, buf.subarray(off, off + GLYPH_BYTES))
off += GLYPH_BYTES
}
return map
}
export type AnsiToPngOptions = {
/** Integer zoom factor (nearest-neighbor). Default 1 — the font is already rasterized at output resolution. */
scale?: number
/** Horizontal padding in 1× pixels. Default 48. */
paddingX?: number
/** Vertical padding in 1× pixels. Default 48. */
paddingY?: number
/** Corner radius in 1× pixels. Default 16. */
borderRadius?: number
/** Background color. Default: dark gray (same as ansiToSvg). */
background?: AnsiColor
}
/**
* Render ANSI-escaped text directly to a PNG buffer.
* Returns a Buffer containing a valid PNG (RGBA, 8-bit).
*/
export function ansiToPng(
ansiText: string,
options: AnsiToPngOptions = {},
): Buffer {
const {
scale = 1,
paddingX = 48,
paddingY = 48,
borderRadius = 16,
background = DEFAULT_BG,
} = options
const lines = parseAnsi(ansiText)
// Trim trailing blank lines (same behavior as ansiToSvg).
while (
lines.length > 0 &&
lines[lines.length - 1]!.every(span => span.text.trim() === '')
) {
lines.pop()
}
if (lines.length === 0) {
lines.push([{ text: '', color: background, bold: false }])
}
const cols = Math.max(1, ...lines.map(lineWidthCells))
const rows = lines.length
const width = (cols * GLYPH_W + paddingX * 2) * scale
const height = (rows * GLYPH_H + paddingY * 2) * scale
// RGBA buffer, pre-filled with the background color.
const px = new Uint8Array(width * height * 4)
fillBackground(px, background)
if (borderRadius > 0) {
roundCorners(px, width, height, borderRadius * scale)
}
// Blit glyphs.
const padX = paddingX * scale
const padY = paddingY * scale
for (let row = 0; row < rows; row++) {
let col = 0
for (const span of lines[row]!) {
for (const ch of span.text) {
const cp = ch.codePointAt(0)!
const cellW = stringWidth(ch)
if (cellW === 0) continue // zero-width (combining marks, etc.)
const x = padX + col * GLYPH_W * scale
const y = padY + row * GLYPH_H * scale
const shade = SHADE_ALPHA[cp]
if (shade !== undefined) {
blitShade(px, width, x, y, span.color, background, shade, scale)
} else {
const glyph = FONT.get(cp) ?? FALLBACK_GLYPH
blitGlyph(px, width, x, y, glyph, span.color, span.bold, scale)
}
col += cellW
}
}
}
return encodePng(px, width, height)
}
/** Terminal column width of a parsed line. */
function lineWidthCells(line: ParsedLine): number {
let w = 0
for (const span of line) w += stringWidth(span.text)
return w
}
function fillBackground(px: Uint8Array, bg: AnsiColor): void {
for (let i = 0; i < px.length; i += 4) {
px[i] = bg.r
px[i + 1] = bg.g
px[i + 2] = bg.b
px[i + 3] = 255
}
}
// Modern terminals render shade chars (░▒▓█) as solid blocks with opacity,
// not the classic VGA dither pattern. Alpha-blend toward background for the
// same look.
const SHADE_ALPHA: Record<number, number> = {
0x2591: 0.25, // ░
0x2592: 0.5, // ▒
0x2593: 0.75, // ▓
0x2588: 1.0, // █
}
function blitShade(
px: Uint8Array,
width: number,
x: number,
y: number,
fg: AnsiColor,
bg: AnsiColor,
alpha: number,
scale: number,
): void {
const r = Math.round(fg.r * alpha + bg.r * (1 - alpha))
const g = Math.round(fg.g * alpha + bg.g * (1 - alpha))
const b = Math.round(fg.b * alpha + bg.b * (1 - alpha))
const cellW = GLYPH_W * scale
const cellH = GLYPH_H * scale
for (let dy = 0; dy < cellH; dy++) {
const rowBase = ((y + dy) * width + x) * 4
for (let dx = 0; dx < cellW; dx++) {
const i = rowBase + dx * 4
px[i] = r
px[i + 1] = g
px[i + 2] = b
}
}
}
/**
* Blit one glyph into the RGBA buffer at (x,y), scaled by `scale`
* (nearest-neighbor). Alpha-composites over the existing background. Bold is
* synthesized by boosting alpha toward opaque a cheap approximation that
* reads as heavier weight without needing a second font.
*/
function blitGlyph(
px: Uint8Array,
width: number,
x: number,
y: number,
glyph: Uint8Array,
color: AnsiColor,
bold: boolean,
scale: number,
): void {
for (let gy = 0; gy < GLYPH_H; gy++) {
for (let gx = 0; gx < GLYPH_W; gx++) {
let a = glyph[gy * GLYPH_W + gx]!
if (a === 0) continue
if (bold) a = Math.min(255, a * 1.4)
const inv = 255 - a
for (let sy = 0; sy < scale; sy++) {
const rowBase = ((y + gy * scale + sy) * width + x + gx * scale) * 4
for (let sx = 0; sx < scale; sx++) {
const i = rowBase + sx * 4
px[i] = (color.r * a + px[i]! * inv) >> 8
px[i + 1] = (color.g * a + px[i + 1]! * inv) >> 8
px[i + 2] = (color.b * a + px[i + 2]! * inv) >> 8
}
}
}
}
}
/**
* Zero out the alpha channel in the four corner regions outside a
* quarter-circle of radius `r`. Produces rounded-rect corners.
*/
function roundCorners(
px: Uint8Array,
width: number,
height: number,
r: number,
): void {
const r2 = r * r
for (let dy = 0; dy < r; dy++) {
for (let dx = 0; dx < r; dx++) {
const ox = r - dx - 0.5
const oy = r - dy - 0.5
if (ox * ox + oy * oy <= r2) continue
// Top-left, top-right, bottom-left, bottom-right.
px[(dy * width + dx) * 4 + 3] = 0
px[(dy * width + (width - 1 - dx)) * 4 + 3] = 0
px[((height - 1 - dy) * width + dx) * 4 + 3] = 0
px[((height - 1 - dy) * width + (width - 1 - dx)) * 4 + 3] = 0
}
}
}
// --- PNG encoding -----------------------------------------------------------
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const CRC_TABLE = makeCrcTable()
function makeCrcTable(): Uint32Array {
const t = 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
}
t[n] = c >>> 0
}
return t
}
function crc32(data: Uint8Array): number {
let c = 0xffffffff
for (let i = 0; i < data.length; i++) {
c = CRC_TABLE[(c ^ data[i]!) & 0xff]! ^ (c >>> 8)
}
return (c ^ 0xffffffff) >>> 0
}
function chunk(type: string, data: Uint8Array): Buffer {
const body = Buffer.alloc(4 + data.length)
body.write(type, 0, 'ascii')
body.set(data, 4)
const out = Buffer.alloc(12 + data.length)
out.writeUInt32BE(data.length, 0)
body.copy(out, 4)
out.writeUInt32BE(crc32(body), 8 + data.length)
return out
}
/**
* Encode an RGBA pixel buffer as PNG. Minimal encoder: 8-bit depth,
* color type 6 (RGBA), filter 0 (none) on every scanline, single IDAT.
*/
function encodePng(px: Uint8Array, width: number, height: number): Buffer {
// IHDR
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type: RGBA
ihdr[10] = 0 // compression: deflate
ihdr[11] = 0 // filter method
ihdr[12] = 0 // interlace: none
// IDAT: each scanline prefixed with filter byte 0.
const stride = width * 4
const raw = Buffer.alloc(height * (stride + 1))
for (let y = 0; y < height; y++) {
const dst = y * (stride + 1)
raw[dst] = 0
raw.set(px.subarray(y * stride, (y + 1) * stride), dst + 1)
}
const idat = deflateSync(raw)
return Buffer.concat([
PNG_SIG,
chunk('IHDR', ihdr),
chunk('IDAT', idat),
chunk('IEND', new Uint8Array(0)),
])
}