a11y: add runtime contrast validation, justify override, line-height floor
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { StyleOption, ElementStyle, FontConfig, ColorPalette } from '../types';
|
import { StyleOption, ElementStyle, FontConfig, ColorPalette } from '../types';
|
||||||
|
import { ensureContrast, isLargeText } from '../utils/contrastUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a color reference (e.g., 'accent', 'background') or returns the color directly
|
* Resolves a color reference (e.g., 'accent', 'background') or returns the color directly
|
||||||
@@ -41,17 +42,33 @@ function elementToCss(
|
|||||||
|
|
||||||
if (style.font) css.push(`font-family: ${resolveFont(style.font, fonts)}`);
|
if (style.font) css.push(`font-family: ${resolveFont(style.font, fonts)}`);
|
||||||
if (style.size) css.push(`font-size: ${style.size}pt`);
|
if (style.size) css.push(`font-size: ${style.size}pt`);
|
||||||
if (style.color) css.push(`color: ${resolveColor(style.color, palette)}`);
|
if (style.color) {
|
||||||
|
let resolvedColor = resolveColor(style.color, palette);
|
||||||
|
const bgColor = resolveColor('background', palette);
|
||||||
|
const sizePt = style.size || 12;
|
||||||
|
const isBold = style.bold || false;
|
||||||
|
const minRatio = isLargeText(sizePt, isBold) ? 4.5 : 7;
|
||||||
|
resolvedColor = ensureContrast(resolvedColor, bgColor, minRatio);
|
||||||
|
css.push(`color: ${resolvedColor}`);
|
||||||
|
}
|
||||||
if (style.background) css.push(`background: ${resolveColor(style.background, palette)}`);
|
if (style.background) css.push(`background: ${resolveColor(style.background, palette)}`);
|
||||||
if (style.bold) css.push('font-weight: 700');
|
if (style.bold) css.push('font-weight: 700');
|
||||||
if (style.italic) css.push('font-style: italic');
|
if (style.italic) css.push('font-style: italic');
|
||||||
if (style.underline) css.push('text-decoration: underline');
|
if (style.underline) css.push('text-decoration: underline');
|
||||||
if (style.allCaps) css.push('text-transform: uppercase');
|
if (style.allCaps) css.push('text-transform: uppercase');
|
||||||
if (style.align) css.push(`text-align: ${style.align === 'both' ? 'justify' : style.align}`);
|
if (style.align) {
|
||||||
|
const align = style.align === 'both' || style.align === 'justify' ? 'left' : style.align;
|
||||||
|
css.push(`text-align: ${align}`);
|
||||||
|
}
|
||||||
if (style.spacing) {
|
if (style.spacing) {
|
||||||
if (style.spacing.before) css.push(`margin-top: ${style.spacing.before}pt`);
|
if (style.spacing.before) css.push(`margin-top: ${style.spacing.before}pt`);
|
||||||
if (style.spacing.after) css.push(`margin-bottom: ${style.spacing.after}pt`);
|
if (style.spacing.after) css.push(`margin-bottom: ${style.spacing.after}pt`);
|
||||||
if (style.spacing.line) css.push(`line-height: ${style.spacing.line}`);
|
if (style.spacing.line) {
|
||||||
|
const isHeading = selector.includes(' h1') || selector.includes(' h2') || selector.includes(' h3') || selector.includes(' h4') || selector.includes(' h5') || selector.includes(' h6');
|
||||||
|
const minLineHeight = isHeading ? 1.0 : 1.5;
|
||||||
|
const lineHeight = Math.max(style.spacing.line, minLineHeight);
|
||||||
|
css.push(`line-height: ${lineHeight}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (style.indent) css.push(`text-indent: ${style.indent}pt`);
|
if (style.indent) css.push(`text-indent: ${style.indent}pt`);
|
||||||
if (style.padding) css.push(`padding: ${style.padding}pt`);
|
if (style.padding) css.push(`padding: ${style.padding}pt`);
|
||||||
@@ -162,9 +179,13 @@ function generateCssFromWordConfig(template: StyleOption): string {
|
|||||||
if (cfg.body?.font) pageStyles.push(`font-family: ${cfg.body.font}`);
|
if (cfg.body?.font) pageStyles.push(`font-family: ${cfg.body.font}`);
|
||||||
if (cfg.body?.size) pageStyles.push(`font-size: ${cfg.body.size}pt`);
|
if (cfg.body?.size) pageStyles.push(`font-size: ${cfg.body.size}pt`);
|
||||||
if (cfg.body?.color) pageStyles.push(`color: #${cfg.body.color}`);
|
if (cfg.body?.color) pageStyles.push(`color: #${cfg.body.color}`);
|
||||||
if (cfg.body?.align) pageStyles.push(`text-align: ${cfg.body.align === 'both' ? 'justify' : cfg.body.align}`);
|
if (cfg.body?.align) {
|
||||||
|
const align = cfg.body.align === 'both' || cfg.body.align === 'justify' ? 'left' : cfg.body.align;
|
||||||
|
pageStyles.push(`text-align: ${align}`);
|
||||||
|
}
|
||||||
if (cfg.body?.spacing?.line) {
|
if (cfg.body?.spacing?.line) {
|
||||||
pageStyles.push(`line-height: ${cfg.body.spacing.line / 240}`);
|
const lineHeight = Math.max(cfg.body.spacing.line / 240, 1.5);
|
||||||
|
pageStyles.push(`line-height: ${lineHeight}`);
|
||||||
}
|
}
|
||||||
// White background for the document page
|
// White background for the document page
|
||||||
pageStyles.push('background: #ffffff');
|
pageStyles.push('background: #ffffff');
|
||||||
@@ -213,9 +234,13 @@ function generateCssFromWordConfig(template: StyleOption): string {
|
|||||||
if (cfg.body.font) pStyles.push(`font-family: ${cfg.body.font}`);
|
if (cfg.body.font) pStyles.push(`font-family: ${cfg.body.font}`);
|
||||||
if (cfg.body.size) pStyles.push(`font-size: ${cfg.body.size}pt`);
|
if (cfg.body.size) pStyles.push(`font-size: ${cfg.body.size}pt`);
|
||||||
if (cfg.body.color) pStyles.push(`color: #${cfg.body.color}`);
|
if (cfg.body.color) pStyles.push(`color: #${cfg.body.color}`);
|
||||||
if (cfg.body.align) pStyles.push(`text-align: ${cfg.body.align === 'both' ? 'justify' : cfg.body.align}`);
|
if (cfg.body.align) {
|
||||||
|
const align = cfg.body.align === 'both' || cfg.body.align === 'justify' ? 'left' : cfg.body.align;
|
||||||
|
pStyles.push(`text-align: ${align}`);
|
||||||
|
}
|
||||||
if (cfg.body.spacing?.line) {
|
if (cfg.body.spacing?.line) {
|
||||||
pStyles.push(`line-height: ${cfg.body.spacing.line / 240}`);
|
const lineHeight = Math.max(cfg.body.spacing.line / 240, 1.5);
|
||||||
|
pStyles.push(`line-height: ${lineHeight}`);
|
||||||
}
|
}
|
||||||
if (cfg.body.spacing?.after) {
|
if (cfg.body.spacing?.after) {
|
||||||
pStyles.push(`margin-bottom: ${cfg.body.spacing.after / 20}pt`);
|
pStyles.push(`margin-bottom: ${cfg.body.spacing.after / 20}pt`);
|
||||||
|
|||||||
71
src/utils/contrastUtils.ts
Normal file
71
src/utils/contrastUtils.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* WCAG 2.2 contrast ratio utilities for runtime color validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const clean = hex.replace('#', '');
|
||||||
|
const full = clean.length === 3
|
||||||
|
? clean.split('').map(c => c + c).join('')
|
||||||
|
: clean;
|
||||||
|
const num = parseInt(full, 16);
|
||||||
|
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeLuminance([r, g, b]: [number, number, number]): number {
|
||||||
|
const [rs, gs, bs] = [r, g, b].map(c => {
|
||||||
|
const s = c / 255;
|
||||||
|
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||||
|
});
|
||||||
|
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastRatio(color1: string, color2: string): number {
|
||||||
|
const l1 = relativeLuminance(hexToRgb(color1));
|
||||||
|
const l2 = relativeLuminance(hexToRgb(color2));
|
||||||
|
const lighter = Math.max(l1, l2);
|
||||||
|
const darker = Math.min(l1, l2);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjusts foreground color to meet minimum contrast ratio against background.
|
||||||
|
* Lightens or darkens the foreground as needed.
|
||||||
|
*/
|
||||||
|
export function ensureContrast(fg: string, bg: string, minRatio: number): string {
|
||||||
|
if (contrastRatio(fg, bg) >= minRatio) return fg;
|
||||||
|
|
||||||
|
const bgLum = relativeLuminance(hexToRgb(bg));
|
||||||
|
const [r, g, b] = hexToRgb(fg);
|
||||||
|
|
||||||
|
// Determine direction: lighten if bg is dark, darken if bg is light
|
||||||
|
const lighten = bgLum < 0.5;
|
||||||
|
|
||||||
|
for (let step = 1; step <= 50; step++) {
|
||||||
|
const factor = step * 0.02;
|
||||||
|
let nr: number, ng: number, nb: number;
|
||||||
|
|
||||||
|
if (lighten) {
|
||||||
|
nr = Math.min(255, Math.round(r + (255 - r) * factor));
|
||||||
|
ng = Math.min(255, Math.round(g + (255 - g) * factor));
|
||||||
|
nb = Math.min(255, Math.round(b + (255 - b) * factor));
|
||||||
|
} else {
|
||||||
|
nr = Math.max(0, Math.round(r * (1 - factor)));
|
||||||
|
ng = Math.max(0, Math.round(g * (1 - factor)));
|
||||||
|
nb = Math.max(0, Math.round(b * (1 - factor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjusted = '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
|
||||||
|
if (contrastRatio(adjusted, bg) >= minRatio) return adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return white or black
|
||||||
|
return lighten ? '#FFFFFF' : '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if text at given size/weight qualifies as "large text" per WCAG.
|
||||||
|
* Large text = 18pt+ (24px+) or 14pt+ bold (18.66px+)
|
||||||
|
*/
|
||||||
|
export function isLargeText(sizePt: number, bold: boolean): boolean {
|
||||||
|
return sizePt >= 18 || (bold && sizePt >= 14);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user