@jsenv/terminal-recorder
Version:
Record terminal output as .svg, .gif, .webm, .mp4
313 lines (289 loc) • 8.62 kB
JavaScript
import { parseAnsi } from "./parse_ansi.js";
import { createSvgRootNode } from "./xml_generator.js";
const colorsDefault = {
black: "#000000",
red: "#cd5555",
green: "#11bc79",
yellow: "#DAA520",
blue: "#4169E1",
magenta: "#9932CC",
cyan: "#008B8B",
white: "#D3D3D3",
gray: "#7f7f7f",
redBright: "#FF4500",
greenBright: "#ADFF2F",
yellowBright: "#FFFF00",
blueBright: "#87CEEB",
magentaBright: "#FF00FF",
cyanBright: "#00FFFF",
whiteBright: "#FFFFFF",
bgBlack: "#000000",
bgRed: "#B22222",
bgGreen: "#32CD32",
bgYellow: "#DAA520",
bgBlue: "#4169E1",
bgMagenta: "#9932CC",
bgCyan: "#008B8B",
bgWhite: "#D3D3D3",
bgGray: "#7f7f7f",
bgRedBright: "#FF0000",
bgGreenBright: "#ADFF2F",
bgYellowBright: "#FFFF00",
bgBlueBright: "#87CEEB",
bgMagentaBright: "#FF00FF",
bgCyanBright: "#00FFFF",
bgWhiteBright: "#FFFFFF",
};
const headDefault = {
height: 40,
};
export const renderTerminalSvg = (
ansi,
{
title = "ansi to terminal",
// Font: (use monospace fonts for best results)
fontFamily = "SauceCodePro Nerd Font, Source Code Pro, Courier",
fontFace,
// Pixel Values:
fontSize = 14,
lineHeight = 18,
paddingTop = 0,
paddingLeft = 10,
paddingBottom = 0,
paddingRight = 10,
backgroundColor = "#282c34",
foregroundColor = "#cccccc",
colors = colorsDefault,
// by default: fixed width of 640 + fluid height
width = 640,
height,
maxWidth,
maxHeight,
preserveAspectRatio, // "xMidYMid slice",
head = true,
} = {},
) => {
const font = {
size: fontSize,
width: 8.4013671875,
height: 14,
face: fontFace,
family: fontFamily,
lineHeight,
emHeightAscent: 10.5546875,
emHeightDescent: 3.4453125,
};
let headerHeight = 0;
if (head) {
if (head === true) head = {};
const headOptions = { ...headDefault, ...head };
headerHeight = headOptions.height;
}
const { rows, columns, chunks } = parseAnsi(ansi);
const bodyTextWidth = columns * font.width;
const bodyTextHeight = rows * (font.lineHeight + 1) + font.emHeightDescent;
const bodyContentWidth = paddingLeft + bodyTextWidth + paddingRight;
const bodyContentHeight = paddingTop + bodyTextHeight + paddingBottom;
let contentWidth = bodyContentWidth;
let contentHeight = headerHeight + bodyContentHeight;
let computedWidth;
if (typeof width === "number") {
computedWidth = width;
} else {
computedWidth = contentWidth;
}
if (typeof maxWidth === "number" && computedWidth > maxWidth) {
computedWidth = maxWidth;
}
let computedHeight;
if (typeof height === "number") {
computedHeight = height;
} else {
computedHeight = contentHeight;
}
if (typeof maxHeight === "number" && computedHeight > maxHeight) {
computedHeight = maxHeight;
}
const svg = createSvgRootNode({
"xmlns": "http://www.w3.org/2000/svg",
"font-family": font.family,
"font-size": font.size,
"width": "100%",
"viewBox": `0 0 ${computedWidth} ${computedHeight}`,
preserveAspectRatio,
"background-color": backgroundColor,
});
render_background: {
const backgroundGroup = svg.createNode("g", {
id: "background",
});
const backgroundRect = svg.createNode("rect", {
"x": 1,
"y": 1,
"width": computedWidth - 2,
"height": computedHeight - 2,
"fill": backgroundColor,
"stroke": "rgba(255,255,255,0.35)",
"stroke-width": 1,
"rx": 8,
});
backgroundGroup.appendChild(backgroundRect);
svg.appendChild(backgroundGroup);
}
if (head) {
render_head: {
const headerGroup = svg.createNode("g", {
id: "header",
});
const iconsGroup = svg.createNode("g", {
transform: `translate(20,${headerHeight / 2})`,
});
const circleA = svg.createNode("circle", {
cx: 0,
cy: 0,
r: 6,
fill: "#ff5f57",
});
iconsGroup.appendChild(circleA);
const circleB = svg.createNode("circle", {
cx: 20,
cy: 0,
r: 6,
fill: "#febc2e",
});
iconsGroup.appendChild(circleB);
const circleC = svg.createNode("circle", {
cx: 40,
cy: 0,
r: 6,
fill: "#28c840",
});
iconsGroup.appendChild(circleC);
headerGroup.appendChild(iconsGroup);
const text = svg.createNode("text", {
"fill": "#abb2bf",
"text-anchor": "middle",
"x": computedWidth / 2,
"y": headerHeight / 2,
});
text.setContent(title);
headerGroup.appendChild(text);
svg.appendChild(headerGroup);
}
}
render_body: {
const bodyComputedHeight = computedHeight - headerHeight + paddingBottom;
const foreignObject = svg.createNode("foreignObject", {
id: "body",
y: headerHeight,
width: "100%",
height: bodyComputedHeight,
// we can't really know in advance the size of the scrollbar
// so putting overflow: "auto"
// is not that great as it would create a scrollbar
// in both axes when a single one is required
overflow: "hidden",
});
svg.appendChild(foreignObject);
const bodySvg = svg.createNode("svg", {
"width": bodyContentWidth,
"height": bodyContentHeight,
"font-family": "monospace",
"font-variant-east-asian": "full-width",
"fill": foregroundColor,
});
foreignObject.appendChild(bodySvg);
const offsetLeft = paddingLeft;
const offsetTop = paddingTop + font.lineHeight - font.emHeightDescent;
const textContainer = svg.createNode("g", {
// transform: `translate(${paddingLeft}, ${offsetTop})`,
});
bodySvg.appendChild(textContainer);
for (const chunk of chunks) {
const { type, value, style } = chunk;
if (type !== "text") {
continue;
}
const { position } = chunk;
const attrs = {};
// Some SVG Implementations drop whitespaces
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xml:space
// and https://codepen.io/dmail/pen/wvLvbbP
if (
// in the following case we need to preserve whitespaces:
// - one or more leading whitespace
// - one or more trailing whitespace
// - 2 or more whitespace in the middle of the string
/^\s+|\s\s|\s+$/.test(value)
) {
attrs.style = "white-space:pre";
}
const x = offsetLeft + position.x * font.width;
const y = offsetTop + position.y + font.lineHeight * position.y;
const w = font.width * position.width;
if (style.bold) {
attrs["font-weight"] = "bold";
}
if (style.italic) {
attrs["font-style"] = "italic";
}
let opacity = 1;
if (style.dim) {
opacity = 0.5;
}
if (style.backgroundColor) {
const backgroundColor = colors[style.backgroundColor];
const rect = svg.createNode("rect", {
x,
y: y - font.lineHeight + font.emHeightDescent,
width: w,
height: font.lineHeight + 1,
fill: backgroundColor,
...(opacity ? { opacity } : {}),
});
textContainer.appendChild(rect);
}
let textForegroundColor;
if (style.foregroundColor) {
textForegroundColor = colors[style.foregroundColor];
attrs["fill"] = textForegroundColor;
}
// Underline & Strikethrough:
// Some SVG implementations do not support underline and
// strikethrough for <text> elements (see Sketch 49.2)
if (style.underline) {
const yOffset = font.height * 0.14;
const ys = y - -yOffset;
const xw = x + w;
const path = svg.createNode("path", {
d: `M${x},${ys} L${xw},${ys} Z`,
stroke: textForegroundColor || foregroundColor,
});
textContainer.appendChild(path);
}
if (style.strikethrough) {
const yOffset = font.height * 0.3;
const ys = y - yOffset;
const xw = x + w;
const path = svg.createNode("path", {
d: `M${x},${ys} L${xw},${ys} Z`,
stroke: textForegroundColor || foregroundColor,
});
textContainer.appendChild(path);
}
if (value.trim() === "") {
// Do not output elements containing only whitespace
continue;
}
const text = svg.createNode("text", {
x,
y,
...attrs,
});
text.setContent(value);
textContainer.appendChild(text);
}
}
const svgString = svg.renderAsString();
return svgString;
};