Define the color of a SF symbols in drawcontext

I already know that I can define the color of a sfsymbles when add image to a stack by using tintcolor.
What I want to know is that how can I do the same in a drawcontext?

2 Likes

I have looked for this as well but couldnā€™t find anything. I guess there is no way currently to do that. As an alternative I am using Unicode emojis.

So I havenā€™t needed this yet, but I took on the challenge and found a workaround. Iā€™ve used the canvas element from the browser to decode the image from the SFSymbol and then just set the color values manually. After that I again used the canvas to convert it back to a PNG image. Iā€™ve also annotated the function with JSDoc, so you get the correct types if you use an IDE. Just copy the function into your code to use it. I hope it is useful!

Code
/**
 * source: https://talk.automators.fm/t/define-the-color-of-a-sf-symbols-in-drawcontext/9897/3
 * @param {Image} image The image from the SFSymbol
 * @param {Color} color The color it should be tinted with
 */
async function tintSFSymbol(image, color) {
  let html = `
  <img id="image" src="data:image/png;base64,${Data.fromPNG(image).toBase64String()}" />
  <canvas id="canvas"></canvas>
  `;
  
  let js = `
    let img = document.getElementById("image");
    let canvas = document.getElementById("canvas");
    let color = 0x${color.hex};

    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;
    for (let i = 0; i < data.length; i++) {
      // skip alpha channel
      if (i % 4 === 3) continue;
      // bit shift the color value to get the correct channel
      data[i] = (color >> (2 - i % 4) * 8) & 0xFF
    }
    ctx.putImageData(imgData, 0, 0);
    canvas.toDataURL("image/png").replace(/^data:image\\/png;base64,/, "");
  `;
  
  let wv = new WebView();
  await wv.loadHTML(html);
  let base64 = await wv.evaluateJavaScript(js);
  return Image.fromData(Data.fromBase64String(base64));
}

// example
// you don't need to copy this
let sym = SFSymbol.named("icloud").image;
let col = Color.orange();
let res = await tintSFSymbol(sym, col);
await QuickLook.present(sym)
QuickLook.present(res)

Edit: Iā€™ve updated the code. You can find it further down: Define the color of a SF symbols in drawcontext - #11 by schl3ck

3 Likes

Thank you! This works fine. Except that I faced an issue with the size of the image. The image size in Scriptable is defined in ā€œPointsā€ and when I pass it to WebView it is converted to ā€œPixelsā€ (i.e. Points x Device.screenScale()) so my image is expanded 3 times (iPhone X).
Note that I am working with small image size (45px x 35px).
So, I tried sending the original size to tintSFSymbol function and put width and height attributes in the img tag as -

<img id="image" src="data:image/png;base64,${Data.fromPNG(image).toBase64String()}" width="${width}" height="{height}" />

but for some reason it didnā€™t work as expected. The image size returned is very small, almost a dot.

Finally, I resized the image returned from tintSFSymbol function as-

          const oW = symbolImage.size.width;
          const oH = symbolImage.size.height;
          const tintedSymbol = await tintSFSymbol(symbolImage, color));
          symbolCanvas=new DrawContext();
          symbolCanvas.opaque = false;
          symbolCanvas.size = new Size(oW,oH);
          const symbolRect = new Rect(0,0,oW,oH);
          symbolCanvas.drawImageInRect(tintedSymbol, symbolRect);
          const newSymbolImage = symbolCanvas.getImage();

I am not sure if this the ideal solution, but this works for me.

1 Like

Yes, Iā€™ve noticed that too. The initial width of the Symbol was for me something.5, which seemed very odd. Since the returned image was just scaled by the screen scale, I thought that itā€™s not a big deal, especially if you want to use it in a draw context where you can resize it while inserting as you did.

1 Like

@Pih & @ajatkj, on this topic, there are two iOS shortcuts available on RoutineHub that you might find helpful:

How do these relate to the topic of setting up the symbols in Scriptable? Arenā€™t these only for Shortcuts use?

Directly, thereā€™s a loose connection. SF Emoji Menu Builder includes some code that I thought might be helpful (it uses Scriptable). The other shortcut may be of no use, but not knowing the detailed requirements, I took a chance thinking it might help.

Hello! Thanks for sharing this. Iā€™m creating a script that downloads each SF Symbol as a PNG in a specific color. I got everything to work but then noticed that ā€˜fillā€™ icons are missing the cut outs.

This example shows the symbol.image in QuickLook, then the result of your script (changing original symbol to red)

Any thoughts on how to fix this?


Whoops! I didnā€™t check many symbols if it worked correctly.

It seems that the 0 in the image is not translucent but opaque white. The function replaces every color with the desired one but leaves the alpha channel as is. Therefore the 0 vanishes.

Iā€™ve now modified the function to use the HSL color map instead of RGB and only replace the most prominent color, but Iā€™m struggling with symbols that initially are gray. Iā€™ll post an update when Iā€™ve got it working!

1 Like

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

@schl3ck Iā€™ve tried to use this a few times and have yet to get it to work. Do you have some example usages that I could see/try? Trying to wrap my head around the color swapping but itā€™s obviously not stuck yet.

Thanks!

Sorry for the long wait.

Iā€™ve found this in my scripts:

let sym = SFSymbol.named("0.circle.fill")
sym.applyFont(Font.systemFont(60));
sym = sym.image;
let col = Color.orange();
let res = await replaceColor(
  sym,
  col,
  // {
  //   currentColor: [220, 0],
  //   replaceLightness: true,
  // }
);
log(res.size)
res.size = new Size(res.size.width / Device.screenScale(), res.size.height / Device.screenScale());
await QuickLook.present(sym)
QuickLook.present(res)
log(res.size)

I donā€™t know why I tried to assign a new size to the image, but it seems not to do anything.