export const SHARE_HALFTONE_DOT_RULE = { step: 5, minRadius: 0.1, maxRadiusRatio: 0.46, paperThreshold: 0, sizeCurve: 0.8, alphaCurve: 0.52, minAlpha: 0.88, maxAlpha: 1, luminanceLow: 0.03, luminanceHigh: 0.915, contrast: 2.09, baseInk: 0.01, inkGamma: 0.92, postCropZoom: 1.05, postCropOffsetX: 0, postCropOffsetY: 0, radiusNoise: 0.08, radiusNoiseFloor: 0.2, accentWeight: 0.41, departureWeight: 0.55, toneWeight: 0.64, hueDepartureWeight: 0.39, saturationDepartureWeight: 0.09, darkDepartureWeight: 0.39, nearWhiteGate: 0.08, paperLightnessGate: 0.885, baseLightnessLift: 0.03, dotLightnessLift: 0.08, baseSaturationScale: 0.84, dotSaturationScale: 1.18, posterWeight: 0.82, posterThreshold: 0.46, posterSoftness: 0.15, edgeWeight: 0.43, edgeRadius: 3, weaveBias: 0.08, toneBias: 1.3, hueBias: 0.34, minInterleave: 0.12, maxInterleave: 0.78, }; export function shareHalftoneSamplePoint(x, y, width, height, sampleWidth, sampleHeight, rule = SHARE_HALFTONE_DOT_RULE) { return { sx: clamp(Math.floor((x / width) * sampleWidth), 0, sampleWidth - 1), sy: clamp(Math.floor((y / height) * sampleHeight), 0, sampleHeight - 1), }; } export function shareHalftoneCropRect(width, height, rule = SHARE_HALFTONE_DOT_RULE) { const zoom = Math.max(1, rule.postCropZoom || 1); const sw = width / zoom; const sh = height / zoom; const centerX = (width - sw) / 2; const centerY = (height - sh) / 2; const sx = clamp(centerX + centerX * (rule.postCropOffsetX || 0), 0, width - sw); const sy = clamp(centerY + centerY * (rule.postCropOffsetY || 0), 0, height - sh); return { sx, sy, sw, sh }; } export function shareHalftoneDotRadius(radiusTone, rule = SHARE_HALFTONE_DOT_RULE) { const safeTone = printableTone(radiusTone, rule); if (safeTone === 0) return 0; const maxRadius = rule.step * rule.maxRadiusRatio; const radiusRange = Math.max(0, maxRadius - rule.minRadius); return rule.minRadius + Math.pow(safeTone, rule.sizeCurve) * radiusRange; } export function shareHalftoneDotAlpha(radiusTone, rule = SHARE_HALFTONE_DOT_RULE) { const safeTone = printableTone(radiusTone, rule); if (safeTone === 0) return 0; return 1; } export function shareHalftoneNoisyTone(radiusTone, x, y, rule = SHARE_HALFTONE_DOT_RULE) { const safeTone = clamp(radiusTone, 0, 1); const midtoneWeight = 1 - Math.abs(safeTone - 0.5) * 2; const noiseWeight = rule.radiusNoiseFloor + (1 - rule.radiusNoiseFloor) * Math.max(0, midtoneWeight); const noise = halftoneCellNoise(x, y) * 2 - 1; return clamp(safeTone + noise * rule.radiusNoise * noiseWeight, 0, 1); } export function shareHalftonePlateColors(primaryInk, secondaryInk, rule = SHARE_HALFTONE_DOT_RULE) { return { baseInk: normalizePlateInk(primaryInk, "base", rule), dotInk: normalizePlateInk(secondaryInk, "dot", rule), }; } export function shareHalftoneFeatureColorPair(colors) { if (!colors.length) return []; if (colors.length === 1) return [colors[0], colors[0]]; const candidates = colors.filter(isUsableFeatureColor).slice(0, 28); if (!candidates.length) return [colors[0], colors[0]]; const baseCandidates = candidates.slice(0, 6); const topWeight = candidates[0]?.weight || 1; let bestPair = [candidates[0], candidates[0]]; let bestScore = -Infinity; baseCandidates.forEach((base, baseIndex) => { candidates.forEach((accent) => { if (accent === base) return; const hueGap = hueDistance(base.h, accent.h); if (hueGap < 12) return; const accentArea = accent.weight / topWeight; if (accentArea < 0.03) return; const baseArea = base.weight / topWeight; const accentPresence = clamp(accentArea / 0.18, 0, 1); const hueSeparation = clamp((hueGap - 10) / 42, 0, 1); const substrateBonus = baseIndex === 0 ? 0.12 : 0; const lightnessContrast = Math.min(Math.abs(base.l - accent.l), 0.42) / 0.42; const score = baseArea * 0.34 + accentPresence * 0.24 + hueSeparation * 0.16 + accent.s * 0.1 + lightnessContrast * 0.08 + substrateBonus; if (score > bestScore) { bestScore = score; bestPair = [base, accent]; } }); }); return bestPair; } export function shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule = SHARE_HALFTONE_DOT_RULE) { const hsl = rgbToHsl(r, g, b); const baseHueFit = hueFit(hsl.h, baseInk.h); const dotHueFit = hueFit(hsl.h, dotInk.h); const chroma = clamp((hsl.s - 0.04) / 0.56, 0, 1); const nearWhitePaper = hsl.l > rule.paperLightnessGate ? rule.nearWhiteGate : 1; const accentSignal = clamp(0.5 + (dotHueFit - baseHueFit) * 1.55, 0, 1) * (0.55 + chroma * 0.45) * nearWhitePaper; const hueDeparture = 1 - baseHueFit; const saturationDeparture = Math.abs(hsl.s - baseInk.s); const darkDeparture = clamp((baseInk.l - hsl.l) / 0.44, 0, 1); const structureDeparture = clamp( hueDeparture * rule.hueDepartureWeight + saturationDeparture * rule.saturationDepartureWeight + darkDeparture * rule.darkDepartureWeight, 0, 1, ) * nearWhitePaper; const structureSignal = dotInk.l >= baseInk.l ? clamp(luminanceTone, 0, 1) : 1 - clamp(luminanceTone, 0, 1); return clamp( accentSignal * rule.accentWeight + structureDeparture * rule.departureWeight + structureSignal * rule.toneWeight + rule.baseInk, 0, 1, ); } export function shareHalftonePosterDotToneForPixel( pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, luminanceTone, rule = SHARE_HALFTONE_DOT_RULE, ) { const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight); const colorTone = shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule); const structureTone = dotInk.l >= baseInk.l ? luminanceTone : 1 - luminanceTone; const posterTone = smoothstep( rule.posterThreshold - rule.posterSoftness, rule.posterThreshold + rule.posterSoftness, structureTone, ); const edgeTone = shareHalftonePosterEdgeTone( pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, luminanceTone, rule, ); return clamp( colorTone * (1 - rule.posterWeight) + posterTone * rule.posterWeight + edgeTone * rule.edgeWeight, 0, 1, ); } export function halftoneInkForPixel(r, g, b, imageInk, primaryInk, secondaryInk, x, y, rule = SHARE_HALFTONE_DOT_RULE) { const hsl = rgbToHsl(r, g, b); const ink = clamp(imageInk, 0, 1); const secondaryHueFit = hueDistance(hsl.h, secondaryInk.h); const primaryHueFit = hueDistance(hsl.h, primaryInk.h); const huePreference = hsl.s < 0.055 ? 0 : clamp((primaryHueFit - secondaryHueFit) / 180, -1, 1) * rule.hueBias; const cellPreference = halftoneCellWeave(x, y, rule.step) ? rule.weaveBias : -rule.weaveBias; const tonePreference = (0.5 - ink) * rule.toneBias; const secondaryChance = clamp( 0.5 + cellPreference + tonePreference + huePreference, rule.minInterleave, rule.maxInterleave, ); return halftoneCellNoise(x, y) < secondaryChance ? secondaryInk : primaryInk; } function halftoneCellWeave(x, y, step) { const cellX = Math.floor(x / step); const cellY = Math.floor(y / step); return (cellX + cellY) % 2 === 0; } function halftoneCellNoise(x, y) { const seed = Math.sin(Math.floor(x) * 12.9898 + Math.floor(y) * 78.233) * 43758.5453; return seed - Math.floor(seed); } function shareHalftonePosterEdgeTone( pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, centerLuminanceTone, rule, ) { const centerShape = generatedDotToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule); const radius = Math.max(1, Math.round(rule.edgeRadius || 1)); const offsets = [ [-radius, 0], [radius, 0], [0, -radius], [0, radius], ]; const maxEdge = offsets.reduce((peak, [dx, dy]) => { const neighborTone = generatedLuminanceToneAt(pixels, sx + dx, sy + dy, sampleWidth, sampleHeight, lowLum, lumRange, rule); const neighborShape = generatedDotToneAt( pixels, sx + dx, sy + dy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule, ); return Math.max( peak, Math.abs(centerLuminanceTone - neighborTone) * 0.72 + Math.abs(centerShape - neighborShape) * 0.28, ); }, 0); return clamp(maxEdge * 1.8, 0, 1); } function generatedDotToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk, rule) { const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight); const luminanceTone = generatedLuminanceToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, rule); return shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, luminanceTone, rule); } function generatedLuminanceToneAt(pixels, sx, sy, sampleWidth, sampleHeight, lowLum, lumRange, rule) { const { r, g, b } = sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; const normalized = clamp((luminance - lowLum) / lumRange, 0, 1); return clamp((normalized - 0.5) * rule.contrast + 0.5, 0, 1); } function sampleHalftonePixel(pixels, sx, sy, sampleWidth, sampleHeight) { const x = clamp(Math.round(sx), 0, sampleWidth - 1); const y = clamp(Math.round(sy), 0, sampleHeight - 1); const offset = (y * sampleWidth + x) * 4; return { r: pixels[offset], g: pixels[offset + 1], b: pixels[offset + 2], }; } function normalizePlateInk(color, role, rule) { const hsl = color.h === undefined || color.s === undefined || color.l === undefined ? rgbToHsl(color.r, color.g, color.b) : color; const saturation = role === "base" ? hsl.s < 0.16 ? clamp(hsl.s * 1.02, 0.04, 0.16) : clamp(hsl.s * rule.baseSaturationScale, 0.18, 0.5) : hsl.s < 0.18 ? clamp(hsl.s * rule.dotSaturationScale, 0.04, 0.18) : clamp(hsl.s * rule.dotSaturationScale, 0.22, 0.74); const lightness = role === "base" ? clamp(hsl.l + rule.baseLightnessLift, 0.38, 0.82) : hsl.l < 0.2 ? clamp(hsl.l + rule.dotLightnessLift * 0.22, 0.08, 0.28) : clamp(hsl.l + rule.dotLightnessLift, 0.48, 0.92); const [r, g, b] = hslToRgb(hsl.h, saturation, lightness); return { r, g, b, h: hsl.h, s: saturation, l: lightness }; } function smoothstep(edge0, edge1, value) { if (edge0 === edge1) return value >= edge1 ? 1 : 0; const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); return t * t * (3 - 2 * t); } function printableTone(radiusTone, rule) { const safeTone = clamp(radiusTone, 0, 1); if (safeTone < rule.paperThreshold) return 0; return clamp((safeTone - rule.paperThreshold) / (1 - rule.paperThreshold), 0, 1); } function rgbToHsl(r, g, b) { const red = r / 255; const green = g / 255; const blue = b / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); let hue = 0; let saturation = 0; const lightness = (max + min) / 2; if (max !== min) { const delta = max - min; saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); if (max === red) { hue = (green - blue) / delta + (green < blue ? 6 : 0); } else if (max === green) { hue = (blue - red) / delta + 2; } else { hue = (red - green) / delta + 4; } hue *= 60; } return { h: hue, s: saturation, l: lightness }; } function hueDistance(firstHue, secondHue) { const difference = Math.abs(firstHue - secondHue) % 360; return Math.min(difference, 360 - difference); } function hueFit(firstHue, secondHue) { return 1 - clamp(hueDistance(firstHue, secondHue) / 90, 0, 1); } function isUsableFeatureColor(color) { return color.l < 0.94; } function hslToRgb(hue, saturation, lightness) { const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; const huePrime = hue / 60; const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); let red = 0; let green = 0; let blue = 0; if (huePrime < 1) { red = chroma; green = x; } else if (huePrime < 2) { red = x; green = chroma; } else if (huePrime < 3) { green = chroma; blue = x; } else if (huePrime < 4) { green = x; blue = chroma; } else if (huePrime < 5) { red = x; blue = chroma; } else { red = chroma; blue = x; } const match = lightness - chroma / 2; return [ Math.round((red + match) * 255), Math.round((green + match) * 255), Math.round((blue + match) * 255), ]; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }