Draws each PNG or JPG the model saves or reads under its tool row: pixels in kitty and Ghostty, quadrant block cells in every other terminal.

The model takes a screenshot or reads a picture, draws a conclusion from it, and you see only a file path. To check what it looked at you open the file yourself. This mod draws each PNG or JPG the model saves or reads under its tool row, so you see the screenshot the model looked at without opening the file.
browser_take_screenshot: the file its result links to, a relative link read against the directory the session started in, where the Playwright server writes it, also after a Bash cd;Read of a .png, .jpg or .jpeg file;sips.$TMPDIR/shot-inline/<hash>.png with sips -s format png, because the terminal draws PNG only. The hash covers the path and the modification time.TERM, TERM_PROGRAM and KITTY_WINDOW_ID) draws the pixels themselves. Every other terminal draws the same box as block cells: sips writes a BMP of exactly the pixels the cells hold, and the mod reads its rows. By default a cell is a quadrant character (▘, ▞, ▐, ▙ and the rest) over two by two pixels: the cell's pixels are split at the middle of the colour channel that spreads widest, the brighter side is drawn in its mean colour and the darker side is the background. That is twice the pixels of a half block across, at the cost of two colours per four pixels. /shot-inline glyphs half goes back to half blocks (▀), two pixels a cell, each in its own colour. The BMP is made once per picture and pixel size. The cells of each picture are kept for the newest box it was drawn in, so a resize replaces them, and they go with the picture once the newest 200 pictures push it out.iTerm2 has an inline image protocol of its own, and the engine does not use it, so iTerm2 takes the block-cell path as well. The protocol is chosen inside the engine's Image element, so no mod can change it. Only the terminal surface draws a picture.
The finer sextant (2 by 3) and octant (2 by 4) characters cannot be used: they lie beyond the Basic Multilingual Plane, and Raster refuses them (measured on 2.1.283: cell 42 holds code point 118089, beyond the Basic Multilingual Plane; the engine drew its own).
In the live check a Read of a PNG and of a JPG each drew under its row, the JPG through a sips copy, with no tree refused in the debug log. In tmux, which has no kitty protocol, the same Read drew 24 rows of half-block cells in 23 foreground and 18 background colours. On 2.1.283 a 320 by 200 test picture drew as 40 by 13 quadrant cells in tmux, with no tree refused.
/shot-inline on or off, the pictures of this session, and the cells in use /shot-inline on | off on by default /shot-inline glyphs half | quadrant the cells of a terminal without the kitty protocol; quadrant by default
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install shot-inline@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
sips is part of macOS, and both the JPG copy and the block cells need it. Elsewhere a PNG up to 4 MiB still draws in kitty and Ghostty, and every other path logs a picture was not drawn: ... once.Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.tsx hooks: session.start, command.run{command=shot-inline}, tool.call{tool=Read}, tool.call{tool=Bash}, tool.call{tool=/"^mcp__(plugin_playwright_)?playwright__browser_take_screenshot$"/}, ui.render{component=ToolUse} ❯ ./register.tsx calls: $.command.register, $.env.get, $.fs.exists (via bmpCopy, pngCopy, prepare), $.fs.read (via gridFor, measure), $.fs.stat (via prepare), $.process.run (via sips, tempDir), $.session.cwd, $.store.get (via readSettings), $.store.set (via runCommand, setGlyphs), $.ui.invalidate (via readSettings, remember, runCommand, setGlyphs), $.ui.log (via report), $.ui.resolve ❯ ./register.tsx env writes: nothing ❯ ./register.tsx env reads: KITTY_WINDOW_ID, TERM, TERM_PROGRAM, TMPDIR
Reach L2, runs processes and writes files.
~ is not expanded.$TMPDIR/shot-inline are not deleted by the mod; the system clears the temp directory.sips, so it is macOS only. The kitty path needs no process for a PNG.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.tsx 245 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { absolute, allBytes, blockCells, bmpName, cells, commandImagePaths, copyName, GLYPHS, hasGraphics, isGlyphs, isImagePath, isPng, pngSize, readBmp, screenshotPath, sipsSize, type Glyphs, type Size } from './shot.ts'
3
4const ENABLED_KEY = 'enabled'
5const GLYPHS_KEY = 'glyphs'
6
7/** Quadrants hold twice the pixels of a half block across, in two colours a cell. */
8const DEFAULT_GLYPHS: Glyphs = 'quadrant'
9
10const USAGE = 'expects nothing (the status), on, off, or glyphs half | quadrant'
11
12/** The Playwright screenshot tool, as a plugin install and as a plain MCP server name it. */
13const SCREENSHOT_TOOL = /^mcp__(plugin_playwright_)?playwright__browser_take_screenshot$/
14
15/** `$.fs.read` refuses a larger file, so a larger PNG is measured with sips. */
16const MAX_READ_BYTES = 4 * 1024 * 1024
17
18/** The newest pictures kept; an older row draws without its picture. */
19const MAX_SHOTS = 200
20
21/** A picture ready to draw: the PNG the terminal reads, its pixel size, and the path the model named. */
22type Shot = { png: string; size: Size; source: string; stamp: number }
23
24/** The box of cells a picture is drawn in. */
25type Box = { columns: number; rows: number }
26
27type State = {
28 shots: Map<string, Shot>
29 enabled: boolean
30 /**
31 * The block cells of each picture at the newest box it was drawn in, keyed by the tool row: one
32 * grid per picture, dropped with its picture, so a resize replaces a grid instead of adding one.
33 */
34 grids: Map<string, { box: string; grid: string }>
35 /** The directory the session started in, read before a Bash `cd` can move it. */
36 root: string
37 /** The terminal takes the kitty graphics protocol, so the picture itself is drawn. */
38 graphics: boolean
39 /** The block characters of a terminal without it. */
40 glyphs: Glyphs
41 lastError?: string
42}
43
44function errorText(err: unknown): string {
45 return err instanceof Error ? err.message : String(err)
46}
47
48/** Logs an error once until a different one comes. */
49function report($: EngineInterface, state: State, err: unknown): void {
50 const text = errorText(err)
51 if (text !== state.lastError) $.ui.log(`a picture was not drawn: ${text}`)
52 state.lastError = text
53}
54
55async function sips($: EngineInterface, argv: string[]): Promise<string> {
56 const r = await $.process.run(['sips', ...argv], { timeoutMs: 20_000 })
57 if (r.exitCode !== 0) throw new Error(`sips failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}`)
58 return r.stdout
59}
60
61async function measure($: EngineInterface, path: string, bytes: number): Promise<Size> {
62 const size = isPng(path) && bytes <= MAX_READ_BYTES
63 ? pngSize((await $.fs.read(path, { as: 'bytes' })).base64)
64 : sipsSize(await sips($, ['-g', 'pixelWidth', '-g', 'pixelHeight', path]))
65 if (size === undefined) throw new Error(`${path} has no readable picture size`)
66 return size
67}
68
69/** A PNG copy of a JPG under the temp directory, made once per path and modification time. */
70async function pngCopy($: EngineInterface, path: string, mtimeMs: number): Promise<string> {
71 const out = `${await tempDir($)}/${copyName(path, mtimeMs)}`
72 if (await $.fs.exists(out)) return out
73 await sips($, ['-s', 'format', 'png', path, '--out', out])
74 return out
75}
76
77/** The picture for an image file, or undefined when the path is not a file. */
78async function prepare($: EngineInterface, path: string): Promise<Shot | undefined> {
79 if (!(await $.fs.exists(path))) return undefined
80 const st = await $.fs.stat(path)
81 if (st.kind !== 'file') return undefined
82 const size = await measure($, path, st.size ?? 0)
83 const png = isPng(path) ? path : await pngCopy($, path, st.mtimeMs)
84 return { png, size, source: path, stamp: st.mtimeMs }
85}
86
87/** The temp directory the mod writes its copies into. */
88async function tempDir($: EngineInterface): Promise<string> {
89 const dir = `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/shot-inline`
90 const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5_000 })
91 if (made.exitCode !== 0) throw new Error(`mkdir ${dir} failed: ${made.stderr.trim()}`)
92 return dir
93}
94
95/** A BMP of the picture with exactly the pixels the box's cells hold, made once per picture and size. */
96async function bmpCopy($: EngineInterface, shot: Shot, box: Box, glyphs: Glyphs): Promise<string> {
97 const width = box.columns * GLYPHS[glyphs].across
98 const height = box.rows * GLYPHS[glyphs].down
99 const out = `${await tempDir($)}/${bmpName(shot.png, shot.stamp, width, height)}`
100 if (await $.fs.exists(out)) return out
101 await sips($, ['-z', String(height), String(width), '-s', 'format', 'bmp', shot.png, '--out', out])
102 return out
103}
104
105/** The block cells for one picture at one box, kept while the picture is and until the box or the glyphs change. */
106async function gridFor($: EngineInterface, state: State, id: string, shot: Shot, box: Box, key: string): Promise<string | undefined> {
107 const kept = state.grids.get(id)
108 if (kept?.box === key) return kept.grid
109 try {
110 const bmp = readBmp(allBytes((await $.fs.read(await bmpCopy($, shot, box, state.glyphs), { as: 'bytes' })).base64))
111 if (bmp === undefined) throw new Error(`${shot.source}: sips wrote a BMP this reader does not take`)
112 const grid = blockCells(bmp, box.columns, box.rows, state.glyphs)
113 state.grids.set(id, { box: key, grid })
114 return grid
115 } catch (err) {
116 // A redraw drops the dispatch under it, so the work of the old one is not a failure.
117 if (!/\baborted\b/.test(errorText(err))) report($, state, err)
118 return undefined
119 }
120}
121
122/**
123 * Keeps the picture for a tool row and redraws the rows. A relative path is read against `base`: the
124 * directory the tool itself ran in.
125 */
126async function remember($: EngineInterface, state: State, id: string, paths: readonly string[], base: string): Promise<void> {
127 try {
128 for (const path of paths) {
129 const shot = await prepare($, absolute(path, base))
130 if (shot === undefined) continue
131 state.shots.set(id, shot)
132 if (state.shots.size > MAX_SHOTS) {
133 const oldest = state.shots.keys().next().value ?? ''
134 state.shots.delete(oldest)
135 state.grids.delete(oldest)
136 }
137 $.ui.invalidate('ui.render')
138 return
139 }
140 } catch (err) {
141 report($, state, err)
142 }
143}
144
145const answered = (r: ToolCallResult): boolean => r.deny === undefined && r.isError !== true
146
147/** Stores the block characters and redraws; every grid is made again for the new family. */
148async function setGlyphs($: EngineInterface, state: State, word: string): Promise<string> {
149 if (!isGlyphs(word)) return `glyphs expects half or quadrant; now ${state.glyphs}`
150 await $.store.set(GLYPHS_KEY, word)
151 state.glyphs = word
152 $.ui.invalidate('ui.render')
153 const { across, down } = GLYPHS[word]
154 return `glyphs ${word}: ${across}x${down} pixels a cell on a terminal without the kitty graphics protocol`
155}
156
157/**
158 * Reads the on/off setting and the block characters from the store, which every window shares, so a
159 * change made in another window applies here at the next hook that acts on it. A row is drawn with what
160 * its own tool call read, because `ui.render` runs on every redraw and never reads the store; a changed
161 * setting redraws the rows, as `on`, `off` and `glyphs` do. Answers whether the mod is on.
162 */
163async function readSettings($: EngineInterface, state: State): Promise<boolean> {
164 const was = `${state.enabled}:${state.glyphs}`
165 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
166 const glyphs = await $.store.get(GLYPHS_KEY)
167 state.glyphs = isGlyphs(glyphs) ? glyphs : DEFAULT_GLYPHS
168 if (`${state.enabled}:${state.glyphs}` !== was) $.ui.invalidate('ui.render')
169 return state.enabled
170}
171
172async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
173 const word = args.trim()
174 await readSettings($, state)
175 if (word === 'on' || word === 'off') {
176 await $.store.set(ENABLED_KEY, word === 'on')
177 state.enabled = word === 'on'
178 $.ui.invalidate('ui.render')
179 return word === 'on' ? 'on: saved and read pictures draw under their tool row' : 'off: no picture is drawn'
180 }
181 if (word.startsWith('glyphs')) return setGlyphs($, state, word.slice('glyphs'.length).trim())
182 const how = state.graphics ? 'this terminal draws the picture itself' : `this terminal has no kitty graphics protocol, so a picture is drawn as ${state.glyphs} block cells`
183 return word === '' ? `${state.enabled ? 'on' : 'off'}; ${state.shots.size} picture(s) this session; ${how}` : USAGE
184}
185
186export const register: Register = on => {
187 const state: State = { shots: new Map(), enabled: true, grids: new Map(), root: '', graphics: false, glyphs: DEFAULT_GLYPHS }
188
189 on('session.start', async ($, e, next) => {
190 const r = await next(e)
191 await $.command.register({ name: 'shot-inline', description: 'Pictures under their tool row: status, on, off, glyphs (shot-inline)', argumentHint: '[on | off | glyphs half|quadrant]' })
192 await readSettings($, state)
193 state.root = await $.session.cwd()
194 state.graphics = hasGraphics((await $.env.get('TERM')) ?? '', (await $.env.get('TERM_PROGRAM')) ?? '', (await $.env.get('KITTY_WINDOW_ID')) ?? '')
195 return r
196 })
197
198 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
199 on('command.run', { command: 'shot-inline' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
200
201 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
202 const r = await next(e)
203 if (!answered(r) || !isImagePath(e.file_path) || !(await readSettings($, state))) return r
204 await remember($, state, e.tool_use_id, [e.file_path], await $.session.cwd())
205 return r
206 })
207
208 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
209 const r = await next(e)
210 const paths = answered(r) ? commandImagePaths(e.command) : []
211 if (paths.length === 0 || !(await readSettings($, state))) return r
212 // A Bash command runs where its shell stands, which a `cd` moves, so its paths read against that.
213 await remember($, state, e.tool_use_id, paths, await $.session.cwd())
214 return r
215 })
216
217 on('tool.call', { tool: SCREENSHOT_TOOL }, async ($, e, next) => {
218 const r = await next(e)
219 const path = answered(r) ? screenshotPath(r.text ?? '') : undefined
220 if (path === undefined || !(await readSettings($, state))) return r
221 // The Playwright server writes a relative path under the directory it started in, the session's own,
222 // which a Bash `cd` does not move.
223 await remember($, state, e.tool_use_id, [path], state.root)
224 return r
225 })
226
227 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
228 const shot = state.enabled && e.surface === 'terminal' ? state.shots.get(e.requestId) : undefined
229 if (shot === undefined || e.surface !== 'terminal') return next(e)
230 const drawn = await next(e)
231 const { Box, Image, Raster } = $.ui.resolve(e)
232 const box = cells(shot.size, Math.min(80, Math.max(10, (e.viewport?.columns ?? 84) - 4)))
233 const key = `${e.requestId}:${box.columns}x${box.rows}:${state.glyphs}`
234 const grid = state.graphics ? undefined : await gridFor($, state, e.requestId, shot, box, key)
235 return (
236 <Box flexDirection="column">
237 {drawn}
238 {grid === undefined
239 ? <Image source={{ file: shot.png, format: 'png' }} columns={box.columns} rows={box.rows} alt={`picture: ${shot.source}`} />
240 : <Raster key={key} columns={box.columns} rows={box.rows} cells={grid} />}
241 </Box>
242 )
243 })
244}
245hooks/shot.ts 264 lines1/** Which image a tool call saved or read, its pixel size, and the box of cells it is drawn in. */
2
3const IMAGE = /\.(png|jpe?g)$/i
4
5export const isImagePath = (path: string): boolean => IMAGE.test(path)
6
7export const isPng = (path: string): boolean => /\.png$/i.test(path)
8
9/** The file a Playwright screenshot result links to: `[Screenshot of viewport](.playwright-mcp/page.png)`. */
10export function screenshotPath(result: string): string | undefined {
11 return /\]\(([^)\s]+\.(?:png|jpe?g))\)/i.exec(result)?.[1]
12}
13
14/** The image paths a shell command names, the last first, because a command usually writes its output last. */
15export function commandImagePaths(command: string): string[] {
16 const quotedOrBare = /(["'])([^"']+?\.(?:png|jpe?g))\1|(?:^|[\s=])([^\s'"|;&<>()=]+\.(?:png|jpe?g))(?=$|[\s|;&<>)])/gi
17 const paths = [...command.matchAll(quotedOrBare)].map(m => m[2] ?? m[3] ?? '')
18 return [...new Set(paths.reverse())].filter(p => p !== '')
19}
20
21/** `path` as an absolute path; `~` is left as it is, so it fails the existence check instead of guessing a home. */
22export function absolute(path: string, cwd: string): string {
23 return path.startsWith('/') || path.startsWith('~') ? path : `${cwd.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`
24}
25
26const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
27
28/** The first bytes of a base64 string; a module has no Node Buffer. */
29export function headBytes(base64: string, count: number): number[] {
30 const bytes: number[] = []
31 let bits = 0
32 let value = 0
33 for (const ch of base64) {
34 const n = B64.indexOf(ch)
35 if (n < 0 || bytes.length >= count) break
36 value = (value << 6) | n
37 bits += 6
38 if (bits >= 8) {
39 bits -= 8
40 bytes.push((value >> bits) & 0xff)
41 }
42 }
43 return bytes
44}
45
46/** Every byte of a base64 string. */
47export function allBytes(text: string): Uint8Array {
48 const out = new Uint8Array(Math.floor((text.length * 3) / 4))
49 let at = 0
50 let bits = 0
51 let value = 0
52 for (const ch of text) {
53 const n = B64.indexOf(ch)
54 if (n < 0) continue
55 value = ((value << 6) | n) >>> 0
56 bits += 6
57 if (bits < 8) continue
58 bits -= 8
59 out[at++] = (value >> bits) & 0xff
60 }
61 return out.subarray(0, at)
62}
63
64export type Size = { width: number; height: number }
65
66const u32 = (b: number[], at: number): number => (((b[at] ?? 0) << 24) >>> 0) + ((b[at + 1] ?? 0) << 16) + ((b[at + 2] ?? 0) << 8) + (b[at + 3] ?? 0)
67
68/** The size in a PNG's IHDR chunk, or undefined for bytes that are not a PNG. */
69export function pngSize(base64: string): Size | undefined {
70 const b = headBytes(base64, 24)
71 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
72 if (!signature.every((v, i) => b[i] === v) || String.fromCharCode(...b.slice(12, 16)) !== 'IHDR') return undefined
73 const size = { width: u32(b, 16), height: u32(b, 20) }
74 return size.width > 0 && size.height > 0 ? size : undefined
75}
76
77/** The size in `sips -g pixelWidth -g pixelHeight` output. */
78export function sipsSize(stdout: string): Size | undefined {
79 const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1] ?? 0)
80 const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1] ?? 0)
81 return width > 0 && height > 0 ? { width, height } : undefined
82}
83
84/** The tallest picture, in rows, so a screenshot does not fill the screen. */
85export const MAX_ROWS = 24
86
87/** Pixels per cell across; a cell is about twice as tall as it is wide. */
88const PX_PER_COLUMN = 8
89
90/** The box of cells that keeps the picture's shape, at most `maxColumns` wide and MAX_ROWS tall. */
91export function cells(size: Size, maxColumns: number): { columns: number; rows: number } {
92 const fit = Math.max(1, Math.min(maxColumns, Math.round(size.width / PX_PER_COLUMN)))
93 const rows = Math.max(1, Math.round((fit * size.height) / size.width / 2))
94 if (rows <= MAX_ROWS) return { columns: fit, rows }
95 return { columns: Math.max(1, Math.round((MAX_ROWS * 2 * size.width) / size.height)), rows: MAX_ROWS }
96}
97
98/** A name for the PNG copy of a JPG, from its path and modification time, FNV-1a 32. */
99export function copyName(path: string, mtimeMs: number): string {
100 return `${hash(`${path}\0${mtimeMs}`)}.png`
101}
102
103/** A name for the BMP of one picture at one size in pixels. */
104export function bmpName(path: string, mtimeMs: number, width: number, height: number): string {
105 return `${hash(`${path}\0${mtimeMs}\0${width}x${height}`)}.bmp`
106}
107
108function hash(text: string): string {
109 let h = 0x811c9dc5
110 for (const ch of text) h = Math.imul(h ^ (ch.codePointAt(0) ?? 0), 0x01000193) >>> 0
111 return h.toString(16).padStart(8, '0')
112}
113
114/** The terminal draws a picture only with the kitty graphics protocol (kitty, Ghostty). */
115export function hasGraphics(term: string, termProgram: string, kittyWindow: string): boolean {
116 if (kittyWindow !== '') return true
117 const name = `${term} ${termProgram}`.toLowerCase()
118 return name.includes('kitty') || name.includes('ghostty')
119}
120
121/** Upper half block: the cell's top pixel is its foreground, its bottom pixel its background. */
122const UPPER_HALF = 0x2580
123
124/** A BMP as `sips -s format bmp` writes one: 24 or 32 bits a pixel, uncompressed, BGR(A). */
125export type Bitmap = { width: number; height: number; offset: number; topDown: boolean; step: number; stride: number; bytes: Uint8Array }
126
127const u32le = (b: Uint8Array, at: number): number => (((b[at] ?? 0) | ((b[at + 1] ?? 0) << 8) | ((b[at + 2] ?? 0) << 16) | ((b[at + 3] ?? 0) << 24)) >>> 0)
128
129/** The header of an uncompressed BMP, or undefined for bytes this reader does not take. */
130export function readBmp(bytes: Uint8Array): Bitmap | undefined {
131 const bpp = (bytes[28] ?? 0) | ((bytes[29] ?? 0) << 8)
132 if (bytes[0] !== 0x42 || bytes[1] !== 0x4d || (bpp !== 24 && bpp !== 32)) return undefined
133 const signedHeight = u32le(bytes, 22) | 0
134 const width = u32le(bytes, 18)
135 const step = bpp / 8
136 if (width < 1 || signedHeight === 0) return undefined
137 return { width, height: Math.abs(signedHeight), offset: u32le(bytes, 10), topDown: signedHeight < 0, step, stride: Math.ceil((width * step) / 4) * 4, bytes }
138}
139
140/** One pixel as `0x00RRGGBB`; a point outside the picture reads its nearest edge. */
141export function pixel(bmp: Bitmap, x: number, y: number): number {
142 const col = Math.min(Math.max(x, 0), bmp.width - 1)
143 const row = Math.min(Math.max(y, 0), bmp.height - 1)
144 const at = bmp.offset + (bmp.topDown ? row : bmp.height - 1 - row) * bmp.stride + col * bmp.step
145 return ((bmp.bytes[at + 2] ?? 0) << 16) | ((bmp.bytes[at + 1] ?? 0) << 8) | (bmp.bytes[at] ?? 0)
146}
147
148/** The picture as `columns * rows` half-block cells, packed as `RasterProps.cells` takes them. */
149export function halfBlocks(bmp: Bitmap, columns: number, rows: number): string {
150 const words = new Uint32Array(columns * rows * 3)
151 for (let y = 0; y < rows; y++) {
152 for (let x = 0; x < columns; x++) {
153 const at = (y * columns + x) * 3
154 words[at] = UPPER_HALF
155 words[at + 1] = pixel(bmp, x, y * 2)
156 words[at + 2] = pixel(bmp, x, y * 2 + 1)
157 }
158 }
159 return base64(new Uint8Array(words.buffer))
160}
161
162/**
163 * The block characters a cell is drawn with, by how finely they split it. The finer sextant (U+1FB00)
164 * and octant (U+1CD00) families lie beyond the Basic Multilingual Plane, which `Raster` refuses
165 * (measured on 2.1.283: "holds code point 118089, beyond the Basic Multilingual Plane").
166 */
167export type Glyphs = 'half' | 'quadrant'
168
169/** Pixels one cell holds across and down for each family. */
170export const GLYPHS: Readonly<Record<Glyphs, { across: number; down: number }>> = {
171 half: { across: 1, down: 2 },
172 quadrant: { across: 2, down: 2 },
173}
174
175export const isGlyphs = (value: unknown): value is Glyphs => typeof value === 'string' && Object.hasOwn(GLYPHS, value)
176
177/** Quadrant characters by mask: bit 0 upper left, 1 upper right, 2 lower left, 3 lower right. */
178const QUADRANTS = [
179 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
180]
181
182/** The quadrant character for a mask of lit pixels. */
183export function quadrant(mask: number): number {
184 return QUADRANTS[mask] ?? 0x20
185}
186
187const channel = (color: number, k: number): number => (color >> (16 - k * 8)) & 0xff
188
189/** The channel (0 red, 1 green, 2 blue) whose values spread widest, with its lowest and highest value. */
190function widest(pixels: readonly number[]): { k: number; low: number; high: number } {
191 let best = { k: 0, low: 0, high: 0 }
192 for (let k = 0; k < 3; k++) {
193 const values = pixels.map(p => channel(p, k))
194 const low = Math.min(...values)
195 const high = Math.max(...values)
196 if (high - low > best.high - best.low) best = { k, low, high }
197 }
198 return best
199}
200
201/** The mean colour of some pixels, `0x00RRGGBB`. */
202function mean(pixels: readonly number[]): number {
203 if (pixels.length === 0) return 0
204 const sum = [0, 1, 2].map(k => pixels.reduce((s, p) => s + channel(p, k), 0) / pixels.length)
205 return (Math.round(sum[0] ?? 0) << 16) | (Math.round(sum[1] ?? 0) << 8) | Math.round(sum[2] ?? 0)
206}
207
208/**
209 * One cell's pixels as two colours: split at the middle of the channel that spreads widest, the higher
210 * side lit in the glyph's colour, the lower the background. A cell of one colour lights nothing.
211 */
212export function splitCell(pixels: readonly number[]): { mask: number; fg: number; bg: number } {
213 const { k, low, high } = widest(pixels)
214 const middle = (low + high) / 2
215 let mask = 0
216 pixels.forEach((p, i) => {
217 if (high > low && channel(p, k) > middle) mask |= 1 << i
218 })
219 const lit = pixels.filter((_, i) => (mask >> i) & 1)
220 const dark = pixels.filter((_, i) => !((mask >> i) & 1))
221 return { mask, fg: lit.length === 0 ? mean(dark) : mean(lit), bg: mean(dark) }
222}
223
224/** The pixels of one cell in reading order. */
225function cellPixels(bmp: Bitmap, x: number, y: number, across: number, down: number): number[] {
226 const out: number[] = []
227 for (let dy = 0; dy < down; dy++) {
228 for (let dx = 0; dx < across; dx++) out.push(pixel(bmp, x * across + dx, y * down + dy))
229 }
230 return out
231}
232
233/**
234 * The picture as `columns * rows` cells of one glyph family, packed as `RasterProps.cells` takes them;
235 * the BMP holds `across` by `down` pixels a cell. A half block keeps each pixel's own colour; a quadrant
236 * cell's four pixels share two.
237 */
238export function blockCells(bmp: Bitmap, columns: number, rows: number, glyphs: Glyphs): string {
239 if (glyphs === 'half') return halfBlocks(bmp, columns, rows)
240 const { across, down } = GLYPHS[glyphs]
241 const words = new Uint32Array(columns * rows * 3)
242 for (let y = 0; y < rows; y++) {
243 for (let x = 0; x < columns; x++) {
244 const cell = splitCell(cellPixels(bmp, x, y, across, down))
245 words.set([quadrant(cell.mask), cell.fg, cell.bg], (y * columns + x) * 3)
246 }
247 }
248 return base64(new Uint8Array(words.buffer))
249}
250
251/** Standard padded base64 of the bytes; a module has no Node Buffer. */
252export function base64(bytes: Uint8Array): string {
253 let out = ''
254 for (let i = 0; i < bytes.length; i += 3) {
255 const a = bytes[i] ?? 0
256 const b = bytes[i + 1]
257 const c = bytes[i + 2]
258 const word = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
259 const digit = (shift: number): string => B64[(word >> shift) & 63] ?? ''
260 out += digit(18) + digit(12) + (b === undefined ? '=' : digit(6)) + (c === undefined ? '=' : digit(0))
261 }
262 return out
263}
264