131 lines
8 KiB
TypeScript
131 lines
8 KiB
TypeScript
// Palette backgrounds, photo backgrounds and the contrast guard. Pure (no fs, no browser): config validation, render
|
|
// (job.json) and `shots check` share it.
|
|
// bg: ['#a', '#b', '#c'] | ['#a', '#b'] gradient stops (each template keeps its own angle)
|
|
// '#15171C' | 'rgb(...)' | 'navy' one colour, flat
|
|
// 'linear-gradient(...)' any CSS gradient, passed through as is
|
|
// image: 'store/art/x.png', overlay: 0.45 photo under a dark overlay, cropped to cover (palette or shots.feature.background)
|
|
import type { Palette } from '../lib/config';
|
|
|
|
const COLOUR_RE = /^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|(rgb|hsl)a?\([^()]*\)|[a-z]{3,30})$/i;
|
|
const GRADIENT_RE = /^(repeating-)?(linear|radial|conic)-gradient\(.*\)$/is;
|
|
export const isColour = (s: unknown): s is string => typeof s === 'string' && COLOUR_RE.test(s.trim()) && !/^(none|inherit|initial|unset)$/i.test(s.trim());
|
|
export const isGradient = (s: unknown): s is string => typeof s === 'string' && GRADIENT_RE.test(s.trim());
|
|
|
|
export const BG_ERROR = 'palette.bg must be a colour, a gradient or an array of 2-3 colours';
|
|
export const DEFAULT_OVERLAY = 0.45;
|
|
|
|
/** Problem with a palette's bg (null = fine). */
|
|
export function bgIssue(bg: unknown): string | null {
|
|
if (isColour(bg) || isGradient(bg)) return null;
|
|
if (Array.isArray(bg) && bg.length >= 2 && bg.length <= 3 && bg.every(isColour)) return null;
|
|
return BG_ERROR;
|
|
}
|
|
|
|
const overlayOk = (o: unknown): boolean => o === undefined || (typeof o === 'number' && o >= 0 && o <= 0.9);
|
|
const imageOk = (i: unknown): boolean => typeof i === 'string' && /\.(png|jpe?g|webp)$/i.test(i.trim());
|
|
|
|
/** Config validation of one palette (a named palette string is checked when it is resolved). */
|
|
export function paletteIssues(p: unknown, key: string): string[] {
|
|
if (typeof p === 'string') return [];
|
|
if (!p || typeof p !== 'object') return [`${key}: a palette name or { bg, glow, ink, shadow }`];
|
|
const o = p as Partial<Palette>, e: string[] = [];
|
|
const bi = bgIssue(o.bg);
|
|
if (bi) e.push(`${key}.bg: ${bi} (got ${JSON.stringify(o.bg)})`);
|
|
for (const k of ['glow', 'ink', 'shadow'] as const) if (!isColour(o[k])) e.push(`${key}.${k}: a colour`);
|
|
if (o.image !== undefined && !imageOk(o.image)) e.push(`${key}.image: a PNG, JPEG or WebP path (relative to the config)`);
|
|
if (!overlayOk(o.overlay)) e.push(`${key}.overlay: a number from 0 to 0.9 (dark overlay over the image)`);
|
|
return e;
|
|
}
|
|
|
|
export function featureBackgroundIssues(b: unknown): string[] {
|
|
if (b === undefined) return [];
|
|
const o = typeof b === 'string' ? { image: b } : b as { image?: unknown; overlay?: unknown } | null;
|
|
if (!o || typeof o !== 'object' || !imageOk(o.image)) return ['shots.feature.background: a PNG, JPEG or WebP path or { image, overlay } (relative to the config)'];
|
|
return overlayOk(o.overlay) ? [] : ['shots.feature.background.overlay: a number from 0 to 0.9'];
|
|
}
|
|
|
|
/** The photo background in effect: shots.feature.background (feature graphic only), else the palette's image. */
|
|
export function photoBackground(p: Palette, featureBg?: string | { image: string; overlay?: number }): { image: string; overlay: number } | null {
|
|
const f = typeof featureBg === 'string' ? { image: featureBg } : featureBg;
|
|
const image = f?.image ?? p.image;
|
|
if (!image) return null;
|
|
return { image, overlay: f?.overlay ?? p.overlay ?? DEFAULT_OVERLAY };
|
|
}
|
|
|
|
/** CSS background for a palette bg at a template's angle (stops at 0 %, mid %, 100 %). */
|
|
export function bgCss(bg: Palette['bg'], angle = 180, mid = 55): string {
|
|
if (typeof bg === 'string') return bg.trim();
|
|
if (bg.length === 2) return bg[0] === bg[1] ? bg[0] : `linear-gradient(${angle}deg, ${bg[0]} 0%, ${bg[1]} 100%)`;
|
|
if (bg[0] === bg[1] && bg[1] === bg[2]) return bg[0];
|
|
return `linear-gradient(${angle}deg, ${bg[0]} 0%, ${bg[1]} ${mid}%, ${bg[2]} 100%)`;
|
|
}
|
|
|
|
/** A photo under a flat dark overlay, cropped to cover (CSS cover = coverCrop below). */
|
|
export function photoCss(url: string, overlay: number): string {
|
|
return `linear-gradient(rgba(0,0,0,${overlay}), rgba(0,0,0,${overlay})), url('${url}') center / cover no-repeat`;
|
|
}
|
|
|
|
/** One colour that stands for the background (the middle stop, the colour, the first colour of a gradient), for
|
|
* light / dark decisions in the templates. A photo counts as dark from an overlay of 0.35 up. */
|
|
export function bgTone(bg: Palette['bg']): string {
|
|
if (Array.isArray(bg)) return bg[Math.floor(bg.length / 2)]!;
|
|
const m = /#[0-9a-f]{3,8}\b|(rgb|hsl)a?\([^()]*\)/i.exec(bg);
|
|
return m ? m[0] : bg;
|
|
}
|
|
|
|
/** Source rectangle a cover crop takes from a srcW x srcH image for an outW x outH box (centred), and the scale
|
|
* (> 1 = the image is upscaled: too small for the output). */
|
|
export function coverCrop(srcW: number, srcH: number, outW: number, outH: number): { x: number; y: number; w: number; h: number; scale: number } {
|
|
const scale = Math.max(outW / srcW, outH / srcH);
|
|
const w = outW / scale, h = outH / scale;
|
|
return { x: (srcW - w) / 2, y: (srcH - h) / 2, w, h, scale };
|
|
}
|
|
|
|
/** Problems of a photo background for one output size: missing file, smaller than the output (upscaled = blurry). */
|
|
export function photoIssues(img: { w: number; h: number } | null, file: string, outW: number, outH: number): { level: 'error' | 'warn'; msg: string }[] {
|
|
if (!img) return [{ level: 'error', msg: `background image ${file} not found or not a PNG / JPEG / WebP` }];
|
|
const c = coverCrop(img.w, img.h, outW, outH);
|
|
return c.scale > 1.001 ? [{ level: 'warn', msg: `background image ${file} is ${img.w}x${img.h}, smaller than the ${outW}x${outH} output (upscaled ${c.scale.toFixed(2)}x, blurry): use one at least ${outW}x${outH}` }] : [];
|
|
}
|
|
|
|
// ---- contrast -------------------------------------------------------------------------------------------------
|
|
|
|
export type RGB = [number, number, number];
|
|
|
|
/** #rgb / #rrggbb / rgb(a)() to RGB (alpha ignored); null for anything else. */
|
|
export function parseColour(s: string): RGB | null {
|
|
const t = s.trim();
|
|
let m = /^#([0-9a-f]{3})$/i.exec(t);
|
|
if (m) return [...m[1]!].map(c => parseInt(c + c, 16)) as RGB;
|
|
m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(t);
|
|
if (m) { const n = parseInt(m[1]!, 16); return [n >> 16, (n >> 8) & 255, n & 255]; }
|
|
m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(t);
|
|
if (m) return [+m[1]!, +m[2]!, +m[3]!];
|
|
return null;
|
|
}
|
|
|
|
const lin = (c: number): number => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
|
|
export const luminance = ([r, g, b]: RGB): number => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
/** WCAG contrast ratio, 1..21. */
|
|
export function contrastRatio(a: RGB, b: RGB): number {
|
|
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [number, number];
|
|
return (x + 0.05) / (y + 0.05);
|
|
}
|
|
|
|
/** One title element's contrast as `shots render` records it: ink colour, the background samples behind the text
|
|
* (text hidden), and the worst ratio. */
|
|
export interface ContrastSample { el: string; ink: string; worst: RGB; ratio: number }
|
|
|
|
/** Worst contrast of an ink colour over background pixel samples. */
|
|
export function worstContrast(ink: RGB, samples: RGB[]): { worst: RGB; ratio: number } {
|
|
let best = { worst: samples[0] ?? ink, ratio: 21 };
|
|
for (const s of samples) { const r = contrastRatio(ink, s); if (r < best.ratio) best = { worst: s, ratio: r }; }
|
|
return best;
|
|
}
|
|
|
|
/** Large headline text: WCAG AA large text asks 3:1; under 2:1 it is close to invisible (white on white). */
|
|
export const CONTRAST_MIN = 3;
|
|
export function contrastIssues(samples: ContrastSample[] | undefined): { level: 'warn'; code: 'contrast'; msg: string }[] {
|
|
return (samples ?? []).filter(s => s.ratio < CONTRAST_MIN).map(s => ({ level: 'warn' as const, code: 'contrast' as const,
|
|
msg: `${s.el} ink ${s.ink} has low contrast against the background behind it (${s.ratio.toFixed(2)}:1, rgb(${s.worst.join(',')}); want >= ${CONTRAST_MIN}:1)${s.ratio < 1.5 ? ': the text is close to invisible' : ''}. Check palette.bg / ink (a string bg needs a colour or a CSS gradient) or raise the photo overlay` }));
|
|
}
|