Define the color of a SF symbols in drawcontext

I’ve now updated the function. This is a big update. Because of this I’ve also renamed it to replaceColor because it essentially does that and can be applied to any image. I’ve tried to describe all the parameters as good as possible. If there are any questions, please ask!

Minified Version
/**
 * source: https://talk.automators.fm/t/define-the-color-of-a-sf-symbols-in-drawcontext/9897/11
 * @param {Image} image The image from the SFSymbol
 * @param {Color} newColor The color it should be tinted with
 * @param {object} [options]
 * @param {number} [options.hueMargin] Maximum hue difference between the color
 * to replace and the color found in the image. Defaults to `10`, range:
 * `0` to `180`. Colors are never separated by more than 180°.
 * @param {number} [options.lightnessMargin] Maximum lightness difference
 * between the color to replace and the color found in the image. Defaults to
 * `0.4`, range: `0` to `1`.
 * @param {Color | number | (Color | number)[]} [options.currentColor] The color
 * that should be replaced. If not provided, the most prominent color will be
 * replaced. Can be a Color instance or a number specifying the hue (0 to 360)
 * or an array of those to replace multiple colors at once.
 * Common hues (https://hslpicker.com):
 * * `0` - red
 * * `30` - orange/brown
 * * `60` - yellow
 * * `120` - green
 * * `180` - cyan
 * * `210` - iOS buttons blue
 * * `240` - blue
 * * `300` - magenta
 * * `360` - red
 * @param {boolean | { lower?: number, upper?: number }} [options.replaceSaturation]
 * Whether or not to use the saturation from the replacement color. You can also
 * specify a cutoff between 0 and 1 with `lower` and `upper`. Every value
 * between them will be replaced. `lower` defaults to 0 and `upper` to 1 if one
 * of them is provided. Gray colors have saturation < 0.2, set this to
 * `{ upper: 0.2 }` to replace them with non-gray colors.
 * @param {boolean | { lower?: number, upper?: number }} [options.replaceLightness]
 * Whether or not to use the lightness from the replacement color. You can also
 * specify a cutoff between 0 and 1 with `lower` and `upper`. Every value
 * between them will be replaced. `lower` defaults to 0 and `upper`to 1 if one
 * of them is provided. Black = 0, full color = 0.5, white = 1.
 * @param {boolean} [options.noWarnings] Turn warnings off. These are printed
 * to the console when you try to replace black, gray or white colors without
 * specifying `replaceSaturation` and `replaceLightness`.
 */
async function replaceColor(image,newColor,options={hueMargin:10,lightnessMargin:.4,currentColor:void 0,replaceSaturation:!1,replaceLightness:!1,noWarnings:!1}){function r(a){
  a&&("number"!=typeof a.lower||a.lower<0?a.lower=0:a.lower>1&&(a.lower=1),"number"!=typeof a.upper||a.upper>1?a.upper=1:a.upper<0&&(a.upper=0))}options||(options={}),"number"!=typeof options.hueMargin&&(options.hueMargin=10),
  options.hueMargin<0&&(options.hueMargin=0),options.hueMargin>180&&(options.hueMargin=180),"number"!=typeof options.lightnessMargin&&(options.lightnessMargin=.4),options.lightnessMargin<0&&(options.lightnessMargin=0),
  options.lightnessMargin>1&&(options.lightnessMargin=1),Array.isArray(options.currentColor)||(options.currentColor=[options.currentColor]),options.currentColor=options.currentColor.filter(a=>"number"==typeof a||a instanceof Color),
  options.replaceSaturation?!0===options.replaceSaturation&&(options.replaceSaturation={}):options.replaceSaturation=!1,r(options.replaceSaturation),
  options.replaceLightness?!0===options.replaceLightness&&(options.replaceLightness={}):options.replaceLightness=!1,r(options.replaceLightness),!0!==options.noWarnings&&(options.noWarnings=!1)
  ;let n=`<img id="image" src="data:image/png;base64,${Data.fromPNG(image).toBase64String()}" /><canvas id="canvas"></canvas>`,o=`function rgbToHsl(a,t,e){a/=255,t/=255,e/=255;var l,o,r=Math.max(a,t,e),s=Math.min(a,t,e),n=(r+s)/2;if(r==s)l=o=0;else{var h=r-s;switch(o=n>.5?h/(2-r-s):h/(r+s),r){case a:l=(t-e)/h+(t<e?6:0);break
  ;case t:l=(e-a)/h+2;break;case e:l=(a-t)/h+4}l/=6}return{h:l,s:o,l:n}}function hslToRgb(a,t,e){var l,o,r;if(0==t)l=o=r=e;else{function s(a,t,e){return e<0&&(e+=1),e>1&&(e-=1),
  e<1/6?a+6*(t-a)*e:e<.5?t:e<2/3?a+(t-a)*(2/3-e)*6:a}var n=e<.5?e*(1+t):e+t-e*t,h=2*e-n;l=s(h,n,a+1/3),o=s(h,n,a),r=s(h,n,a-1/3)}return{r:Math.round(255*l),g:Math.round(255*o),b:Math.round(255*r)}}
  function toHex(a,t,e){return"0x"+a.toString(16)+t.toString(16)+e.toString(16)}
  let img=document.getElementById("image"),canvas=document.getElementById("canvas"),color=0x${newColor.hex},oldColor=${JSON.stringify(options.currentColor.map((c) => c instanceof Color ? '"0x' + c.hex + '"' : c))},
  targetHsl=rgbToHsl(color>>16&255,color>>8&255,255&color);targetHsl.h*=360
  ;let oldHsl=oldColor.map(a=>{if("string"==typeof a){const t=rgbToHsl((a=parseInt(a))>>16&255,a>>8&255,255&a);return t.h*=360,t}return{h:a,s:1,l:.5}});const hasOldColor=oldHsl.length>0
  ;canvas.width=img.width,canvas.height=img.height;let ctx=canvas.getContext("2d");ctx.drawImage(img,0,0);let imgData=ctx.getImageData(0,0,img.width,img.height),data=imgData.data
  ;const hslData=new Array(data.length),colorMap=new Map;for(let a=0;a<data.length;a+=4){let t=rgbToHsl(data[a+0],data[a+1],data[a+2]);t.h*=360,hslData[a+0]=t.h,hslData[a+1]=t.s,hslData[a+2]=t.l
  ;const e=Math.round(t.h);if(!hasOldColor&&data[a+3]>0){const a=colorMap.has(e)?colorMap.get(e):0;colorMap.set(e,a+1)}}let maxHue=0,maxHueCount=0
  ;if(!hasOldColor)for(const[a,t]of colorMap)t>maxHueCount&&(maxHue=a,maxHueCount=t);let warnedAboutGray=${options.noWarnings},warnedAboutBlack=${options.noWarnings}
  ;const satBounds=${JSON.stringify(options.replaceSaturation)},lightBounds=${JSON.stringify(options.replaceLightness)}
  ;for(let a=0;a<data.length;a+=4){if(0===data[a+3])continue;let t={h:0,s:0,l:0};if(hasOldColor?t=oldHsl.map(t=>{const e={h:Math.abs(hslData[a]-t.h),s:Math.abs(hslData[a+1]-t.s),
  l:Math.abs(hslData[a+2]-t.l)};return e.h>180&&(e.h=360-e.h),e}).reduce((a,t)=>a&&a.h<t.h?a:t):(t.h=Math.abs(hslData[a]-maxHue),t.h>180&&(t.h=360-t.h)),t.h<${options.hueMargin}&&t.l<${options.lightnessMargin}){
  let t=hslData[a+1];t>=satBounds.lower&&t<=satBounds.upper&&(t=targetHsl.s);let e=hslData[a+2];e>=lightBounds.lower&&e<=lightBounds.upper&&(e=targetHsl.l);const l=hslToRgb(targetHsl.h/360,t,e)
  ;data[a+0]=l.r,data[a+1]=l.g,data[a+2]=l.b}}ctx.putImageData(imgData,0,0),canvas.toDataURL("image/png").replace(/^data:image\\/png;base64,/,"");`
  ;const l=new WebView;await l.loadHTML(n);const s=await l.evaluateJavaScript(o);return Image.fromData(Data.fromBase64String(s))
}
/**
 * source: https://talk.automators.fm/t/define-the-color-of-a-sf-symbols-in-drawcontext/9897/11
 * @param {Image} image The image from the SFSymbol
 * @param {Color} newColor The color it should be tinted with
 * @param {object} [options]
 * @param {number} [options.hueMargin] Maximum hue difference between the color
 * to replace and the color found in the image. Defaults to `10`, range:
 * `0` to `180`. Colors are never separated by more than 180°.
 * @param {number} [options.lightnessMargin] Maximum lightness difference
 * between the color to replace and the color found in the image. Defaults to
 * `0.4`, range: `0` to `1`.
 * @param {Color | number | (Color | number)[]} [options.currentColor] The color
 * that should be replaced. If not provided, the most prominent color will be
 * replaced. Can be a Color instance or a number specifying the hue (0 to 360)
 * or an array of those to replace multiple colors at once.
 * Common hues (https://hslpicker.com):
 * * `0` - red
 * * `30` - orange/brown
 * * `60` - yellow
 * * `120` - green
 * * `180` - cyan
 * * `210` - iOS buttons blue
 * * `240` - blue
 * * `300` - magenta
 * * `360` - red
 * @param {boolean | { lower?: number, upper?: number }} [options.replaceSaturation]
 * Whether or not to use the saturation from the replacement color. You can also
 * specify a cutoff between 0 and 1 with `lower` and `upper`. Every value
 * between them will be replaced. `lower` defaults to 0 and `upper` to 1 if one
 * of them is provided. Gray colors have saturation < 0.2, set this to
 * `{ upper: 0.2 }` to replace them with non-gray colors.
 * @param {boolean | { lower?: number, upper?: number }} [options.replaceLightness]
 * Whether or not to use the lightness from the replacement color. You can also
 * specify a cutoff between 0 and 1 with `lower` and `upper`. Every value
 * between them will be replaced. `lower` defaults to 0 and `upper`to 1 if one
 * of them is provided. Black = 0, full color = 0.5, white = 1.
 * @param {boolean} [options.noWarnings] Turn warnings off. These are printed
 * to the console when you try to replace black, gray or white colors without
 * specifying `replaceSaturation` and `replaceLightness`.
 */
async function replaceColor(
  image,
  newColor,
  options = {
    hueMargin: 10,
    lightnessMargin: 0.4,
    currentColor: undefined,
    replaceSaturation: false,
    replaceLightness: false,
    noWarnings: false,
  },
) {
  if (!options) options = {};
  if (typeof options.hueMargin !== "number") {
    options.hueMargin = 10;
  }
  if (options.hueMargin < 0) options.hueMargin = 0;
  if (options.hueMargin > 180) options.hueMargin = 180;

  if (typeof options.lightnessMargin !== "number") {
    options.lightnessMargin = 0.4;
  }
  if (options.lightnessMargin < 0) options.lightnessMargin = 0;
  if (options.lightnessMargin > 1) options.lightnessMargin = 1;

  if (!Array.isArray(options.currentColor)) {
    options.currentColor = [options.currentColor];
  }
  options.currentColor = options.currentColor.filter(
    (c) => typeof c === "number" || c instanceof Color,
  );

  if (!options.replaceSaturation) {
    options.replaceSaturation = false;
  } else if (options.replaceSaturation === true) {
    options.replaceSaturation = {};
  }
  sanitizeOption(options.replaceSaturation);

  if (!options.replaceLightness) {
    options.replaceLightness = false;
  } else if (options.replaceLightness === true) {
    options.replaceLightness = {};
  }
  sanitizeOption(options.replaceLightness);

  if (options.noWarnings !== true) {
    options.noWarnings = false;
  }

  /**
   * @param {false | { lower?: number, upper?: number }} option
   */
  function sanitizeOption(option) {
    if (option) {
      if (typeof option.lower !== "number" || option.lower < 0) {
        option.lower = 0;
      } else if (option.lower > 1) {
        option.lower = 1;
      }
      if (typeof option.upper !== "number" || option.upper > 1) {
        option.upper = 1;
      } else if (option.upper < 0) {
        option.upper = 0;
      }
    }
  }
  
  let html = `
  <img id="image" src="data:image/png;base64,${Data.fromPNG(image).toBase64String()}" />
  <canvas id="canvas"></canvas>
  `;
  
  let js = `
    function rgbToHsl(r, g, b) {
        r /= 255, g /= 255, b /= 255;
        var max = Math.max(r, g, b),
            min = Math.min(r, g, b);
        var h, s, l = (max + min) / 2;
    
        if (max == min) {
            h = s = 0; // achromatic
        } else {
            var d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch (max) {
                case r:
                    h = (g - b) / d + (g < b ? 6 : 0);
                    break;
                case g:
                    h = (b - r) / d + 2;
                    break;
                case b:
                    h = (r - g) / d + 4;
                    break;
            }
            h /= 6;
        }
    
        return ({
            h: h,
            s: s,
            l: l,
        });
    }
    
    
    function hslToRgb(h, s, l) {
        var r, g, b;
    
        if (s == 0) {
            r = g = b = l; // achromatic
        } else {
            function hue2rgb(p, q, t) {
                if (t < 0) t += 1;
                if (t > 1) t -= 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;
            }
    
            var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
            var p = 2 * l - q;
            r = hue2rgb(p, q, h + 1 / 3);
            g = hue2rgb(p, q, h);
            b = hue2rgb(p, q, h - 1 / 3);
        }
    
        return ({
            r: Math.round(r * 255),
            g: Math.round(g * 255),
            b: Math.round(b * 255),
        });
    }

    function toHex(r, g, b) {
      return "0x" + r.toString(16) + g.toString(16) + b.toString(16);
    }


    let img = document.getElementById("image");
    let canvas = document.getElementById("canvas");
    let color = 0x${newColor.hex};
    let oldColor = ${JSON.stringify(
      options.currentColor.map((c) => c instanceof Color ? '"0x' + c.hex + '"' : c),
    )};
    let targetHsl = rgbToHsl(
      (color >> 16) & 0xFF,
      (color >> 8) & 0xFF,
      color & 0xFF,
    );
    targetHsl.h *= 360;
    let oldHsl = oldColor.map(
      (c) => {
        if (typeof c === "string") {
          c = parseInt(c);
          const hsl = rgbToHsl(
            (c >> 16) & 0xFF,
            (c >> 8) & 0xFF,
            c & 0xFF,
          );
          hsl.h *= 360;
          return hsl;
        } else {
          return {
            h: c,
            s: 1,
            l: 0.5,
          };
        }
      }
    );
    const hasOldColor = oldHsl.length > 0;
    log("hasOldColor: " + hasOldColor);
    log("oldHsl:");
    log(oldHsl);

    canvas.width = img.width;
    canvas.height = img.height;
    let ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, 0);
    let imgData = ctx.getImageData(0, 0, img.width, img.height);
    // ordered in RGBA format
    let data = imgData.data;

    // order: hue saturation luminance alpha (alpha is not filled)
    const hslData = new Array(data.length);
    const colorMap = new Map();
    for (let i = 0; i < data.length; i += 4) {
      let hsl = rgbToHsl(data[i + 0], data[i + 1], data[i + 2]);
      hsl.h *= 360;
      hslData[i + 0] = hsl.h;
      hslData[i + 1] = hsl.s;
      hslData[i + 2] = hsl.l;
      const hue = Math.round(hsl.h);

      if (!hasOldColor && data[i + 3] > 0) {
        const current = colorMap.has(hue) ? colorMap.get(hue) : 0;
        colorMap.set(hue, current + 1);
      }
    }

    let maxHue = 0;
    let maxHueCount = 0;
    if (!hasOldColor) {
      for (const [hue, count] of colorMap) {
        if (count > maxHueCount) {
          maxHue = hue;
          maxHueCount = count;
        }
      }
    }

    let warnedAboutGray = ${options.noWarnings};
    let warnedAboutBlack = ${options.noWarnings};
    const satBounds = ${JSON.stringify(options.replaceSaturation)};
    const lightBounds = ${JSON.stringify(options.replaceLightness)};
    for (let i = 0; i < data.length; i += 4) {
      if (data[i + 3] === 0) continue;
      let diff = {
        h: 0,
        s: 0,
        l: 0,
      };
      if (hasOldColor) {
        diff = oldHsl
          .map((hsl) => {
            const ret = {
              h: Math.abs(hslData[i] - hsl.h),
              s: Math.abs(hslData[i + 1] - hsl.s),
              l: Math.abs(hslData[i + 2] - hsl.l),
            };
            if (ret.h > 180) ret.h = 360 - ret.h;
            return ret;
          })
          .reduce((prev, cur) => !prev ? cur : prev.h < cur.h ? prev : cur);
      } else {
        diff.h = Math.abs(hslData[i] - maxHue);
        if (diff.h > 180) {
          diff.h = 360 - diff.h;
        }
      }
      if (diff.h < ${options.hueMargin} && diff.l < ${options.lightnessMargin}) {
        let sat = hslData[i + 1];
        if (satBounds && sat >= satBounds.lower && sat <= satBounds.upper) {
          sat = targetHsl.s;
        } else if (!satBounds && sat < 0.1 && !warnedAboutGray) {
          warnedAboutGray = true;
          logWarning(
            "[replaceColor()] You tried replacing a grayish color without"
            + " specifying that its saturation should also be replaced."
          );
        }
        let light = hslData[i + 2];
        if (
          lightBounds
          && light >= lightBounds.lower
          && light <= lightBounds.upper
        ) {
          light = targetHsl.l;
        } else if (
          !lightBounds
          && (light <= 0.1 || light >= 0.9)
          && !warnedAboutBlack
        ) {
          warnedAboutBlack = true;
          logWarning(
            "[replaceColor()] You tried replacing a black or white color without"
            + " specifying that its lightness should also be replaced. You"
            + " should also specify that its saturation should be replaced, if"
            + " you haven't done so already."
          );
        }
        const rgb = hslToRgb(targetHsl.h / 360, sat, light);
        data[i + 0] = rgb.r;
        data[i + 1] = rgb.g;
        data[i + 2] = rgb.b;
      }
    }
    ctx.putImageData(imgData, 0, 0);
    canvas.toDataURL("image/png").replace(/^data:image\\/png;base64,/, "");
  `;

  const wv = new WebView();
  await wv.loadHTML(html);
  const base64 = await wv.evaluateJavaScript(js);
  return Image.fromData(Data.fromBase64String(base64));
}
1 Like