← back to CelebritySignatures

public/assets/signature-ink.js

88 lines

// Display-only cleanup. Never redraw handwriting or modify the archive source.
// `ink` optionally recolors the extracted strokes to a per-signature hue (RGB
// triple, 0-255) rendered on a clean white ground; omit it for the classic
// near-black. The archive pixels are never touched — this only decides what
// color the display copy paints the already-thresholded ink.
export function normalizeInk({data,width,height},ink) {
  const ir=ink?ink[0]:26, ig=ink?ink[1]:26, ib=ink?ink[2]:26;
  if (!width || !height || data.length !== width*height*4 || width*height>4000000) throw new Error('Invalid image');
  const gray=new Uint8Array(width*height), hist=new Uint32Array(256), border=[];
  for(let y=0;y<height;y++) for(let x=0;x<width;x++) {
    const p=y*width+x,i=p*4,a=data[i+3]/255;
    const g=Math.round((.2126*data[i]+.7152*data[i+1]+.0722*data[i+2])*a+255*(1-a));
    gray[p]=g;
    if(x===0||y===0||x===width-1||y===height-1) border.push(g);
  }
  border.sort((a,b)=>a-b);
  // A uniform dark background with light ink is displayed in reverse polarity.
  const invert=border[Math.floor(border.length*.5)]<100 && border[Math.floor(border.length*.9)]-border[Math.floor(border.length*.1)]<28;
  for(let p=0;p<gray.length;p++) { if(invert)gray[p]=255-gray[p];hist[gray[p]]++; }
  let total=0;for(let i=0;i<256;i++)total+=i*hist[i];
  let weight=0,sum=0,best=-1,threshold=0;
  for(let i=0;i<255;i++) {
    weight+=hist[i];sum+=i*hist[i];
    if(!weight||weight===gray.length)continue;
    const between=weight*(gray.length-weight)*(sum/weight-(total-sum)/(gray.length-weight))**2;
    if(between>best){best=between;threshold=i;}
  }
  if(best<=0)throw new Error('No distinct ink found');
  // Otsu separates even faded ink from aged paper without a fixed cutoff.
  // Keep the original frame: automatic cropping could discard a flourish.
  const out=new Uint8ClampedArray(data.length);let inkPixels=0;
  for(let p=0;p<gray.length;p++) {
    const isInk=gray[p]<=threshold; if(isInk)inkPixels++;
    const i=p*4;
    if(isInk){out[i]=ir;out[i+1]=ig;out[i+2]=ib;} else {out[i]=out[i+1]=out[i+2]=255;}
    out[i+3]=255;
  }
  if(!inkPixels||inkPixels===gray.length)throw new Error('No distinct ink found');
  return {data:out,width,height,threshold,inverted:invert};
}

// Stable per-signature ink color: same key (a person's qid, else name) always
// maps to the same hue, so every version of one signer shares a color and the
// gallery reads as a lively spread rather than a wall of black. Returns [r,g,b].
export function inkColorFor(key) {
  let h=2166136261;
  const s=String(key||'');
  for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}
  return inkColorForHue((h>>>0)%360);
}
// WCAG relative luminance (gamma-corrected) of an [r,g,b] triple.
function relLuminance([r,g,b]) {
  const f=c=>{c/=255;return c<=0.03928?c/12.92:((c+0.055)/1.055)**2.4;};
  return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);
}
// The hue wheel has a legibility dead zone on white: at a fixed lightness the
// olive/yellow/green band (~hue 40-150) sits far above the others in luminance,
// so ~10% of hues render as a pale wash below the WCAG 3:1 non-text floor. Rather
// than ban those hues (which loses colors), keep the hue and darken the lightness
// only as much as needed to clear the floor — so EVERY signer gets a distinct,
// legibly-dark colored ink on white. Contrast vs white = 1.05/(L+0.05); >=3.0
// means L<=0.30. We aim past it (0.26 ≈ 3.4:1) for comfortable reading margin.
export const INK_MAX_LUMINANCE = 0.26;
export function inkColorForHue(hue) {
  let l=0.42, rgb=hslToRgb(hue/360, 0.62, l);
  while(relLuminance(rgb)>INK_MAX_LUMINANCE && l>0.14){ l-=0.02; rgb=hslToRgb(hue/360, 0.62, l); }
  return rgb;
}
function hslToRgb(h,s,l) {
  if(s===0){const v=Math.round(l*255);return [v,v,v];}
  const q=l<0.5?l*(1+s):l+s-l*s, p=2*l-q;
  const hk=t=>{t=(t%1+1)%1;
    if(t<1/6)return p+(q-p)*6*t;
    if(t<1/2)return q;
    if(t<2/3)return p+(q-p)*(2/3-t)*6;
    return p;};
  return [Math.round(hk(h+1/3)*255),Math.round(hk(h)*255),Math.round(hk(h-1/3)*255)];
}

export function imageSource(value,base='https://celebsignatures.com') {
  try {
    const u=new URL(value,base);
    if(!['https:','http:'].includes(u.protocol)||u.username||u.password)return null;
    if(u.protocol==='http:' && u.hostname!=='localhost' && u.hostname!=='127.0.0.1')u.protocol='https:';
    return u.href;
  } catch {return null;}
}