UNPKG

@markgorzynski/color-utils

Version:

The only color science library with adaptive visual perception modeling and accessibility-driven optimization.

252 lines (228 loc) 7.62 kB
/** * @module gamut * @description Gamut validation and management functions following industry standards. * * DESIGN PRINCIPLES: * ================== * - Core conversions process out-of-range values * - Validation is opt-in via these utility functions * - Multiple strategies for different use cases * - No automatic clamping in mathematical operations * * @see RANGE_STANDARDS.md for detailed conventions */ /** * Checks if an sRGB color is within the standard [0,1] gamut. * * @param {SrgbColor} srgb - sRGB color to check * @param {number} [epsilon=1e-10] - Tolerance for floating point comparison * @returns {boolean} True if all components are within [0,1] * * @example * isSrgbInGamut({r: 0.5, g: 0.3, b: 0.8}) // true * isSrgbInGamut({r: 1.1, g: 0.5, b: -0.1}) // false * isSrgbInGamut({r: 1.0000000001, g: 0.5, b: 0.5}, 1e-9) // true (within tolerance) */ export function isSrgbInGamut(srgb, epsilon = 1e-10) { return srgb.r >= -epsilon && srgb.r <= 1 + epsilon && srgb.g >= -epsilon && srgb.g <= 1 + epsilon && srgb.b >= -epsilon && srgb.b <= 1 + epsilon; } /** * Simple clamping of sRGB values to [0,1] range. * Fast but can shift hue for out-of-gamut colors. * * @param {SrgbColor} srgb - sRGB color to clamp * @returns {SrgbColor} Clamped color with all components in [0,1] * * @example * clampSrgb({r: 1.2, g: 0.5, b: -0.1}) // {r: 1, g: 0.5, b: 0} */ export function clampSrgb(srgb) { return { r: Math.max(0, Math.min(1, srgb.r)), g: Math.max(0, Math.min(1, srgb.g)), b: Math.max(0, Math.min(1, srgb.b)) }; } /** * Checks if a CIELAB color is within typical display gamut. * Note: Lab space can represent colors outside any display gamut. * * @param {LabColor} lab - CIELAB color to check * @returns {boolean} True if within typical ranges * * Typical ranges: * - L*: [0, 100] * - a*: [-128, 127] * - b*: [-128, 127] */ export function isLabInTypicalRange(lab) { return lab.L >= 0 && lab.L <= 100 && lab.a >= -128 && lab.a <= 127 && lab.b >= -128 && lab.b <= 127; } /** * Checks if an Oklab color is within typical display gamut. * * @param {OklabColor} oklab - Oklab color to check * @returns {boolean} True if within typical ranges * * Typical ranges: * - L: [0, 1] * - a: [-0.4, 0.4] * - b: [-0.4, 0.4] */ export function isOklabInTypicalRange(oklab) { return oklab.L >= 0 && oklab.L <= 1 && oklab.a >= -0.4 && oklab.a <= 0.4 && oklab.b >= -0.4 && oklab.b <= 0.4; } /** * Validates that an object has the required sRGB properties. * Does NOT validate ranges, only structure. * * @param {any} obj - Object to validate * @returns {boolean} True if object has numeric r, g, b properties */ export function isValidSrgbObject(obj) { return obj != null && typeof obj === 'object' && typeof obj.r === 'number' && !Number.isNaN(obj.r) && typeof obj.g === 'number' && !Number.isNaN(obj.g) && typeof obj.b === 'number' && !Number.isNaN(obj.b); } /** * Validates that an object has the required Lab properties. * Does NOT validate ranges, only structure. * * @param {any} obj - Object to validate * @returns {boolean} True if object has numeric L, a, b properties */ export function isValidLabObject(obj) { return obj != null && typeof obj === 'object' && typeof obj.L === 'number' && !Number.isNaN(obj.L) && typeof obj.a === 'number' && !Number.isNaN(obj.a) && typeof obj.b === 'number' && !Number.isNaN(obj.b); } /** * Clamps a numeric value to a range. * Helper function for custom clamping operations. * * @param {number} value - Value to clamp * @param {number} min - Minimum value * @param {number} max - Maximum value * @returns {number} Clamped value */ export function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } /** * Scales an sRGB color uniformly until it fits within gamut. * Preserves hue better than channel clamping but reduces saturation. * * @param {SrgbColor} srgb - sRGB color to scale * @returns {SrgbColor} Scaled color within [0,1] gamut * * @example * scaleToSrgbGamut({r: 1.2, g: 0.6, b: 0.3}) // Scales all channels by 1/1.2 */ export function scaleToSrgbGamut(srgb) { const maxChannel = Math.max(srgb.r, srgb.g, srgb.b); const minChannel = Math.min(srgb.r, srgb.g, srgb.b); // If already in gamut, return as-is if (maxChannel <= 1 && minChannel >= 0) { return srgb; } // For colors with negative values, we need to shift and scale if (minChannel < 0) { // Shift all channels to make minimum 0 const shift = -minChannel; const shiftedR = srgb.r + shift; const shiftedG = srgb.g + shift; const shiftedB = srgb.b + shift; const maxShifted = Math.max(shiftedR, shiftedG, shiftedB); // Scale down if needed if (maxShifted > 1) { const scale = 1 / maxShifted; return { r: shiftedR * scale, g: shiftedG * scale, b: shiftedB * scale }; } return { r: shiftedR, g: shiftedG, b: shiftedB }; } // For colors that only exceed 1, simple scaling if (maxChannel > 1) { const scale = 1 / maxChannel; return { r: srgb.r * scale, g: srgb.g * scale, b: srgb.b * scale }; } return srgb; } /** * Gets the maximum displayable chroma for a given lightness and hue. * Uses binary search to find the gamut boundary. * * @param {number} L - Lightness (0-100 for Lab, 0-1 for Oklab) * @param {number} h - Hue angle in degrees [0, 360) * @param {string} [space='oklch'] - Color space to use ('lch' or 'oklch') * @param {number} [precision=0.001] - Search precision * @returns {number} Maximum chroma that stays in sRGB gamut */ export function getMaxChroma(L, h, space = 'oklch', precision = 0.001) { // Import only if needed to avoid circular dependencies const { lchToSrgb } = require('./cielab.js'); const { oklchToSrgb } = require('./oklab.js'); const toSrgb = space === 'lch' ? lchToSrgb : oklchToSrgb; // Binary search for maximum chroma let low = 0; let high = space === 'lch' ? 150 : 0.5; // Reasonable upper bounds while (high - low > precision) { const mid = (low + high) / 2; const color = { L, C: mid, h }; const srgb = toSrgb(color); if (isSrgbInGamut(srgb)) { low = mid; } else { high = mid; } } return low; } /** * Information about gamut check results. * @typedef {Object} GamutInfo * @property {boolean} inGamut - Whether color is in gamut * @property {number[]} channels - Array of channel values * @property {number} maxExcess - Maximum amount any channel exceeds bounds * @property {number} minDeficit - Maximum amount any channel is below bounds */ /** * Detailed gamut check with information about how far out of gamut. * * @param {SrgbColor} srgb - sRGB color to check * @returns {GamutInfo} Detailed gamut information * * @example * const info = getSrgbGamutInfo({r: 1.2, g: 0.5, b: -0.1}); * // { inGamut: false, channels: [1.2, 0.5, -0.1], maxExcess: 0.2, minDeficit: 0.1 } */ export function getSrgbGamutInfo(srgb) { const channels = [srgb.r, srgb.g, srgb.b]; const maxExcess = Math.max(0, ...channels.map(c => c - 1)); const minDeficit = Math.max(0, ...channels.map(c => -c)); return { inGamut: maxExcess === 0 && minDeficit === 0, channels, maxExcess, minDeficit }; } // Re-export the advanced gamut mapping from existing module export { gamutMapOklch, gamutMapSrgb, isInGamut } from './gamut-mapping.js';