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 +27 -0
- package/assets/jetbrains-mono-400-italic.ttf +0 -0
- package/assets/jetbrains-mono-400-normal.ttf +0 -0
- package/assets/jetbrains-mono-700-italic.ttf +0 -0
- package/assets/jetbrains-mono-700-normal.ttf +0 -0
- package/bin/terminal-png +5 -0
- package/dist/ansi-parser.d.ts +23 -0
- package/dist/ansi-parser.js +247 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +59 -0
- package/dist/font.d.ts +4 -0
- package/dist/font.js +37 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +20 -0
- package/dist/png-renderer.d.ts +1 -0
- package/dist/png-renderer.js +18 -0
- package/dist/svg-renderer.d.ts +6 -0
- package/dist/svg-renderer.js +225 -0
- package/package.json +31 -0
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
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/bin/terminal-png
ADDED
|
@@ -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
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
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");
|
package/dist/index.d.ts
ADDED
|
@@ -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,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("&", "&")
|
|
217
|
+
.replaceAll("<", "<")
|
|
218
|
+
.replaceAll(">", ">");
|
|
219
|
+
}
|
|
220
|
+
function escapeXmlAttribute(value) {
|
|
221
|
+
return escapeXmlText(value).replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
+
}
|