335 lines
210 KiB
TypeScript
335 lines
210 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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 ~5–15ms, 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)),
|
|||
|
|
])
|
|||
|
|
}
|