/** 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 }; }