typography-canvas-renderer
Version:
A lightweight npm package for rendering typographic content (text and images) on HTML5 Canvas with full CSS styling support including borders, border-radius, multiple border styles, inline text rendering, auto height calculation, and image support
504 lines (439 loc) • 15.1 kB
text/typescript
import { validateAndParseCSS } from './css-validator.js';
import { loadImages } from './image-loader.js';
import type { RenderElement, ParsedCSS } from './types.js';
/**
* Renders elements on a canvas context
*/
export const renderElements = async (
ctx: CanvasRenderingContext2D,
elements: RenderElement[],
scaleFactor: number
): Promise<void> => {
// Load all images first
const imageSrcs = elements
.filter(el => el.type === 'image')
.map(el => el.content);
const loadedImages = await loadImages(imageSrcs);
const imageMap = new Map(loadedImages.map(img => [img.src, img.element]));
// Render each element
for (const element of elements) {
const scaledCSS = applyScalingToCSS(element.css, scaleFactor);
const parsedCSS = validateAndParseCSS(scaledCSS, element.type);
if (element.type === 'text') {
await renderText(ctx, element.content, parsedCSS);
} else if (element.type === 'image') {
const image = imageMap.get(element.content);
if (image) {
await renderImage(ctx, image, parsedCSS);
}
}
}
};
/**
* Wraps text to fit within specified width
*/
const wrapText = (
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number
): string[] => {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (let i = 0; i < words.length; i++) {
const word = words[i];
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const testWidth = ctx.measureText(testLine).width;
if (testWidth > maxWidth && currentLine) {
// Current line is too long, start a new line
lines.push(currentLine);
currentLine = word;
} else {
// Add word to current line
currentLine = testLine;
}
}
// Add the last line if it's not empty
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
/**
* Renders text on canvas
*/
const renderText = async (
ctx: CanvasRenderingContext2D,
text: string,
css: ParsedCSS
): Promise<void> => {
if (!css.left || !css.top) {
console.warn('Text element missing required position properties (left, top)');
return;
}
// Set font properties
const fontSize = css.fontSize || 16;
const fontFamily = css.fontFamily || 'Arial, sans-serif';
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = css.color || '#000000';
ctx.textAlign = css.textAlign || 'left';
ctx.textBaseline = 'top';
// Save canvas state for opacity
ctx.save();
// Set opacity
if (css.opacity !== undefined) {
ctx.globalAlpha = css.opacity;
}
// Check if width is set to determine rendering mode
const hasWidth = css.width !== undefined;
if (!hasWidth) {
// Inline text rendering - no width constraint
// Calculate padding for inline text (considering border width)
const borderWidth = css.border ? css.border.width : 0;
const customPadding = css.padding;
const padding = customPadding !== undefined ? customPadding : Math.max(borderWidth, 2); // Use custom padding or border width, minimum 2px
// Handle multiline text for inline rendering
const lineHeight = css.lineHeight || fontSize * 1.2;
const lines = text.split('\n');
// Calculate dimensions for all lines
let maxWidth = 0;
lines.forEach(line => {
const lineWidth = ctx.measureText(line).width;
maxWidth = Math.max(maxWidth, lineWidth);
});
const textWidth = maxWidth;
// Calculate actual text height more accurately
// For single line, use fontSize; for multiple lines, use (lines.length - 1) * lineHeight + fontSize
const textHeight = lines.length === 1 ? fontSize : (lines.length - 1) * lineHeight + fontSize;
// Calculate total dimensions including padding
const totalWidth = textWidth + (padding * 2);
const totalHeight = textHeight + (padding * 2);
// Position text inside the padded area
let textX = css.left + padding;
const textY = css.top + padding;
// Adjust textX for center alignment
if (css.textAlign === 'center') {
textX = css.left + totalWidth / 2;
} else if (css.textAlign === 'right') {
textX = css.left + totalWidth - padding;
}
// Draw background if specified (for inline text, use total dimensions)
if (css.backgroundColor) {
ctx.fillStyle = css.backgroundColor;
if (css.borderRadius && css.borderRadius > 0) {
// Draw rounded background
drawRoundedRect(ctx, css.left, css.top, totalWidth, totalHeight, css.borderRadius, true);
} else {
ctx.fillRect(css.left, css.top, totalWidth, totalHeight);
}
}
// Draw text - render each line
ctx.fillStyle = css.color || '#000000';
lines.forEach((line, index) => {
const lineY = textY + (index * lineHeight);
ctx.fillText(line, textX, lineY);
});
// Draw border if specified (for inline text, use total dimensions)
if (css.border) {
drawBorder(ctx, css.left, css.top, totalWidth, totalHeight, css.border, css.borderRadius);
}
} else {
// Block text rendering - width constraint applied
const lineHeight = css.lineHeight || fontSize * 1.2;
// Calculate available width for text (considering padding)
const borderWidth = css.border ? css.border.width : 0;
const customPadding = css.padding;
const padding = customPadding !== undefined ? customPadding : Math.max(borderWidth, 4); // Use custom padding or minimum 4px
const availableWidth = (css.width as number) - (padding * 2);
// Process text: split by \n and wrap long lines
const allLines: string[] = [];
const manualLines = text.split('\n');
manualLines.forEach(line => {
if (ctx.measureText(line).width > availableWidth) {
// Wrap this line to fit within available width
const wrappedLines = wrapText(ctx, line, availableWidth);
allLines.push(...wrappedLines);
} else {
allLines.push(line);
}
});
// Calculate dimensions for all lines
let maxWidth = 0;
allLines.forEach(line => {
const lineWidth = ctx.measureText(line).width;
maxWidth = Math.max(maxWidth, lineWidth);
});
const measuredWidth = maxWidth;
// Calculate actual text height based on the final wrapped lines count
// Use the actual line height for each line (this accounts for the increased line count due to padding)
const measuredHeight = allLines.length * lineHeight;
// Use specified dimensions if available, otherwise use measured text dimensions
const elementWidth = css.width || measuredWidth;
const elementHeight = css.height || (measuredHeight + (padding * 2));
// Calculate text position with padding and vertical alignment
const textX = css.left + padding;
// Handle vertical alignment
let textY = css.top + padding; // Default to top alignment
if (css.verticalAlign) {
const totalTextHeight = allLines.length * lineHeight; // Use the same calculation as measuredHeight
const availableHeight = elementHeight - (padding * 2);
if (css.verticalAlign === 'middle' || css.verticalAlign === 'center') {
textY = css.top + padding + (availableHeight - totalTextHeight) / 2;
} else if (css.verticalAlign === 'bottom') {
textY = css.top + elementHeight - padding - totalTextHeight;
}
}
// Draw background if specified
if (css.backgroundColor) {
ctx.fillStyle = css.backgroundColor;
if (css.borderRadius && css.borderRadius > 0) {
// Draw rounded background
drawRoundedRect(ctx, css.left, css.top, elementWidth, elementHeight, css.borderRadius, true);
} else {
ctx.fillRect(css.left, css.top, elementWidth, elementHeight);
}
}
// Draw text with proper positioning
ctx.fillStyle = css.color || '#000000';
// Render each line
allLines.forEach((line, index) => {
const lineY = textY + (index * lineHeight);
// Handle text alignment within the element
if (css.textAlign === 'center') {
ctx.textAlign = 'center';
ctx.fillText(line, (css.left || 0) + elementWidth / 2, lineY);
} else if (css.textAlign === 'right') {
ctx.textAlign = 'right';
ctx.fillText(line, (css.left || 0) + elementWidth - padding, lineY);
} else {
ctx.textAlign = 'left';
ctx.fillText(line, textX, lineY);
}
});
// Draw border if specified
if (css.border) {
drawBorder(ctx, css.left, css.top, elementWidth, elementHeight, css.border, css.borderRadius);
}
}
// Restore canvas state (resets opacity)
ctx.restore();
};
/**
* Renders image on canvas
*/
const renderImage = async (
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
css: ParsedCSS
): Promise<void> => {
if (!css.left || !css.top) {
console.warn('Image element missing required position properties (left, top)');
return;
}
const width = css.width || image.naturalWidth;
const height = css.height || image.naturalHeight;
// Save canvas state for opacity
ctx.save();
// Set opacity
if (css.opacity !== undefined) {
ctx.globalAlpha = css.opacity;
}
// Draw background if specified
if (css.backgroundColor) {
ctx.fillStyle = css.backgroundColor;
if (css.borderRadius && css.borderRadius > 0) {
// Draw rounded background
drawRoundedRect(ctx, css.left, css.top, width, height, css.borderRadius, true);
} else {
ctx.fillRect(css.left, css.top, width, height);
}
}
// Draw image
if (css.borderRadius) {
// Draw image with rounded corners
drawRoundedImage(ctx, image, css.left, css.top, width, height, css.borderRadius);
} else {
ctx.drawImage(image, css.left, css.top, width, height);
}
// Draw border if specified
if (css.border) {
drawBorder(ctx, css.left, css.top, width, height, css.border, css.borderRadius);
}
// Restore canvas state (resets opacity)
ctx.restore();
};
/**
* Draws a border around an element
*/
const drawBorder = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
border: { width: number; style: string; color: string },
borderRadius?: number
): void => {
ctx.strokeStyle = border.color;
ctx.lineWidth = border.width;
// Handle double border specially
if (border.style === 'double') {
const outerWidth = border.width;
const innerWidth = Math.max(1, Math.floor(outerWidth / 3));
const gap = Math.max(1, Math.floor(outerWidth / 3));
// Draw outer border
ctx.lineWidth = outerWidth;
ctx.setLineDash([]);
if (borderRadius && borderRadius > 0) {
drawRoundedRect(ctx, x, y, width, height, borderRadius, false);
} else {
ctx.strokeRect(x, y, width, height);
}
// Draw inner border
ctx.lineWidth = innerWidth;
const innerX = x + gap;
const innerY = y + gap;
const innerWidthRect = width - 2 * gap;
const innerHeightRect = height - 2 * gap;
const innerRadius = Math.max(0, (borderRadius || 0) - gap);
if (innerRadius > 0) {
drawRoundedRect(ctx, innerX, innerY, innerWidthRect, innerHeightRect, innerRadius, false);
} else {
ctx.strokeRect(innerX, innerY, innerWidthRect, innerHeightRect);
}
} else {
// Handle other border styles
ctx.setLineDash(getLineDash(border.style));
if (borderRadius && borderRadius > 0) {
// Draw rounded rectangle border
drawRoundedRect(ctx, x, y, width, height, borderRadius, false);
} else {
// Draw regular rectangle border
ctx.strokeRect(x, y, width, height);
}
ctx.setLineDash([]);
}
};
/**
* Draws a rounded rectangle
*/
const drawRoundedRect = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
fill: boolean
): void => {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
if (fill) {
ctx.fill();
} else {
ctx.stroke();
}
};
/**
* Draws an image with rounded corners
*/
const drawRoundedImage = (
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
x: number,
y: number,
width: number,
height: number,
radius: number
): void => {
ctx.save();
// Create clipping path first
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.clip();
// Draw the image
ctx.drawImage(image, x, y, width, height);
ctx.restore();
};
/**
* Gets line dash pattern for border styles
*/
const getLineDash = (style: string): number[] => {
switch (style) {
case 'dashed':
return [5, 5];
case 'dotted':
return [2, 2];
case 'double':
return [0]; // Double border would need special handling
default:
return []; // solid
}
};
/**
* Applies scaling to CSS properties
*/
const applyScalingToCSS = (css: Record<string, string>, scaleFactor: number): Record<string, string> => {
if (scaleFactor === 1) {
return css;
}
const scaledCss: Record<string, string> = { ...css };
const propertiesToScale = [
'left',
'top',
'width',
'height',
'font-size',
'border-width',
'border-radius'
];
for (const property of propertiesToScale) {
if (scaledCss[property]) {
const value = scaledCss[property];
const pixelMatch = value.match(/^(-?\d+(?:\.\d+)?)px$/);
if (pixelMatch) {
const numericValue = parseFloat(pixelMatch[1]);
const scaledValue = numericValue * scaleFactor;
scaledCss[property] = `${scaledValue}px`;
}
}
}
// Handle border property scaling
if (scaledCss.border) {
scaledCss.border = scaleBorderProperty(scaledCss.border, scaleFactor);
}
return scaledCss;
};
/**
* Scales border property values
*/
const scaleBorderProperty = (borderValue: string, scaleFactor: number): string => {
const parts = borderValue.trim().split(/\s+/);
if (parts.length >= 1) {
const widthMatch = parts[0].match(/^(-?\d+(?:\.\d+)?)px$/);
if (widthMatch) {
const numericWidth = parseFloat(widthMatch[1]);
const scaledWidth = numericWidth * scaleFactor;
parts[0] = `${scaledWidth}px`;
}
}
return parts.join(' ');
};