terminal-png 0.1.0 → 0.1.2

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.
@@ -19,5 +19,7 @@ export type StyledRun = {
19
19
  underline: boolean;
20
20
  strikethrough: boolean;
21
21
  dim: boolean;
22
+ inverse: boolean;
23
+ conceal: boolean;
22
24
  };
23
25
  export declare function parseAnsi(input: string): StyledRun[];
@@ -7,7 +7,9 @@ function createDefaultStyle() {
7
7
  italic: false,
8
8
  underline: false,
9
9
  strikethrough: false,
10
- dim: false
10
+ dim: false,
11
+ inverse: false,
12
+ conceal: false
11
13
  };
12
14
  }
13
15
  function cloneStyle(style) {
@@ -19,6 +21,8 @@ function stylesEqual(left, right) {
19
21
  left.underline === right.underline &&
20
22
  left.strikethrough === right.strikethrough &&
21
23
  left.dim === right.dim &&
24
+ left.inverse === right.inverse &&
25
+ left.conceal === right.conceal &&
22
26
  colorsEqual(left.fg, right.fg) &&
23
27
  colorsEqual(left.bg, right.bg));
24
28
  }
@@ -54,18 +58,20 @@ function pushRun(runs, style, text) {
54
58
  italic: style.italic,
55
59
  underline: style.underline,
56
60
  strikethrough: style.strikethrough,
57
- dim: style.dim
61
+ dim: style.dim,
62
+ inverse: style.inverse,
63
+ conceal: style.conceal
58
64
  });
59
65
  }
60
- function parseCsi(input, start) {
61
- let index = start + 2;
66
+ function parseCsi(input, start, prefixLength) {
67
+ let index = start + prefixLength;
62
68
  while (index < input.length) {
63
69
  const code = input.charCodeAt(index);
64
70
  if (code >= 0x40 && code <= 0x7e) {
65
71
  return {
66
72
  end: index + 1,
67
73
  final: input[index],
68
- params: input.slice(start + 2, index)
74
+ params: input.slice(start + prefixLength, index)
69
75
  };
70
76
  }
71
77
  index += 1;
@@ -73,7 +79,7 @@ function parseCsi(input, start) {
73
79
  return {
74
80
  end: input.length,
75
81
  final: null,
76
- params: input.slice(start + 2)
82
+ params: input.slice(start + prefixLength)
77
83
  };
78
84
  }
79
85
  function toInteger(value) {
@@ -125,16 +131,24 @@ function applyExtendedColor(params, start) {
125
131
  function applySgr(style, paramsText) {
126
132
  const nextStyle = cloneStyle(style);
127
133
  const rawParams = paramsText.length === 0 ? ["0"] : paramsText.split(";");
128
- const params = [];
129
- for (const rawParam of rawParams) {
130
- const value = toInteger(rawParam);
134
+ for (let index = 0; index < rawParams.length; index += 1) {
135
+ const rawParam = rawParams[index] ?? "";
136
+ const colonColor = applyColonExtendedColor(rawParam);
137
+ if (colonColor !== undefined) {
138
+ if (colonColor !== null) {
139
+ if (colonColor.target === 38) {
140
+ nextStyle.fg = colonColor.color;
141
+ }
142
+ else {
143
+ nextStyle.bg = colonColor.color;
144
+ }
145
+ }
146
+ continue;
147
+ }
148
+ const value = rawParam.length === 0 ? 0 : toInteger(rawParam);
131
149
  if (value === null) {
132
150
  return nextStyle;
133
151
  }
134
- params.push(value);
135
- }
136
- for (let index = 0; index < params.length; index += 1) {
137
- const value = params[index];
138
152
  if (value === 0) {
139
153
  Object.assign(nextStyle, createDefaultStyle());
140
154
  continue;
@@ -176,6 +190,22 @@ function applySgr(style, paramsText) {
176
190
  nextStyle.strikethrough = false;
177
191
  continue;
178
192
  }
193
+ if (value === 7) {
194
+ nextStyle.inverse = true;
195
+ continue;
196
+ }
197
+ if (value === 27) {
198
+ nextStyle.inverse = false;
199
+ continue;
200
+ }
201
+ if (value === 8) {
202
+ nextStyle.conceal = true;
203
+ continue;
204
+ }
205
+ if (value === 28) {
206
+ nextStyle.conceal = false;
207
+ continue;
208
+ }
179
209
  if (value === 39) {
180
210
  nextStyle.fg = null;
181
211
  continue;
@@ -201,8 +231,14 @@ function applySgr(style, paramsText) {
201
231
  continue;
202
232
  }
203
233
  if (value === 38 || value === 48) {
204
- const extended = applyExtendedColor(params, index + 1);
234
+ const params = rawParams.map((param) => toInteger(param));
235
+ const extended = params.every((param) => param !== null)
236
+ ? applyExtendedColor(params, index + 1)
237
+ : null;
205
238
  if (!extended) {
239
+ if (rawParams[index + 1] === "2" || rawParams[index + 1] === "5") {
240
+ break;
241
+ }
206
242
  continue;
207
243
  }
208
244
  if (value === 38) {
@@ -216,32 +252,149 @@ function applySgr(style, paramsText) {
216
252
  }
217
253
  return nextStyle;
218
254
  }
219
- export function parseAnsi(input) {
255
+ function applyColonExtendedColor(rawParam) {
256
+ if (!rawParam.includes(":")) {
257
+ return undefined;
258
+ }
259
+ const segments = rawParam.split(":");
260
+ const target = toInteger(segments[0]);
261
+ if (target !== 38 && target !== 48) {
262
+ return null;
263
+ }
264
+ const mode = toInteger(segments[1]);
265
+ if (mode === 5) {
266
+ const index = toInteger(segments[2]);
267
+ return index === null ? null : { target, color: { type: "ansi8", index: clampByte(index) } };
268
+ }
269
+ if (mode !== 2) {
270
+ return null;
271
+ }
272
+ const offset = segments[2] === "" ? 3 : 2;
273
+ const r = toInteger(segments[offset]);
274
+ const g = toInteger(segments[offset + 1]);
275
+ const b = toInteger(segments[offset + 2]);
276
+ if (r === null || g === null || b === null) {
277
+ return null;
278
+ }
279
+ return { target, color: { type: "rgb", r: clampByte(r), g: clampByte(g), b: clampByte(b) } };
280
+ }
281
+ function parseOscEnd(input, start) {
282
+ let index = start + 2;
283
+ while (index < input.length) {
284
+ if (input[index] === "\u0007") {
285
+ return index + 1;
286
+ }
287
+ if (input[index] === ESC && input[index + 1] === "\\") {
288
+ return index + 2;
289
+ }
290
+ index += 1;
291
+ }
292
+ return input.length;
293
+ }
294
+ function positionParameter(params, index, fallback) {
295
+ const value = toInteger(params.split(";")[index]);
296
+ return value === null || value < 1 ? fallback : value;
297
+ }
298
+ function buildRuns(lines, lineBreakStyles) {
220
299
  const runs = [];
300
+ const defaultStyle = createDefaultStyle();
301
+ for (let row = 0; row < lines.length; row += 1) {
302
+ const line = lines[row] ?? [];
303
+ let lastCell = line.length - 1;
304
+ while (lastCell >= 0 && line[lastCell] === undefined) {
305
+ lastCell -= 1;
306
+ }
307
+ for (let column = 0; column <= lastCell; column += 1) {
308
+ const cell = line[column] ?? { text: " ", style: defaultStyle };
309
+ pushRun(runs, cell.style, cell.text);
310
+ }
311
+ if (row < lines.length - 1) {
312
+ pushRun(runs, lineBreakStyles[row] ?? defaultStyle, "\n");
313
+ }
314
+ }
315
+ return runs;
316
+ }
317
+ export function parseAnsi(input) {
318
+ const lines = [[]];
319
+ const lineBreakStyles = [];
221
320
  let style = createDefaultStyle();
222
- let textStart = 0;
321
+ let row = 0;
322
+ let column = 0;
223
323
  let index = 0;
324
+ const ensureLine = () => {
325
+ while (lines.length <= row) {
326
+ lines.push([]);
327
+ }
328
+ };
329
+ const moveDown = (keepColumn) => {
330
+ lineBreakStyles[row] = cloneStyle(style);
331
+ row += 1;
332
+ if (!keepColumn) {
333
+ column = 0;
334
+ }
335
+ ensureLine();
336
+ };
224
337
  while (index < input.length) {
225
338
  const char = input[index];
226
339
  if (char === "\n") {
227
- pushRun(runs, style, input.slice(textStart, index));
228
- pushRun(runs, style, "\n");
340
+ moveDown(false);
229
341
  index += 1;
230
- textStart = index;
231
342
  continue;
232
343
  }
233
- if (char === ESC && input[index + 1] === "[") {
234
- pushRun(runs, style, input.slice(textStart, index));
235
- const sequence = parseCsi(input, index);
344
+ if (char === "\v") {
345
+ moveDown(true);
346
+ index += 1;
347
+ continue;
348
+ }
349
+ if (char === "\r") {
350
+ column = 0;
351
+ index += 1;
352
+ continue;
353
+ }
354
+ if (char === "\b") {
355
+ column = Math.max(0, column - 1);
356
+ index += 1;
357
+ continue;
358
+ }
359
+ if (char === ESC && input[index + 1] === "]") {
360
+ index = parseOscEnd(input, index);
361
+ continue;
362
+ }
363
+ const csiPrefixLength = char === "\u009b" ? 1 : char === ESC && input[index + 1] === "[" ? 2 : null;
364
+ if (csiPrefixLength !== null) {
365
+ const sequence = parseCsi(input, index, csiPrefixLength);
236
366
  if (sequence.final === "m") {
237
367
  style = applySgr(style, sequence.params);
238
368
  }
369
+ else if (sequence.final === "G") {
370
+ column = positionParameter(sequence.params, 0, 1) - 1;
371
+ }
372
+ else if (sequence.final === "C") {
373
+ column += positionParameter(sequence.params, 0, 1);
374
+ }
375
+ else if (sequence.final === "D") {
376
+ column = Math.max(0, column - positionParameter(sequence.params, 0, 1));
377
+ }
378
+ else if (sequence.final === "A") {
379
+ row = Math.max(0, row - positionParameter(sequence.params, 0, 1));
380
+ }
381
+ else if (sequence.final === "B") {
382
+ row += positionParameter(sequence.params, 0, 1);
383
+ ensureLine();
384
+ }
385
+ else if (sequence.final === "H" || sequence.final === "f") {
386
+ row = positionParameter(sequence.params, 0, 1) - 1;
387
+ column = positionParameter(sequence.params, 1, 1) - 1;
388
+ ensureLine();
389
+ }
239
390
  index = sequence.end;
240
- textStart = index;
241
391
  continue;
242
392
  }
243
- index += 1;
393
+ const codePoint = input.codePointAt(index);
394
+ const text = codePoint === undefined ? "" : String.fromCodePoint(codePoint);
395
+ lines[row][column] = { text, style: cloneStyle(style) };
396
+ column += 1;
397
+ index += text.length;
244
398
  }
245
- pushRun(runs, style, input.slice(textStart));
246
- return runs;
399
+ return buildRuns(lines, lineBreakStyles);
247
400
  }
package/dist/font.js CHANGED
@@ -22,12 +22,12 @@ function createFontFace(base64, weight, style) {
22
22
  }`;
23
23
  }
24
24
  export const JETBRAINS_MONO_BASE64 = readWebfontBase64("JetBrainsMono-Regular.woff2");
25
- export const JETBRAINS_MONO_FONT_FILES = [
25
+ export const JETBRAINS_MONO_FONT_FILES = Object.freeze([
26
26
  resolveAssetPath("jetbrains-mono-400-normal.ttf"),
27
27
  resolveAssetPath("jetbrains-mono-700-normal.ttf"),
28
28
  resolveAssetPath("jetbrains-mono-400-italic.ttf"),
29
29
  resolveAssetPath("jetbrains-mono-700-italic.ttf")
30
- ];
30
+ ]);
31
31
  export const JETBRAINS_MONO_TTF_PATH = JETBRAINS_MONO_FONT_FILES[0];
32
32
  export const FONT_FACE_CSS = [
33
33
  createFontFace(JETBRAINS_MONO_BASE64, 400, "normal"),
package/dist/index.js CHANGED
@@ -1,8 +1,12 @@
1
- import { writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { rename, rm, writeFile } from "node:fs/promises";
2
3
  import { parseAnsi } from "./ansi-parser.js";
3
4
  import { renderPng } from "./png-renderer.js";
4
5
  import { renderSvg } from "./svg-renderer.js";
5
6
  export async function renderTerminalPng(ansiText, options = {}) {
7
+ if (options.padding !== undefined && (!Number.isInteger(options.padding) || options.padding < 0)) {
8
+ throw new Error("Padding must be a non-negative integer.");
9
+ }
6
10
  const runs = parseAnsi(ansiText);
7
11
  const svg = renderSvg(runs, {
8
12
  padding: options.padding,
@@ -10,7 +14,20 @@ export async function renderTerminalPng(ansiText, options = {}) {
10
14
  });
11
15
  const png = renderPng(svg);
12
16
  if (options.output) {
13
- await writeFile(options.output, png);
17
+ const temporaryPath = `${options.output}.${randomUUID()}.tmp`;
18
+ try {
19
+ await writeFile(temporaryPath, png, { flag: "wx" });
20
+ await rename(temporaryPath, options.output);
21
+ }
22
+ catch (error) {
23
+ try {
24
+ await rm(temporaryPath, { force: true });
25
+ }
26
+ catch (cleanupError) {
27
+ void cleanupError;
28
+ }
29
+ throw error;
30
+ }
14
31
  }
15
32
  return png;
16
33
  }
@@ -114,16 +114,30 @@ function splitIntoLines(runs) {
114
114
  return lines;
115
115
  }
116
116
  function measureLines(lines) {
117
- return Math.max(...lines.map(line => line.reduce((width, run) => width + (displayWidth(run.text) * CHARACTER_WIDTH), 0)), 0);
117
+ return Math.max(...lines.map((line) => displayWidth(line.map((run) => run.text).join("")) * CHARACTER_WIDTH), 0);
118
118
  }
119
- function displayWidth(text) {
119
+ function displayWidth(text, startColumn = 0) {
120
120
  const segmenter = new Intl.Segmenter();
121
- let width = 0;
121
+ let column = startColumn;
122
122
  for (const { segment } of segmenter.segment(text)) {
123
+ if (segment === "\t") {
124
+ column += 8 - (column % 8);
125
+ continue;
126
+ }
123
127
  const cp = segment.codePointAt(0);
124
- width += cp !== undefined && isWideCodePoint(cp) ? 2 : 1;
128
+ if (cp !== undefined && isZeroWidthCodePoint(cp)) {
129
+ continue;
130
+ }
131
+ column += cp !== undefined && (isWideCodePoint(cp) || isFlagSegment(segment)) ? 2 : 1;
125
132
  }
126
- return width;
133
+ return column - startColumn;
134
+ }
135
+ function isZeroWidthCodePoint(cp) {
136
+ return (cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x1dc0 && cp <= 0x1dff) || (cp >= 0x20d0 && cp <= 0x20ff) || (cp >= 0xfe20 && cp <= 0xfe2f);
137
+ }
138
+ function isFlagSegment(segment) {
139
+ const codePoints = [...segment].map((character) => character.codePointAt(0));
140
+ return codePoints.length === 2 && codePoints.every((cp) => cp !== undefined && cp >= 0x1f1e6 && cp <= 0x1f1ff);
127
141
  }
128
142
  function isWideCodePoint(cp) {
129
143
  return ((cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
@@ -155,6 +169,7 @@ function renderLines(lines, textStartX, textStartY, lineHeightPx) {
155
169
  return `<text x="${formatNumber(textStartX)}" y="${y}" xml:space="preserve"/>`;
156
170
  }
157
171
  return [
172
+ renderBackgrounds(line, textStartX, textStartY + (index * lineHeightPx) - lineHeightPx, lineHeightPx),
158
173
  `<text x="${formatNumber(textStartX)}" y="${y}" xml:space="preserve">`,
159
174
  line.map(renderRun).join(""),
160
175
  "</text>"
@@ -162,9 +177,22 @@ function renderLines(lines, textStartX, textStartY, lineHeightPx) {
162
177
  })
163
178
  .join("");
164
179
  }
180
+ function renderBackgrounds(line, startX, y, height) {
181
+ let column = 0;
182
+ const rectangles = [];
183
+ for (const run of line) {
184
+ const width = displayWidth(run.text, column);
185
+ const background = resolveBackgroundColor(run);
186
+ if (background !== BACKGROUND && width > 0) {
187
+ rectangles.push(`<rect x="${formatNumber(startX + (column * CHARACTER_WIDTH))}" y="${formatNumber(y)}" width="${formatNumber(width * CHARACTER_WIDTH)}" height="${formatNumber(height)}" fill="${escapeXmlAttribute(background)}" />`);
188
+ }
189
+ column += width;
190
+ }
191
+ return rectangles.join("");
192
+ }
165
193
  function renderRun(run) {
166
194
  const attributes = ['xml:space="preserve"'];
167
- const color = resolveColor(run.fg);
195
+ const color = resolveForegroundColor(run);
168
196
  const textDecorations = [];
169
197
  if (color !== DEFAULT_FOREGROUND) {
170
198
  attributes.push(`fill="${escapeXmlAttribute(color)}"`);
@@ -187,7 +215,8 @@ function renderRun(run) {
187
215
  if (run.dim) {
188
216
  attributes.push('opacity="0.7"');
189
217
  }
190
- return `<tspan ${attributes.join(" ")}>${escapeXmlText(run.text)}</tspan>`;
218
+ const text = run.conceal ? " ".repeat(displayWidth(run.text)) : run.text;
219
+ return `<tspan ${attributes.join(" ")}>${escapeXmlText(text)}</tspan>`;
191
220
  }
192
221
  function renderWindowControls() {
193
222
  return [
@@ -211,6 +240,15 @@ function resolveColor(color) {
211
240
  }
212
241
  return `rgb(${color.r},${color.g},${color.b})`;
213
242
  }
243
+ function resolveForegroundColor(run) {
244
+ return run.inverse ? resolveColorOrDefault(run.bg, BACKGROUND) : resolveColor(run.fg);
245
+ }
246
+ function resolveBackgroundColor(run) {
247
+ return run.inverse ? resolveColor(run.fg) : resolveColorOrDefault(run.bg, BACKGROUND);
248
+ }
249
+ function resolveColorOrDefault(color, defaultColor) {
250
+ return color === null ? defaultColor : resolveColor(color);
251
+ }
214
252
  function escapeXmlText(value) {
215
253
  return value
216
254
  .replaceAll("&", "&amp;")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-png",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Render PNG images from ANSI terminal output.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -10,7 +10,8 @@
10
10
  "terminal-png": "./bin/terminal-png"
11
11
  },
12
12
  "scripts": {
13
- "build": "tsc -p tsconfig.build.json",
13
+ "build": "node ../../scripts/guard-package-dist.mjs && tsc -p tsconfig.build.json",
14
+ "prepack": "node ../../scripts/set-bin-executable.mjs",
14
15
  "compare": "tsx scripts/compare.ts"
15
16
  },
16
17
  "files": [