94 lines
5.5 KiB
TypeScript
94 lines
5.5 KiB
TypeScript
/** Internal contrast arithmetic. Results model opaque legacy sRGB colors;
|
|
* they are not pixel measurements or a claim about every rendered instance. */
|
|
export type Rgb = readonly [number, number, number];
|
|
export interface VerticalGradient { stops: readonly { color: Rgb; offset: number }[] }
|
|
export interface ContrastModel {
|
|
status: 'MODEL' | 'NOT VERIFIED';
|
|
reason?: string;
|
|
stopMinimum?: number;
|
|
fullGradientMinimum?: number;
|
|
textRegionMinimum?: number;
|
|
}
|
|
|
|
export function parseOpaqueColor(value: string): Rgb | null {
|
|
const text = value.trim();
|
|
if (/^#[\da-f]{6}$/i.test(text)) return [1, 3, 5].map(index => parseInt(text.slice(index, index + 2), 16)) as unknown as Rgb;
|
|
if (/^#[\da-f]{3}$/i.test(text)) return [...text.slice(1)].map(part => parseInt(part + part, 16)) as unknown as Rgb;
|
|
const match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(text);
|
|
if (!match || (match[4] !== undefined && Number(match[4]) !== 1)) return null;
|
|
const channels = match.slice(1, 4).map(Number);
|
|
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels as unknown as Rgb : null;
|
|
}
|
|
|
|
export function relativeLuminance(color: Rgb): number {
|
|
const channels = color.map(channel => {
|
|
const encoded = channel / 255;
|
|
return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
|
|
}
|
|
|
|
export function contrastRatio(foreground: Rgb, background: Rgb): number {
|
|
const first = relativeLuminance(foreground), second = relativeLuminance(background);
|
|
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
|
|
}
|
|
|
|
/** Restricted intentionally: no alpha, images, multiple layers, omitted stops,
|
|
* other angles, color spaces, relative units, or repeating gradients.
|
|
* Computed CSS may omit the default 180deg angle; that is equivalent. */
|
|
export function parseVerticalGradient(value: string): VerticalGradient | null {
|
|
const outer = /^linear-gradient\(\s*(?:180deg\s*,\s*)?(.*)\)$/i.exec(value.trim());
|
|
if (!outer) return null;
|
|
const parts = outer[1]!.split(/,(?![^()]*\))/);
|
|
const stops: { color: Rgb; offset: number }[] = [];
|
|
for (const part of parts) {
|
|
const match = /^\s*(#[\da-f]+|rgba?\([^()]*\))\s+([\d.]+)%\s*$/i.exec(part);
|
|
const color = match && parseOpaqueColor(match[1]!);
|
|
const offset = match ? Number(match[2]) / 100 : NaN;
|
|
if (!color || !Number.isFinite(offset) || offset < 0 || offset > 1 || (stops.length > 0 && offset <= stops.at(-1)!.offset)) return null;
|
|
stops.push({ color, offset });
|
|
}
|
|
return stops.length >= 2 && stops[0]!.offset === 0 && stops.at(-1)!.offset === 1 ? { stops } : null;
|
|
}
|
|
|
|
function gradientColor(gradient: VerticalGradient, offset: number): Rgb {
|
|
let index = 1;
|
|
while (index < gradient.stops.length - 1 && offset > gradient.stops[index]!.offset) index++;
|
|
const before = gradient.stops[index - 1]!, after = gradient.stops[index]!;
|
|
const amount = (offset - before.offset) / (after.offset - before.offset);
|
|
return before.color.map((channel, channelIndex) => channel + (after.color[channelIndex]! - channel) * amount) as unknown as Rgb;
|
|
}
|
|
|
|
export function textContrastThreshold(fontSizePx: number, fontWeight: number): 3 | 4.5 {
|
|
return fontSizePx >= 24 || (fontWeight >= 700 && fontSizePx >= 14 * 96 / 72) ? 3 : 4.5;
|
|
}
|
|
|
|
/** Ranges are actual text line rectangles in CSS px relative to the gradient
|
|
* painting box, not the padded heading rectangle. The whole-gradient sample
|
|
* and stop sample are reported separately; neither substitutes for text area. */
|
|
export function evaluateGradientContrast(input: {
|
|
foreground: string;
|
|
backgroundImage: string;
|
|
ranges: readonly { top: number; bottom: number }[];
|
|
backgroundHeight: number;
|
|
sampleStep?: number;
|
|
}): ContrastModel {
|
|
const foreground = parseOpaqueColor(input.foreground), gradient = parseVerticalGradient(input.backgroundImage);
|
|
if (!foreground || !gradient) return { status: 'NOT VERIFIED', reason: '지원하지 않는 색상/gradient: 불투명 sRGB 180deg 및 명시적 % stop만 지원' };
|
|
const height = input.backgroundHeight, step = input.sampleStep ?? 1;
|
|
if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(step) || step <= 0 || height / step > 10000 || input.ranges.length === 0 || input.ranges.some(range => !Number.isFinite(range.top) || !Number.isFinite(range.bottom) || range.top < 0 || range.bottom > height || range.bottom <= range.top)) {
|
|
return { status: 'NOT VERIFIED', reason: '텍스트 범위/배경 높이/샘플 간격 확인 불가' };
|
|
}
|
|
const ratioAt = (offset: number) => contrastRatio(foreground, gradientColor(gradient, offset));
|
|
const stopMinimum = Math.min(...gradient.stops.map(stop => contrastRatio(foreground, stop.color)));
|
|
let fullGradientMinimum = stopMinimum;
|
|
for (let index = 0; index <= 256; index++) fullGradientMinimum = Math.min(fullGradientMinimum, ratioAt(index / 256));
|
|
let textRegionMinimum = Infinity;
|
|
for (const range of input.ranges) {
|
|
textRegionMinimum = Math.min(textRegionMinimum, ratioAt(range.top / height), ratioAt(range.bottom / height));
|
|
for (let y = range.top + step; y < range.bottom; y += step) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(y / height));
|
|
for (const stop of gradient.stops) if (stop.offset * height >= range.top && stop.offset * height <= range.bottom) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(stop.offset));
|
|
}
|
|
return { status: 'MODEL', stopMinimum, fullGradientMinimum, textRegionMinimum };
|
|
}
|