code-snippet-image-generator
Version:
Pretty snippets generator
435 lines (376 loc) • 13.9 kB
JavaScript
const { createCanvas, registerFont } = require("canvas");
const fs = require("fs");
/**
* Generates a beautiful image from a code snippet
* @param {string} code - The code snippet to display
* @param {Object} options - Configuration options
* @param {string} options.backgroundColor - Base color for background (hex format, default: "#ff3366")
* @param {string} options.outputPath - Path to save the image (default: "code-snippet.png")
* @param {number} options.width - Canvas width (default: 1200)
* @param {number} options.height - Canvas height (default: 800)
* @param {number} options.padding - Padding around the code (default: 60)
* @param {string} options.fontFamily - Font family for code (default: "monospace")
* @param {number} options.fontSize - Font size for code (default: 24)
* @returns {Promise<Buffer>} - Returns a promise that resolves to the image buffer
*/
function codeToImage(code, options = {}) {
// Calculate the dimensions based on code content
const calculateDimensions = (code, fontSize, lineHeight) => {
// Create a temporary canvas to measure text
const tempCanvas = createCanvas(1, 1);
const tempCtx = tempCanvas.getContext("2d");
tempCtx.font = `${fontSize}px monospace`;
// Split the code into lines and find the longest line
const lines = code.split("\n");
const lineCount = lines.length;
// Calculate width based on the longest line
let maxLineWidth = 0;
lines.forEach((line) => {
const lineWidth = tempCtx.measureText(line).width;
maxLineWidth = Math.max(maxLineWidth, lineWidth);
});
// Add padding for the width and height
const paddingX = 150; // Left and right padding combined
const paddingY = 150; // Top and bottom padding combined
// Calculate the adaptive width and height
const adaptiveWidth = Math.max(400, maxLineWidth + paddingX);
// Add extra space at the bottom for the closing brace and shadow
const adaptiveHeight = Math.max(
200,
lineCount * fontSize * lineHeight + paddingY + 40
);
return {
width: Math.min(2000, adaptiveWidth), // Cap width at 2000px
height: Math.min(1500, adaptiveHeight), // Cap height at 1500px
};
};
// Get the font size from options or default
const fontSize = options.fontSize || 24;
const lineHeight = options.lineHeight || 1.5;
// Calculate adaptive dimensions if not explicitly provided
const adaptiveDimensions = calculateDimensions(code, fontSize, lineHeight);
// Default options with adaptive dimensions
const config = {
backgroundColor: options.backgroundColor || "#ff3366",
outputPath: options.outputPath || "code-snippet.png",
width: options.width || adaptiveDimensions.width,
height: options.height || adaptiveDimensions.height,
padding: options.padding || 50,
fontFamily: options.fontFamily || "monospace",
fontSize: fontSize,
lineHeight: lineHeight,
terminalRadius: options.terminalRadius || 15,
controlButtonRadius: options.controlButtonRadius || 12,
controlButtonSpacing: options.controlButtonSpacing || 25,
};
// Create canvas with the specified dimensions
const canvas = createCanvas(config.width, config.height);
const ctx = canvas.getContext("2d");
// Create gradient background
const gradient = ctx.createLinearGradient(0, 0, 0, config.height);
// Create a darker version of the background color for gradient
const darkenHexColor = (hex, percent) => {
// Parse the hex color
let r = parseInt(hex.slice(1, 3), 16);
let g = parseInt(hex.slice(3, 5), 16);
let b = parseInt(hex.slice(5, 7), 16);
// Darken the color by reducing RGB values
r = Math.max(0, Math.floor(r * (1 - percent / 100)));
g = Math.max(0, Math.floor(g * (1 - percent / 100)));
b = Math.max(0, Math.floor(b * (1 - percent / 100)));
// Convert back to hex
return `#${r.toString(16).padStart(2, "0")}${g
.toString(16)
.padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
};
const darkColor = darkenHexColor(config.backgroundColor, 20);
gradient.addColorStop(0, config.backgroundColor);
gradient.addColorStop(1, darkColor);
// Fill background
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, config.width, config.height);
// Calculate terminal window dimensions
const terminalWidth = config.width - config.padding * 2;
const terminalHeight = config.height - config.padding * 2;
const terminalX = config.padding;
const terminalY = config.padding;
// Draw terminal window with shadow
// First draw the shadow - subtle with larger spread
// Shadow blur effect with larger spread (simulate with multiple layers)
for (let i = 2; i <= 12; i += 2) {
// Increased max from 8 to 12 for larger spread
ctx.fillStyle = `rgba(0, 0, 0, ${0.1 - i * 0.008})`; // Adjusted opacity formula
// Use manual rounded rect if roundRect not available
if (!ctx.roundRect) {
// Draw rounded rectangle for shadow
const radius = config.terminalRadius;
const x = terminalX + i;
const y = terminalY + i;
const width = terminalWidth;
const height = terminalHeight;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.closePath();
ctx.fill();
} else {
ctx.roundRect(
terminalX + i,
terminalY + i,
terminalWidth,
terminalHeight,
config.terminalRadius
);
ctx.fill();
}
}
// Main shadow with slightly increased opacity
ctx.fillStyle = "rgba(0, 0, 0, 0.18)"; // Slightly increased from 0.15
ctx.beginPath();
// Use manual rounded rect if roundRect not available
if (!ctx.roundRect) {
// Draw rounded rectangle for shadow
const radius = config.terminalRadius;
const x = terminalX + 2; // Reduced offset from 4 to 2
const y = terminalY + 2; // Reduced offset from 4 to 2
const width = terminalWidth;
const height = terminalHeight;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.closePath();
} else {
ctx.roundRect(
terminalX + 2,
terminalY + 2,
terminalWidth,
terminalHeight,
config.terminalRadius
);
}
ctx.fill();
// Then draw the terminal
ctx.fillStyle = "#0a192f"; // Dark blue terminal background
ctx.beginPath();
// Use manual rounded rect if roundRect not available
if (!ctx.roundRect) {
// Draw rounded rectangle for terminal
const radius = config.terminalRadius;
const x = terminalX;
const y = terminalY;
const width = terminalWidth;
const height = terminalHeight;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.closePath();
} else {
ctx.roundRect(
terminalX,
terminalY,
terminalWidth,
terminalHeight,
config.terminalRadius
);
}
ctx.fill();
// Draw terminal controls (the colored circles)
const controlsY = terminalY + config.controlButtonRadius + 15;
const colors = ["#ff6057", "#ffbd2e", "#27c93f"]; // Red, Yellow, Green
// Increased spacing between the control buttons
const buttonSpacing = config.controlButtonSpacing + 5; // Add 5px more space
colors.forEach((color, i) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(
terminalX + config.controlButtonRadius + 15 + i * buttonSpacing,
controlsY,
config.controlButtonRadius,
0,
Math.PI * 2
);
ctx.fill();
});
// Syntax highlighting colors (Monokai-like theme)
const syntaxColors = {
keyword: "#f92672", // magenta
function: "#66d9ef", // blue
string: "#e6db74", // yellow
comment: "#75715e", // gray
variable: "#a6e22e", // green
normal: "#f8f8f2", // light gray
};
// Simple syntax highlighting for JavaScript
function getTokenType(token) {
// Check if token is a keyword
if (
/^(console|const|let|var|function|return|if|else|for|while|class|import|export|from|=>)$/.test(
token
)
) {
return "keyword";
}
// Check if token is a function call (ends with parenthesis)
if (/\w+\(/.test(token)) {
return "function";
}
// Check if token contains a string
if (/(["'`]).*\1/.test(token)) {
return "string";
}
// Check if token is a comment
if (/\/\//.test(token)) {
return "comment";
}
// Default color
return "normal";
}
// Draw text with syntax highlighting
function drawCodeWithHighlighting(ctx, code, x, y, options) {
const { fontSize, fontFamily, lineHeight } = options;
// Split the code into lines
const lines = code.split("\n");
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textBaseline = "top";
lines.forEach((line, lineIndex) => {
const lineY = y + lineIndex * fontSize * lineHeight;
// Simple tokenization by splitting on spaces and special characters
let currentX = x;
let currentWord = "";
// Process each character
for (let i = 0; i < line.length; i++) {
const char = line[i];
// Handle strings
if (char === '"' || char === "'" || char === "`") {
// Draw any accumulated word first
if (currentWord) {
const tokenType = getTokenType(currentWord);
ctx.fillStyle = syntaxColors[tokenType];
ctx.fillText(currentWord, currentX, lineY);
currentX += ctx.measureText(currentWord).width;
currentWord = "";
}
// Find the end of the string
let stringContent = char;
let j = i + 1;
while (j < line.length && line[j] !== char) {
stringContent += line[j];
j++;
}
// Add the closing quote if found
if (j < line.length) {
stringContent += line[j];
i = j; // Skip to the end of the string
}
// Draw the string
ctx.fillStyle = syntaxColors.string;
ctx.fillText(stringContent, currentX, lineY);
currentX += ctx.measureText(stringContent).width;
continue;
}
// Handle spaces
if (char === " ") {
// Draw any accumulated word
if (currentWord) {
const tokenType = getTokenType(currentWord);
ctx.fillStyle = syntaxColors[tokenType];
ctx.fillText(currentWord, currentX, lineY);
currentX += ctx.measureText(currentWord).width;
currentWord = "";
}
// Add the space
currentX += ctx.measureText(" ").width;
continue;
}
// Handle punctuation and special characters
if (/[(){}[\],;:.]/.test(char)) {
// Draw any accumulated word
if (currentWord) {
const tokenType = getTokenType(currentWord);
ctx.fillStyle = syntaxColors[tokenType];
ctx.fillText(currentWord, currentX, lineY);
currentX += ctx.measureText(currentWord).width;
currentWord = "";
}
// Draw the punctuation
ctx.fillStyle = syntaxColors.normal;
ctx.fillText(char, currentX, lineY);
currentX += ctx.measureText(char).width;
continue;
}
// Accumulate characters for the current word
currentWord += char;
}
// Draw any remaining word
if (currentWord) {
const tokenType = getTokenType(currentWord);
ctx.fillStyle = syntaxColors[tokenType];
ctx.fillText(currentWord, currentX, lineY);
}
});
}
// Draw the code with syntax highlighting
const codeX = terminalX + 30; // Reduced left margin
const codeY = controlsY + 35; // Reduced top margin
drawCodeWithHighlighting(ctx, code, codeX, codeY, {
fontSize: config.fontSize,
fontFamily: config.fontFamily,
lineHeight: config.lineHeight,
});
// Return the image buffer
return canvas.toBuffer("image/png");
}
/**
* Save the generated image to a file
* @param {Buffer} imageBuffer - The image buffer
* @param {string} outputPath - Path to save the image
* @returns {Promise<void>}
*/
async function saveImage(imageBuffer, outputPath) {
return new Promise((resolve, reject) => {
fs.writeFile(outputPath, imageBuffer, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Generate a code image and save it to file
* @param {string} code - The code snippet
* @param {Object} options - Configuration options
* @returns {Promise<string>} - Path to the saved image
*/
async function generateCodeImage(code, options = {}) {
try {
const imageBuffer = codeToImage(code, options);
const outputPath = options.outputPath || "code-snippet.png";
await saveImage(imageBuffer, outputPath);
console.log(`Image saved to ${outputPath}`);
return outputPath;
} catch (error) {
console.error("Error generating code image:", error);
throw error;
}
}
module.exports = {
codeToImage,
saveImage,
generateCodeImage,
};
// If running this file directly, generate an example
if (require.main === module) {
const exampleCode = `console.log("It's that simple?");`;
generateCodeImage(exampleCode, {
backgroundColor: "#ff3366",
outputPath: "example-code-snippet.png",
});
}