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

233 lines (210 loc) 6.76 kB
import { CssValidationError } from './errors.js'; import type { ParsedCSS } from './types.js'; /** * Supported CSS properties for the canvas renderer */ const SUPPORTED_PROPERTIES = new Set([ 'position', 'left', 'top', 'width', 'height', 'font-size', 'font-family', 'color', 'background-color', 'opacity', 'text-align', 'vertical-align', 'line-height', 'padding', 'border', 'border-radius', 'z-index' ]); /** * Validates CSS properties and returns parsed values */ export const validateAndParseCSS = (css: Record<string, string>, _elementType: 'text' | 'image'): ParsedCSS => { const parsed: ParsedCSS = { zIndex: 0 }; for (const [property, value] of Object.entries(css)) { // Check if property is supported if (!SUPPORTED_PROPERTIES.has(property)) { console.warn(`Unsupported CSS property '${property}' will be ignored`); continue; } // Skip CSS validation for now - csstree-validator is too strict // TODO: Implement custom validation or use a more lenient validator // Parse and validate specific properties try { switch (property) { case 'position': if (value !== 'absolute') { console.warn(`Only 'position: absolute' is supported, ignoring 'position: ${value}'`); } else { parsed.position = 'absolute'; } break; case 'left': case 'top': case 'width': case 'height': case 'font-size': case 'line-height': case 'border-radius': const numValue = parsePixelValue(value); if (numValue < 0) { throw new CssValidationError( `Negative values not allowed for '${property}': ${value}`, property, value ); } if (property === 'left') parsed.left = numValue; else if (property === 'top') parsed.top = numValue; else if (property === 'width') parsed.width = numValue; else if (property === 'height') parsed.height = numValue; else if (property === 'font-size') parsed.fontSize = numValue; else if (property === 'line-height') parsed.lineHeight = numValue; else if (property === 'border-radius') parsed.borderRadius = numValue; break; case 'font-family': parsed.fontFamily = value; break; case 'color': case 'background-color': if (!isValidColor(value)) { throw new CssValidationError( `Invalid color value for '${property}': ${value}`, property, value ); } if (property === 'color') parsed.color = value; else parsed.backgroundColor = value; break; case 'opacity': const opacity = parseFloat(value); if (isNaN(opacity) || opacity < 0 || opacity > 1) { throw new CssValidationError( `Invalid opacity value: ${value}. Must be between 0 and 1`, property, value ); } parsed.opacity = opacity; break; case 'text-align': if (!['left', 'center', 'right'].includes(value)) { throw new CssValidationError( `Invalid text-align value: ${value}. Must be 'left', 'center', or 'right'`, property, value ); } parsed.textAlign = value as 'left' | 'center' | 'right'; break; case 'vertical-align': if (!['top', 'middle', 'center', 'bottom'].includes(value)) { throw new CssValidationError( `Invalid vertical-align value: ${value}. Must be 'top', 'middle', 'center', or 'bottom'`, property, value ); } parsed.verticalAlign = value as 'top' | 'middle' | 'center' | 'bottom'; break; case 'padding': const padding = parseInt(value, 10); if (isNaN(padding) || padding < 0) { throw new CssValidationError( `Invalid padding value: ${value}. Must be a non-negative number`, property, value ); } parsed.padding = padding; break; case 'border': parsed.border = parseBorder(value); break; case 'z-index': const zIndex = parseInt(value, 10); if (isNaN(zIndex)) { throw new CssValidationError( `Invalid z-index value: ${value}. Must be a number`, property, value ); } parsed.zIndex = zIndex; break; } } catch (error) { if (error instanceof CssValidationError) { throw error; } throw new CssValidationError( `Error parsing CSS property '${property}': ${error instanceof Error ? error.message : 'Unknown error'}`, property, value ); } } return parsed; }; /** * Parses pixel values from CSS strings */ const parsePixelValue = (value: string): number => { const match = value.match(/^(-?\d+(?:\.\d+)?)px$/); if (!match) { throw new CssValidationError( `Invalid pixel value: ${value}. Must be a number followed by 'px'`, 'pixel-value', value ); } return parseFloat(match[1]!); }; /** * Validates color values */ const isValidColor = (color: string): boolean => { // Basic color validation - can be extended for more complex color formats const colorRegex = /^(#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|hsl\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*\)|hsla\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*,\s*[\d.]+\s*\)|[a-zA-Z]+)$/; return colorRegex.test(color); }; /** * Parses border CSS value */ const parseBorder = (value: string): { width: number; style: string; color: string } => { const parts = value.trim().split(/\s+/); if (parts.length < 3) { throw new CssValidationError( `Invalid border value: ${value}. Must be in format 'width style color'`, 'border', value ); } const width = parsePixelValue(parts[0]!); const style = parts[1]!; const color = parts.slice(2).join(' '); if (!['solid', 'dashed', 'dotted', 'double'].includes(style)) { throw new CssValidationError( `Unsupported border style: ${style}. Supported styles: solid, dashed, dotted, double`, 'border', value ); } if (!isValidColor(color)) { throw new CssValidationError( `Invalid border color: ${color}`, 'border', value ); } return { width, style, color }; };