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
text/typescript
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 };
};