terminal-png 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # terminal-png
2
+
3
+ Render PNG images from ANSI terminal output.
4
+
5
+ ## API
6
+
7
+ ```ts
8
+ import { renderTerminalPng } from "terminal-png";
9
+
10
+ await renderTerminalPng(ansiText, {
11
+ output: "example.png",
12
+ window: true,
13
+ padding: 20
14
+ });
15
+ ```
16
+
17
+ Options:
18
+
19
+ - `output`: path to the output PNG file
20
+ - `window`: include terminal window chrome
21
+ - `padding`: padding around the rendered terminal content
22
+
23
+ ## CLI
24
+
25
+ ```sh
26
+ terminal-png <input.ansi> -o <output.png> [--window] [--padding 20]
27
+ ```
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../dist/cli.js";
3
+
4
+ const exitCode = await main();
5
+ process.exitCode = exitCode;
@@ -0,0 +1,23 @@
1
+ export type Color = {
2
+ type: "ansi4";
3
+ index: number;
4
+ } | {
5
+ type: "ansi8";
6
+ index: number;
7
+ } | {
8
+ type: "rgb";
9
+ r: number;
10
+ g: number;
11
+ b: number;
12
+ };
13
+ export type StyledRun = {
14
+ text: string;
15
+ fg: Color | null;
16
+ bg: Color | null;
17
+ bold: boolean;
18
+ italic: boolean;
19
+ underline: boolean;
20
+ strikethrough: boolean;
21
+ dim: boolean;
22
+ };
23
+ export declare function parseAnsi(input: string): StyledRun[];
@@ -0,0 +1,247 @@
1
+ const ESC = "\u001b";
2
+ function createDefaultStyle() {
3
+ return {
4
+ fg: null,
5
+ bg: null,
6
+ bold: false,
7
+ italic: false,
8
+ underline: false,
9
+ strikethrough: false,
10
+ dim: false
11
+ };
12
+ }
13
+ function cloneStyle(style) {
14
+ return { ...style };
15
+ }
16
+ function stylesEqual(left, right) {
17
+ return (left.bold === right.bold &&
18
+ left.italic === right.italic &&
19
+ left.underline === right.underline &&
20
+ left.strikethrough === right.strikethrough &&
21
+ left.dim === right.dim &&
22
+ colorsEqual(left.fg, right.fg) &&
23
+ colorsEqual(left.bg, right.bg));
24
+ }
25
+ function colorsEqual(left, right) {
26
+ if (left === right) {
27
+ return true;
28
+ }
29
+ if (!left || !right || left.type !== right.type) {
30
+ return false;
31
+ }
32
+ if (left.type === "rgb" && right.type === "rgb") {
33
+ return left.r === right.r && left.g === right.g && left.b === right.b;
34
+ }
35
+ if ((left.type === "ansi4" || left.type === "ansi8") && left.type === right.type) {
36
+ return left.index === right.index;
37
+ }
38
+ return false;
39
+ }
40
+ function pushRun(runs, style, text) {
41
+ if (text.length === 0) {
42
+ return;
43
+ }
44
+ const previous = runs.at(-1);
45
+ if (previous && text !== "\n" && previous.text !== "\n" && stylesEqual(previous, style)) {
46
+ previous.text += text;
47
+ return;
48
+ }
49
+ runs.push({
50
+ text,
51
+ fg: style.fg,
52
+ bg: style.bg,
53
+ bold: style.bold,
54
+ italic: style.italic,
55
+ underline: style.underline,
56
+ strikethrough: style.strikethrough,
57
+ dim: style.dim
58
+ });
59
+ }
60
+ function parseCsi(input, start) {
61
+ let index = start + 2;
62
+ while (index < input.length) {
63
+ const code = input.charCodeAt(index);
64
+ if (code >= 0x40 && code <= 0x7e) {
65
+ return {
66
+ end: index + 1,
67
+ final: input[index],
68
+ params: input.slice(start + 2, index)
69
+ };
70
+ }
71
+ index += 1;
72
+ }
73
+ return {
74
+ end: input.length,
75
+ final: null,
76
+ params: input.slice(start + 2)
77
+ };
78
+ }
79
+ function toInteger(value) {
80
+ if (!value || value.length === 0) {
81
+ return null;
82
+ }
83
+ for (const char of value) {
84
+ if (char < "0" || char > "9") {
85
+ return null;
86
+ }
87
+ }
88
+ return Number.parseInt(value, 10);
89
+ }
90
+ function clampByte(value) {
91
+ if (value < 0) {
92
+ return 0;
93
+ }
94
+ if (value > 255) {
95
+ return 255;
96
+ }
97
+ return value;
98
+ }
99
+ function applyExtendedColor(params, start) {
100
+ const mode = params[start];
101
+ if (mode === 5) {
102
+ const index = params[start + 1];
103
+ if (index === undefined) {
104
+ return null;
105
+ }
106
+ return {
107
+ color: { type: "ansi8", index: clampByte(index) },
108
+ consumed: 2
109
+ };
110
+ }
111
+ if (mode === 2) {
112
+ const r = params[start + 1];
113
+ const g = params[start + 2];
114
+ const b = params[start + 3];
115
+ if (r === undefined || g === undefined || b === undefined) {
116
+ return null;
117
+ }
118
+ return {
119
+ color: { type: "rgb", r: clampByte(r), g: clampByte(g), b: clampByte(b) },
120
+ consumed: 4
121
+ };
122
+ }
123
+ return null;
124
+ }
125
+ function applySgr(style, paramsText) {
126
+ const nextStyle = cloneStyle(style);
127
+ const rawParams = paramsText.length === 0 ? ["0"] : paramsText.split(";");
128
+ const params = [];
129
+ for (const rawParam of rawParams) {
130
+ const value = toInteger(rawParam);
131
+ if (value === null) {
132
+ return nextStyle;
133
+ }
134
+ params.push(value);
135
+ }
136
+ for (let index = 0; index < params.length; index += 1) {
137
+ const value = params[index];
138
+ if (value === 0) {
139
+ Object.assign(nextStyle, createDefaultStyle());
140
+ continue;
141
+ }
142
+ if (value === 1) {
143
+ nextStyle.bold = true;
144
+ continue;
145
+ }
146
+ if (value === 2) {
147
+ nextStyle.dim = true;
148
+ continue;
149
+ }
150
+ if (value === 22) {
151
+ nextStyle.bold = false;
152
+ nextStyle.dim = false;
153
+ continue;
154
+ }
155
+ if (value === 3) {
156
+ nextStyle.italic = true;
157
+ continue;
158
+ }
159
+ if (value === 23) {
160
+ nextStyle.italic = false;
161
+ continue;
162
+ }
163
+ if (value === 4) {
164
+ nextStyle.underline = true;
165
+ continue;
166
+ }
167
+ if (value === 24) {
168
+ nextStyle.underline = false;
169
+ continue;
170
+ }
171
+ if (value === 9) {
172
+ nextStyle.strikethrough = true;
173
+ continue;
174
+ }
175
+ if (value === 29) {
176
+ nextStyle.strikethrough = false;
177
+ continue;
178
+ }
179
+ if (value === 39) {
180
+ nextStyle.fg = null;
181
+ continue;
182
+ }
183
+ if (value === 49) {
184
+ nextStyle.bg = null;
185
+ continue;
186
+ }
187
+ if (value >= 30 && value <= 37) {
188
+ nextStyle.fg = { type: "ansi4", index: value - 30 };
189
+ continue;
190
+ }
191
+ if (value >= 90 && value <= 97) {
192
+ nextStyle.fg = { type: "ansi4", index: value - 90 + 8 };
193
+ continue;
194
+ }
195
+ if (value >= 40 && value <= 47) {
196
+ nextStyle.bg = { type: "ansi4", index: value - 40 };
197
+ continue;
198
+ }
199
+ if (value >= 100 && value <= 107) {
200
+ nextStyle.bg = { type: "ansi4", index: value - 100 + 8 };
201
+ continue;
202
+ }
203
+ if (value === 38 || value === 48) {
204
+ const extended = applyExtendedColor(params, index + 1);
205
+ if (!extended) {
206
+ continue;
207
+ }
208
+ if (value === 38) {
209
+ nextStyle.fg = extended.color;
210
+ }
211
+ else {
212
+ nextStyle.bg = extended.color;
213
+ }
214
+ index += extended.consumed;
215
+ }
216
+ }
217
+ return nextStyle;
218
+ }
219
+ export function parseAnsi(input) {
220
+ const runs = [];
221
+ let style = createDefaultStyle();
222
+ let textStart = 0;
223
+ let index = 0;
224
+ while (index < input.length) {
225
+ const char = input[index];
226
+ if (char === "\n") {
227
+ pushRun(runs, style, input.slice(textStart, index));
228
+ pushRun(runs, style, "\n");
229
+ index += 1;
230
+ textStart = index;
231
+ continue;
232
+ }
233
+ if (char === ESC && input[index + 1] === "[") {
234
+ pushRun(runs, style, input.slice(textStart, index));
235
+ const sequence = parseCsi(input, index);
236
+ if (sequence.final === "m") {
237
+ style = applySgr(style, sequence.params);
238
+ }
239
+ index = sequence.end;
240
+ textStart = index;
241
+ continue;
242
+ }
243
+ index += 1;
244
+ }
245
+ pushRun(runs, style, input.slice(textStart));
246
+ return runs;
247
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ interface CliWriter {
2
+ write(chunk: string | Uint8Array): boolean;
3
+ }
4
+ interface CliOutput {
5
+ stderr: CliWriter;
6
+ }
7
+ export declare function main(args?: string[], output?: CliOutput): Promise<number>;
8
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { Command, CommanderError, InvalidArgumentError } from "commander";
5
+ import { renderTerminalPng } from "./index.js";
6
+ const defaultOutput = {
7
+ stderr: process.stderr
8
+ };
9
+ function parsePadding(value) {
10
+ const padding = Number(value);
11
+ if (!Number.isInteger(padding) || padding < 0) {
12
+ throw new InvalidArgumentError("padding must be a non-negative integer");
13
+ }
14
+ return padding;
15
+ }
16
+ export async function main(args = process.argv.slice(2), output = defaultOutput) {
17
+ const program = new Command();
18
+ program
19
+ .name("terminal-png")
20
+ .description("Render a PNG image from ANSI terminal output")
21
+ .argument("<input>", "Path to the ANSI input file")
22
+ .requiredOption("-o, --output <output>", "Path to the output PNG file")
23
+ .option("--window", "Include terminal window chrome", true)
24
+ .option("--no-window", "Exclude terminal window chrome")
25
+ .option("--padding <n>", "Padding around terminal content", parsePadding)
26
+ .action(async (input, options) => {
27
+ const ansiText = await readFile(input, "utf8");
28
+ await renderTerminalPng(ansiText, {
29
+ output: options.output,
30
+ padding: options.padding,
31
+ window: options.window
32
+ });
33
+ });
34
+ program.configureOutput({
35
+ writeErr: value => {
36
+ output.stderr.write(value);
37
+ }
38
+ });
39
+ program.exitOverride();
40
+ try {
41
+ await program.parseAsync(args, { from: "user" });
42
+ return 0;
43
+ }
44
+ catch (error) {
45
+ if (error instanceof CommanderError) {
46
+ return error.exitCode;
47
+ }
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ output.stderr.write(`Error: ${message}\n`);
50
+ return 1;
51
+ }
52
+ }
53
+ const entryPoint = process.argv[1]
54
+ ? pathToFileURL(path.resolve(process.argv[1])).href
55
+ : null;
56
+ if (entryPoint === import.meta.url) {
57
+ const exitCode = await main();
58
+ process.exitCode = exitCode;
59
+ }
package/dist/font.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare const JETBRAINS_MONO_BASE64: string;
2
+ export declare const JETBRAINS_MONO_FONT_FILES: readonly [string, string, string, string];
3
+ export declare const JETBRAINS_MONO_TTF_PATH: string;
4
+ export declare const FONT_FACE_CSS: string;
package/dist/font.js ADDED
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const require = createRequire(import.meta.url);
6
+ // Full JetBrains Mono (all Unicode blocks including box-drawing and geometric shapes).
7
+ // The @fontsource subsets only cover Latin and miss characters used by the design system.
8
+ const fontPackageRoot = dirname(require.resolve("jetbrains-mono/package.json"));
9
+ const webfontRoot = join(fontPackageRoot, "fonts/webfonts");
10
+ function readWebfontBase64(filename) {
11
+ return readFileSync(join(webfontRoot, filename)).toString("base64");
12
+ }
13
+ function resolveAssetPath(filename) {
14
+ return fileURLToPath(new URL(`../assets/${filename}`, import.meta.url));
15
+ }
16
+ function createFontFace(base64, weight, style) {
17
+ return `@font-face {
18
+ font-family: 'JetBrains Mono';
19
+ font-style: ${style};
20
+ font-weight: ${weight};
21
+ src: url('data:font/woff2;base64,${base64}') format('woff2');
22
+ }`;
23
+ }
24
+ export const JETBRAINS_MONO_BASE64 = readWebfontBase64("JetBrainsMono-Regular.woff2");
25
+ export const JETBRAINS_MONO_FONT_FILES = [
26
+ resolveAssetPath("jetbrains-mono-400-normal.ttf"),
27
+ resolveAssetPath("jetbrains-mono-700-normal.ttf"),
28
+ resolveAssetPath("jetbrains-mono-400-italic.ttf"),
29
+ resolveAssetPath("jetbrains-mono-700-italic.ttf")
30
+ ];
31
+ export const JETBRAINS_MONO_TTF_PATH = JETBRAINS_MONO_FONT_FILES[0];
32
+ export const FONT_FACE_CSS = [
33
+ createFontFace(JETBRAINS_MONO_BASE64, 400, "normal"),
34
+ createFontFace(readWebfontBase64("JetBrainsMono-Bold.woff2"), 700, "normal"),
35
+ createFontFace(readWebfontBase64("JetBrainsMono-Italic.woff2"), 400, "italic"),
36
+ createFontFace(readWebfontBase64("JetBrainsMono-BoldItalic.woff2"), 700, "italic")
37
+ ].join("\n");
@@ -0,0 +1,10 @@
1
+ export interface TerminalPngOptions {
2
+ padding?: number;
3
+ window?: boolean;
4
+ output?: string;
5
+ }
6
+ export declare function renderTerminalPng(ansiText: string, options?: TerminalPngOptions): Promise<Buffer>;
7
+ export * from "./ansi-parser.js";
8
+ export * from "./font.js";
9
+ export * from "./png-renderer.js";
10
+ export * from "./svg-renderer.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { parseAnsi } from "./ansi-parser.js";
3
+ import { renderPng } from "./png-renderer.js";
4
+ import { renderSvg } from "./svg-renderer.js";
5
+ export async function renderTerminalPng(ansiText, options = {}) {
6
+ const runs = parseAnsi(ansiText);
7
+ const svg = renderSvg(runs, {
8
+ padding: options.padding,
9
+ window: options.window
10
+ });
11
+ const png = renderPng(svg);
12
+ if (options.output) {
13
+ await writeFile(options.output, png);
14
+ }
15
+ return png;
16
+ }
17
+ export * from "./ansi-parser.js";
18
+ export * from "./font.js";
19
+ export * from "./png-renderer.js";
20
+ export * from "./svg-renderer.js";
@@ -0,0 +1 @@
1
+ export declare function renderPng(svg: string): Buffer;
@@ -0,0 +1,18 @@
1
+ import { Resvg } from "@resvg/resvg-js";
2
+ import { JETBRAINS_MONO_FONT_FILES } from "./font.js";
3
+ export function renderPng(svg) {
4
+ const resvg = new Resvg(svg, {
5
+ font: {
6
+ defaultFontFamily: "JetBrains Mono",
7
+ fontFiles: [...JETBRAINS_MONO_FONT_FILES],
8
+ loadSystemFonts: false,
9
+ monospaceFamily: "JetBrains Mono"
10
+ },
11
+ fitTo: {
12
+ mode: "zoom",
13
+ value: 4
14
+ }
15
+ });
16
+ const png = resvg.render();
17
+ return Buffer.from(png.asPng());
18
+ }
@@ -0,0 +1,6 @@
1
+ import type { StyledRun } from "./ansi-parser.js";
2
+ export interface SvgOptions {
3
+ padding?: number;
4
+ window?: boolean;
5
+ }
6
+ export declare function renderSvg(runs: StyledRun[], options?: SvgOptions): string;
@@ -0,0 +1,225 @@
1
+ import { FONT_FACE_CSS } from "./font.js";
2
+ const ANSI_16_PALETTE = [
3
+ "#282a2e",
4
+ "#D74E6F",
5
+ "#31BB71",
6
+ "#D3E561",
7
+ "#8056FF",
8
+ "#ED61D7",
9
+ "#04D7D7",
10
+ "#C5C8C6",
11
+ "#4B4B4B",
12
+ "#FE5F86",
13
+ "#00D787",
14
+ "#EBFF71",
15
+ "#8F69FF",
16
+ "#FF7AEA",
17
+ "#00FEFE",
18
+ "#FFFFFF"
19
+ ];
20
+ const XTERM_256_PALETTE = [
21
+ "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
22
+ "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
23
+ "#000000", "#00005f", "#000087", "#0000af", "#0000d7", "#0000ff", "#005f00", "#005f5f",
24
+ "#005f87", "#005faf", "#005fd7", "#005fff", "#008700", "#00875f", "#008787", "#0087af",
25
+ "#0087d7", "#0087ff", "#00af00", "#00af5f", "#00af87", "#00afaf", "#00afd7", "#00afff",
26
+ "#00d700", "#00d75f", "#00d787", "#00d7af", "#00d7d7", "#00d7ff", "#00ff00", "#00ff5f",
27
+ "#00ff87", "#00ffaf", "#00ffd7", "#00ffff", "#5f0000", "#5f005f", "#5f0087", "#5f00af",
28
+ "#5f00d7", "#5f00ff", "#5f5f00", "#5f5f5f", "#5f5f87", "#5f5faf", "#5f5fd7", "#5f5fff",
29
+ "#5f8700", "#5f875f", "#5f8787", "#5f87af", "#5f87d7", "#5f87ff", "#5faf00", "#5faf5f",
30
+ "#5faf87", "#5fafaf", "#5fafd7", "#5fafff", "#5fd700", "#5fd75f", "#5fd787", "#5fd7af",
31
+ "#5fd7d7", "#5fd7ff", "#5fff00", "#5fff5f", "#5fff87", "#5fffaf", "#5fffd7", "#5fffff",
32
+ "#870000", "#87005f", "#870087", "#8700af", "#8700d7", "#8700ff", "#875f00", "#875f5f",
33
+ "#875f87", "#875faf", "#875fd7", "#875fff", "#878700", "#87875f", "#878787", "#8787af",
34
+ "#8787d7", "#8787ff", "#87af00", "#87af5f", "#87af87", "#87afaf", "#87afd7", "#87afff",
35
+ "#87d700", "#87d75f", "#87d787", "#87d7af", "#87d7d7", "#87d7ff", "#87ff00", "#87ff5f",
36
+ "#87ff87", "#87ffaf", "#87ffd7", "#87ffff", "#af0000", "#af005f", "#af0087", "#af00af",
37
+ "#af00d7", "#af00ff", "#af5f00", "#af5f5f", "#af5f87", "#af5faf", "#af5fd7", "#af5fff",
38
+ "#af8700", "#af875f", "#af8787", "#af87af", "#af87d7", "#af87ff", "#afaf00", "#afaf5f",
39
+ "#afaf87", "#afafaf", "#afafd7", "#afafff", "#afd700", "#afd75f", "#afd787", "#afd7af",
40
+ "#afd7d7", "#afd7ff", "#afff00", "#afff5f", "#afff87", "#afffaf", "#afffd7", "#afffff",
41
+ "#d70000", "#d7005f", "#d70087", "#d700af", "#d700d7", "#d700ff", "#d75f00", "#d75f5f",
42
+ "#d75f87", "#d75faf", "#d75fd7", "#d75fff", "#d78700", "#d7875f", "#d78787", "#d787af",
43
+ "#d787d7", "#d787ff", "#d7af00", "#d7af5f", "#d7af87", "#d7afaf", "#d7afd7", "#d7afff",
44
+ "#d7d700", "#d7d75f", "#d7d787", "#d7d7af", "#d7d7d7", "#d7d7ff", "#d7ff00", "#d7ff5f",
45
+ "#d7ff87", "#d7ffaf", "#d7ffd7", "#d7ffff", "#ff0000", "#ff005f", "#ff0087", "#ff00af",
46
+ "#ff00d7", "#ff00ff", "#ff5f00", "#ff5f5f", "#ff5f87", "#ff5faf", "#ff5fd7", "#ff5fff",
47
+ "#ff8700", "#ff875f", "#ff8787", "#ff87af", "#ff87d7", "#ff87ff", "#ffaf00", "#ffaf5f",
48
+ "#ffaf87", "#ffafaf", "#ffafd7", "#ffafff", "#ffd700", "#ffd75f", "#ffd787", "#ffd7af",
49
+ "#ffd7d7", "#ffd7ff", "#ffff00", "#ffff5f", "#ffff87", "#ffffaf", "#ffffd7", "#ffffff",
50
+ "#080808", "#121212", "#1c1c1c", "#262626", "#303030", "#3a3a3a", "#444444", "#4e4e4e",
51
+ "#585858", "#606060", "#666666", "#767676", "#808080", "#8a8a8a", "#949494", "#9e9e9e",
52
+ "#a8a8a8", "#b2b2b2", "#bcbcbc", "#c6c6c6", "#d0d0d0", "#dadada", "#e4e4e4", "#eeeeee"
53
+ ];
54
+ const BACKGROUND = "#171717";
55
+ const DEFAULT_FOREGROUND = "#c4c4c4";
56
+ const FONT_FAMILY = "JetBrains Mono";
57
+ const FONT_SIZE = 14;
58
+ const LINE_HEIGHT = 1.2;
59
+ const CHARACTER_WIDTH = 8.412666666666667;
60
+ const TEXT_BOTTOM_PADDING = 11.6;
61
+ const DEFAULT_PADDING = {
62
+ top: 20,
63
+ right: 40,
64
+ bottom: 20,
65
+ left: 20
66
+ };
67
+ const WINDOW_BAR_HEIGHT = 15;
68
+ export function renderSvg(runs, options = {}) {
69
+ const padding = options.padding === undefined
70
+ ? DEFAULT_PADDING
71
+ : {
72
+ top: options.padding,
73
+ right: options.padding,
74
+ bottom: options.padding,
75
+ left: options.padding
76
+ };
77
+ const showWindow = options.window ?? true;
78
+ const titleBarHeight = showWindow ? WINDOW_BAR_HEIGHT : 0;
79
+ const textStartX = padding.left;
80
+ const lineHeightPx = FONT_SIZE * LINE_HEIGHT;
81
+ const textStartY = titleBarHeight + padding.top + lineHeightPx;
82
+ const lines = splitIntoLines(runs);
83
+ const contentWidth = measureLines(lines);
84
+ const width = padding.left + contentWidth + padding.right;
85
+ const height = titleBarHeight + padding.top + (lines.length * lineHeightPx) + padding.bottom + TEXT_BOTTOM_PADDING;
86
+ const svgWidth = formatNumber(width);
87
+ const svgHeight = formatNumber(height);
88
+ const viewBox = `0 0 ${svgWidth} ${svgHeight}`;
89
+ const textElements = renderLines(lines, textStartX, textStartY, lineHeightPx);
90
+ return [
91
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="${viewBox}">`,
92
+ "<defs>",
93
+ "<style><![CDATA[",
94
+ FONT_FACE_CSS,
95
+ "]]></style>",
96
+ "</defs>",
97
+ `<rect x="0" y="0" width="${svgWidth}" height="${svgHeight}" fill="${BACKGROUND}" />`,
98
+ showWindow ? renderWindowControls() : "",
99
+ `<g font-family="${FONT_FAMILY}" font-size="${formatNumber(FONT_SIZE)}px" fill="${DEFAULT_FOREGROUND}">`,
100
+ textElements,
101
+ "</g>",
102
+ "</svg>"
103
+ ].join("");
104
+ }
105
+ function splitIntoLines(runs) {
106
+ const lines = [[]];
107
+ for (const run of runs) {
108
+ if (run.text === "\n") {
109
+ lines.push([]);
110
+ continue;
111
+ }
112
+ lines[lines.length - 1]?.push(run);
113
+ }
114
+ return lines;
115
+ }
116
+ function measureLines(lines) {
117
+ return Math.max(...lines.map(line => line.reduce((width, run) => width + (displayWidth(run.text) * CHARACTER_WIDTH), 0)), 0);
118
+ }
119
+ function displayWidth(text) {
120
+ const segmenter = new Intl.Segmenter();
121
+ let width = 0;
122
+ for (const { segment } of segmenter.segment(text)) {
123
+ const cp = segment.codePointAt(0);
124
+ width += cp !== undefined && isWideCodePoint(cp) ? 2 : 1;
125
+ }
126
+ return width;
127
+ }
128
+ function isWideCodePoint(cp) {
129
+ return ((cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
130
+ cp === 0x2329 || cp === 0x232A || // Angle brackets
131
+ (cp >= 0x2E80 && cp <= 0x303E) || // CJK Radicals, Kangxi
132
+ (cp >= 0x3041 && cp <= 0x33BF) || // Hiragana, Katakana, CJK symbols
133
+ (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Extension A
134
+ (cp >= 0x4E00 && cp <= 0xA4CF) || // CJK Unified Ideographs
135
+ (cp >= 0xA960 && cp <= 0xA97F) || // Hangul Jamo Extended-A
136
+ (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
137
+ (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
138
+ (cp >= 0xFE10 && cp <= 0xFE19) || // Vertical Forms
139
+ (cp >= 0xFE30 && cp <= 0xFE6F) || // CJK Compatibility Forms
140
+ (cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Latin, Halfwidth Katakana
141
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs
142
+ (cp >= 0x1B000 && cp <= 0x1B0FF) || // Kana Supplement
143
+ (cp >= 0x1F004 && cp <= 0x1F004) || // Mahjong tile
144
+ (cp >= 0x1F0CF && cp <= 0x1F0CF) || // Playing card black joker
145
+ (cp >= 0x1F200 && cp <= 0x1FFFD) || // Enclosed CJK + Emoji
146
+ (cp >= 0x20000 && cp <= 0x2FFFD) || // CJK Extension B–F
147
+ (cp >= 0x30000 && cp <= 0x3FFFD) // CJK Extension G–H
148
+ );
149
+ }
150
+ function renderLines(lines, textStartX, textStartY, lineHeightPx) {
151
+ return lines
152
+ .map((line, index) => {
153
+ const y = formatNumber(textStartY + (index * lineHeightPx));
154
+ if (line.length === 0) {
155
+ return `<text x="${formatNumber(textStartX)}" y="${y}" xml:space="preserve"/>`;
156
+ }
157
+ return [
158
+ `<text x="${formatNumber(textStartX)}" y="${y}" xml:space="preserve">`,
159
+ line.map(renderRun).join(""),
160
+ "</text>"
161
+ ].join("");
162
+ })
163
+ .join("");
164
+ }
165
+ function renderRun(run) {
166
+ const attributes = ['xml:space="preserve"'];
167
+ const color = resolveColor(run.fg);
168
+ const textDecorations = [];
169
+ if (color !== DEFAULT_FOREGROUND) {
170
+ attributes.push(`fill="${escapeXmlAttribute(color)}"`);
171
+ }
172
+ if (run.bold) {
173
+ attributes.push('font-weight="bold"');
174
+ }
175
+ if (run.italic) {
176
+ attributes.push('font-style="italic"');
177
+ }
178
+ if (run.underline) {
179
+ textDecorations.push("underline");
180
+ }
181
+ if (run.strikethrough) {
182
+ textDecorations.push("line-through");
183
+ }
184
+ if (textDecorations.length > 0) {
185
+ attributes.push(`text-decoration="${textDecorations.join(" ")}"`);
186
+ }
187
+ if (run.dim) {
188
+ attributes.push('opacity="0.7"');
189
+ }
190
+ return `<tspan ${attributes.join(" ")}>${escapeXmlText(run.text)}</tspan>`;
191
+ }
192
+ function renderWindowControls() {
193
+ return [
194
+ '<circle cx="13.5" cy="12" r="5.5" fill="#FF5A54" />',
195
+ '<circle cx="32.5" cy="12" r="5.5" fill="#E6BF29" />',
196
+ '<circle cx="51.5" cy="12" r="5.5" fill="#52C12B" />'
197
+ ].join("");
198
+ }
199
+ function resolveColor(color) {
200
+ if (color === null) {
201
+ return DEFAULT_FOREGROUND;
202
+ }
203
+ if (color.type === "ansi4") {
204
+ return ANSI_16_PALETTE[color.index] ?? DEFAULT_FOREGROUND;
205
+ }
206
+ if (color.type === "ansi8") {
207
+ if (color.index < ANSI_16_PALETTE.length) {
208
+ return ANSI_16_PALETTE[color.index] ?? DEFAULT_FOREGROUND;
209
+ }
210
+ return XTERM_256_PALETTE[color.index] ?? DEFAULT_FOREGROUND;
211
+ }
212
+ return `rgb(${color.r},${color.g},${color.b})`;
213
+ }
214
+ function escapeXmlText(value) {
215
+ return value
216
+ .replaceAll("&", "&amp;")
217
+ .replaceAll("<", "&lt;")
218
+ .replaceAll(">", "&gt;");
219
+ }
220
+ function escapeXmlAttribute(value) {
221
+ return escapeXmlText(value).replaceAll('"', "&quot;").replaceAll("'", "&apos;");
222
+ }
223
+ function formatNumber(value) {
224
+ return value.toFixed(2);
225
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "terminal-png",
3
+ "version": "0.1.0",
4
+ "description": "Render PNG images from ANSI terminal output.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": "./dist/index.js",
9
+ "bin": {
10
+ "terminal-png": "./bin/terminal-png"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "compare": "tsx scripts/compare.ts"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "bin",
19
+ "assets"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/poe-platform/poe-code.git",
24
+ "directory": "packages/terminal-png"
25
+ },
26
+ "dependencies": {
27
+ "@resvg/resvg-js": "^2.6.2",
28
+ "commander": "^14.0.3",
29
+ "jetbrains-mono": "^1.0.6"
30
+ }
31
+ }