UNPKG

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
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(' '); };