1

I am dynamically creating SVG code containing text on a transparent background. The SVG should be drawn on a canvas, fonts should come from Google Fonts.

The problem:

While the approach basically works, some font sizes apparently yield bad alpha channels with createImageBitmap(), resulting in horribly jagged text.

I am experiencing the problem on the latest versions of Chrome on both Windows 10 and Ubuntu. Deactivating Chrome's hardware acceleration doesn't change anything.

Image of output: Text with jagged edges

In a nutshell, this is what the code does:

  1. Generate SVG sourcecode that displays some text on a transparent background.
  2. In SVG code, replace links to external content (fonts) with respective base64 content.
  3. Create an ImageBitmap from that SVG using createImageBitmap().
  4. Draw that ImageBitmap on a canvas.

function createSvg(bckgrColor1, bckgrColor2, w, h, fontSize) {
  return `
  <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
    xmlns:ev="http://www.w3.org/2001/xml-events" version="2" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
    <style type="text/css">
      @font-face {
        font-family: 'Lobster';
        font-style: normal;
        font-weight: 400;
        font-display: swap;
        src: url(https://fonts.gstatic.com/s/lobster/v23/neILzCirqoswsqX9zoKmMw.woff2) format('woff2');
        unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
      }
    </style>
    <text x="0" y="50" font-family="Lobster" font-size="${fontSize}">
      Hello World!
    </text>
  </svg>`;
}

const _embedAssets = async function(svgSrc) {
  const _imageExtensions = ["png", "gif", "jpg", "jpeg", "svg", "bmp"];
  const _fontExtensions = ["woff2"];
  const _assetExtensions = [..._imageExtensions, ..._fontExtensions];
  /**
   * @uses https://stackoverflow.com/a/8943487/1273551
   * @license CC BY-SA 4.0.
   */
  const urlRegex = /(\bhttps?:\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi;
  const allUrls = svgSrc.match(urlRegex);
  const assetUrls = allUrls.filter((url) =>
    _assetExtensions.some((extension) =>
      url.toLowerCase().endsWith(`.${extension}`)
    )
  );
  const assetBase64Fetcher = assetUrls.map(_fetchBase64AssetUrl);
  const assetFetcherResults = await Promise.all(assetBase64Fetcher);
  return assetFetcherResults.reduce(
    (svgSrc, x) => svgSrc.replace(x.url, x.base64),
    svgSrc
  );
};

// Fetch asset (image or font) and convert it to base64 string representation.
const _fetchBase64AssetUrl = async function(assetUrl) {
  return new Promise(async(resolve, reject) => {
    const resp = await fetch(assetUrl);
    const blob = await resp.blob();
    const reader = new FileReader();
    reader.onloadend = (event) => {
      const target = event.target;
      if (!target) {
        return reject(`Asset with URL "${assetUrl}" could not be loaded.`);
      }
      const result = target.result;
      if (!result) {
        return reject(`Asset with URL "${assetUrl}" returned an empty result.`);
      }
      resolve({
        url: assetUrl,
        base64: result.toString()
      });
    };
    reader.readAsDataURL(blob);
  });
};

const createImageBitmapFromSvg = async function(svgSrc) {
  return new Promise(async(resolve) => {
    const svgWithAssetsEmbedded = await _embedAssets(svgSrc);
    const svgBlob = new Blob([svgWithAssetsEmbedded], {
      type: "image/svg+xml;charset=utf-8"
    });
    const svgBase64 = URL.createObjectURL(svgBlob);
    let img = new Image();
    img.onload = async() => {
      const imgBitmap = await createImageBitmap(img);
      resolve(imgBitmap);
    };
    img.src = svgBase64;
  });
};

const renderCanvas = async function(canvas, svgSource, width, height, color) {
  canvas.width = width;
  canvas.height = height;
  let svgEmbedded = await _embedAssets(svgSource);
  let svgImageBitmap = await createImageBitmapFromSvg(svgEmbedded);
  let ctx = canvas.getContext("2d");
  if (ctx) {
    ctx.fillStyle = color;
    ctx.strokeStyle = "#000000";
    ctx.lineWidth = 2;
    ctx.fillRect(0, 0, canvas.width, canvas.height); //
    ctx.strokeRect(0, 0, canvas.width, canvas.height); //for white background
    ctx.drawImage(svgImageBitmap, 0, 0, canvas.width, canvas.height);
  }
};

const renderCanvasAlternative = async function(canvas, svgSource, width, height, color) {

  // create imagebitmap from raw svg code  
  let svgImageBitmap = await createImageBitmapFromSvg(svgSource);

  // temporary intermediate step as suggested on StackOverflow 
  const osc = await new OffscreenCanvas(width, height)
  let oscx = osc.getContext("bitmaprenderer")
  oscx.transferFromImageBitmap(svgImageBitmap);

  const svgImageBitmapFromOffscreenCanvas = osc.transferToImageBitmap();
  // const svgImageBitmapFromOffscreenCanvas2 = await createImageBitmap(osc); // results in empty bitmap

  // draw image bitmap on canvas
  canvas.width = width;
  canvas.height = height;
  let ctx = canvas.getContext("bitmaprenderer");
  if (!ctx) throw new Error("Could not get context from canvas.");
  ctx.transferFromImageBitmap(svgImageBitmapFromOffscreenCanvas);
}

const bootstrap = async() => {
  // width and height for svg and canvases
  const w = "300";
  const h = "80";

  // create two svg sources, only difference is fontsize of embedded font
  const svgSourceGood = createSvg("", "", w, h, 49);
  const svgSourceBad = createSvg("#990000", "", w, h, 48);

  // draw GOOD svg in canvas
  renderCanvasAlternative(
    document.getElementById("myCanvas01"),
    svgSourceGood,
    w,
    h,
    "green"
  );

  // draw BAD svg in canvas
  renderCanvasAlternative(
    document.getElementById("myCanvas02"),
    svgSourceBad,
    w,
    h,
    "red"
  );
};

bootstrap();
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Document</title>
</head>

<body>
  <div>SVG drawn in Canvas, Fontsize 49</div>
  <canvas id="myCanvas01"></canvas>
  <div>SVG drawn in Canvas, Fontsize 48</div>
  <canvas id="myCanvas02"></canvas>
  <script src="index.js"></script>
</body>

</html>
  • Looks like a browser bug. Which browser are you experiencing this issue on? Does deactivating hardware acceleration still reproduces the issue? (on my computer with macOS it renders fine in Chrome and Firefox, Safari faces https://bugs.webkit.org/show_bug.cgi?id=39059 but this only renders with sans-serif instead of the web-font). – Kaiido Oct 27 '21 at 14:56
  • 1
    Also, please include a minimal example in the question itself. You can append live snippets by using the `<>` icon, and you can simplify your example by showing only the broken case. – Kaiido Oct 27 '21 at 15:02
  • Oh and given Safari's bug, you may want to add a timeout anyway, and you may try to workaround the bug by drawing the svg image to an intermediary canvas and creating the ImageBitmap from that canvas (still after a small delay for Safari). – Kaiido Oct 27 '21 at 15:05
  • @Kaiido I am experiencing the problem on the latest versions of Chrome on both Windows 10 and Ubuntu. Deactivating Chrome's hardware acceleration doesn't change anything. – hschmiedhofer Nov 02 '21 at 13:33
  • @Kaiido What exactly do you mean by 'drawing the SVG image to an intermediary canvas?'. Like, replacing the image composition I'm using SVG for by the canvas? If that's what you are referring to, it would actually work, but in my scenario it's not an option, unfortunaltely. – hschmiedhofer Nov 02 '21 at 13:40
  • I mean draw the which contains the svg image on a canvas and create the ImageBitmap from that canvas. And please **include the code in the question itself**, we don't accept outsourced code that shows the issue. We can't be sure that in 2 years the code will still be there, making your question moot at this point. – Kaiido Nov 02 '21 at 13:48
  • @Kaiido thanks for your comments, I edited my post accordingly. Regarding the jagged font problem: I already put raw SVG code into an HTMLImageElement and use createImageBitmap() on that in order to get something I can draw in a canvas. So, I think my code already does what you're suggesting. Am I missing something? – hschmiedhofer Nov 03 '21 at 09:33
  • Yes, I am proposing you to add an extra step, which consist in drawing the on a canvas and then call createImageBitmap(canvas), before drawing that imagebitmap again somewhere else. Obviously not as performant, but would help identify where the issue lies. – Kaiido Nov 03 '21 at 10:02
  • @Kaiido please see function `renderCanvasAlternative`. I am now using an intermediary OffscreenCanvas to produce an ImageBitmap that's being drawn on the final DOM canvas. – hschmiedhofer Nov 03 '21 at 11:34
  • But you are drawing the ImageBitmap on the OffscreenCanvas, so if the bug is in the createImageBitmap path, you'll still experience it. I told you to draw the on that canvas. – Kaiido Nov 03 '21 at 12:54
  • So the question is, do you still see the issue [in this fiddle](https://jsfiddle.net/21zk8wxp/)? – Kaiido Nov 04 '21 at 05:43
  • @Kaiido nope, problem is gone. I came to the very same conclusion before I read your comment. createImageBitmap() is a redundant step here, and obviously produced the issue. Thanks for your help, highly appreciated! :) – hschmiedhofer Nov 04 '21 at 09:44
  • 1
    This is definitely a bug though, please open an issue at https://crbug.com with extended details about your config. – Kaiido Nov 04 '21 at 11:42

1 Answers1

2

Since the canvas context also accepts HTMLImageElement as an input, using createImageBitmap() is redundant here. Instead, we return the DOM loaded <img> itself, thus circumventing createImageBitmap(), which obviously caused the jagged edges. Thanks to @Kaiido.

const createImageFromSvg = async function(svgSrc: string): Promise < HTMLImageElement > {
  return new Promise(async resolve => {
    // replace assets with their base64 versions in svg source code 
    const svgWithAssetsEmbedded = await _embedAssets(svgSrc);

    // create blob from that
    const svgBlob = new Blob([svgWithAssetsEmbedded], {
      type: 'image/svg+xml;charset=utf-8'
    });

    // create URL that can be used in HTML (?)
    const svgBase64 = URL.createObjectURL(svgBlob);

    let img = new Image();
    img.onload = async() => {
      resolve(img);
    };
    img.src = svgBase64;
  });
};
  • The onload event is fired when the image can be added to DOM, not when image is actually loaded. We observe that onload is fired 100-300ms before the image is actually loaded and can be drawn on a canvas for a substantial amount of Safari users. Drawing an image immediately (e.g. synchronously or after a small delay), doesn't draw anything - the canvas remain blank. – Ivan Nikitin Dec 02 '22 at 09:44