Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom font on SVG element is not correct #1463

Closed
PodeMarut opened this issue Mar 12, 2018 · 26 comments · Fixed by #2320
Closed

Custom font on SVG element is not correct #1463

PodeMarut opened this issue Mar 12, 2018 · 26 comments · Fixed by #2320

Comments

@PodeMarut
Copy link

Hello, I have svg element and use google font.
But when capture this element to image the font is not correctly.
I'm using version 1.0.0-alpha.10.
Thank you for your advice.

Here is my code: https://jsfiddle.net/fgppmvxo/8/

@Thomas487
Copy link

Experiencing the same issue. Is there any temporary fix to this? Using a dropdown to select many Google Fonts, but even though they're displayed correctly on the page, the rendering is wrong (unless i have it installed on my pc).

@bakku
Copy link

bakku commented Feb 11, 2019

Temporary fix for me was #1709 (comment) by @maximgeerinck

@Groude
Copy link

Groude commented Apr 10, 2019

any updates on this? solution from @maximgeerinck didn't work for me

@bjornol
Copy link

bjornol commented Aug 16, 2019

Im also waiting for a fix on this one. Any update?

@scoelli
Copy link

scoelli commented Sep 26, 2019

The same happens to me, any updates on this?

@lifeinchords
Copy link

I got it to work by using this module
https://github.com/lukehorvat/computed-style-to-inline-style
to inline the font related styles into the SVG, before passing it to the convert

const el = document.querySelector('mySvg')
computedStyleToInlineStyle(el, {
    recursive: true,
    properties: ["font-size", "font-family", "font-weight"]
})

html2canvas(el).then(function(canvas) {
    document.body.appendChild(canvas);
});

 

@singhsterabhi
Copy link

singhsterabhi commented Jan 4, 2020

Got it to work by manually adding a style tag inside the SVG element. The style tag will contain the CSS for the font you want to include.
Note that you should download the whole file of CSS for the font including replacing the fonts by their data URIs.

chart: {
	type: 'pie',
	events: {
		redraw: function(event) {
			let style = document.createElement("style"); 
			style.appendChild(document.createTextNode(abelCss));
			const element = document.getElementById(event.target.container.id).firstChild
			element.insertBefore(style, element.firstChild)
		}
	} 
}

You must do this process anytime before running the html2canvas function.
In my case, I had a chart rendered as SVG. So I used this event function that fires when the chart is rendered to include the font CSS.

The library mentioned by @lifeinchords basically does this process for you.

@bjornol
Copy link

bjornol commented Jan 7, 2020

Did not really get this to work. Anyone care to post an complete axample?
My current code at the moment:
bilde

@krotte1
Copy link

krotte1 commented Jun 1, 2020

Did not really get this to work. Anyone care to post an complete axample?
My current code at the moment:
bilde

I agree... I took the fiddle in the original post (https://jsfiddle.net/fgppmvxo/8/) and modified it to call computedStyleToInlineStyle before html2canvas, and the results were identical. It would be really nice if this worked.

@iamsank8
Copy link

iamsank8 commented Jun 4, 2020

Does not work for me as well.

@z1haze
Copy link

z1haze commented Jul 30, 2020

The provided solutions do not work here: has anyone got a solution that DOES work? Example of it not working with suggested fix:
https://jsfiddle.net/z1haze/29mucLzw/5/

@mkubdev
Copy link

mkubdev commented Sep 16, 2020

Temporary fix for me was #1709 (comment) by @maximgeerinck as well. Thanks!

@goliney
Copy link

goliney commented Dec 3, 2020

I'm using 1.0.0-rc.7 and a custom font is still not applied to the SVG element I try to export with html2canvas.
@niklasvh could you please reopen this ticket and take a look when you have time? 🙇‍♂️

Pasted_Image_03_12_2020__14_18

@z1haze
Copy link

z1haze commented Dec 4, 2020

Please reopen this @niklasvh

@mkubdev
Copy link

mkubdev commented Dec 4, 2020

[Quick Fix working with the joint.js Library] : You could inject a custom attribute "font-family" in each svg node :

fontFamilyInjection :

private fontFamilyInjection (targetElem) {
      var svgElem = targetElem.getElementsByTagName("svg");
      for (const node of svgElem) {
        node.setAttribute("font-family", window.getComputedStyle(node, null).getPropertyValue("font-family"));
        node.replaceWith(node);
      }
}

html2canvas :

var domElement: any = document.getElementById('paper');

// Re-inject font-family
this.fontFamilyInjection(domElement);

html2canvas(domElement, { 
   scale: 3,
   allowTaint: true,
   useCORS: true
}).then(canvas => {

          // Do something...

});

@goliney
Copy link

goliney commented Dec 4, 2020

@mkubdev can you edit the jsfiddle to demonstrate your example works? It would be a great example.

@mkubdev
Copy link

mkubdev commented Dec 4, 2020

@garaboncias sure... i'll try. Maybe this is related to Google Font CDN?
You should first need to load the font on the style.css of your app, and then this quick fix should work :

@import url('https://fonts.googleapis.com/css2?family=Kanit&display=swap');

Second, you should try to download directly the 'Kanit' font from GoogleFonts and import it to your app => html2canvas works as expected.

https://jsfiddle.net/1yajtgb4/

@goliney
Copy link

goliney commented Dec 4, 2020

I converted the Kanit font to base64 format and still not able to see it applied:
https://jsfiddle.net/fqpcrvb0/
Pasted_Image_04_12_2020__11_53
So the problem is not with the Google Font CDN.

@mkubdev
Copy link

mkubdev commented Dec 4, 2020

@goliney Damned. You are right. I don't understand why i can't get it to work on jsfiddle but it's working on my apps.

I can make it work on jsfiddle with Jquery's way... This should be convertible to TypeScript : https://jsfiddle.net/9Lvwcnu8/

@goliney
Copy link

goliney commented Dec 4, 2020

@mkubdev WOW! This is awesome. Thank you for your help 🍺

@goliney
Copy link

goliney commented Dec 4, 2020

In case anyone needs it, here is the @mkubdev's working example without jQuery:
https://jsfiddle.net/jt1a5xv3/
Pasted_Image_04_12_2020__12_55

It's worth mentioning, that with foreignObjectRendering: true option, the font is still not loaded correctly. My app uses this option, unfortunately.
Pasted_Image_04_12_2020__12_57

@DungBuiDeveloper
Copy link

some one can help me with reactJS please?

@gonzofish
Copy link

gonzofish commented Apr 28, 2022

Just wanted to add another comment. We just used @mkubdev's concept in our application's code. However, one thing we did differently was to leverage document.styleSheets to grab the font faces. We also leverage async/await and Promise to keep the code more readable.

This is the code that converts from the stylesheets' font-face rules:

import axios, { AxiosResponse } from 'axios';

type FontConversion = {
  base64: string;
  url: string;
}

const convertFontsToBase64 = async () => {
  const fontFaceRules = getFontFaceRules();
  const urls = getFontFaceUrls(fontFaceRules);
  let fontFaceCss = fontFaceRules;

  if (urls && urls.length > 0) {
    const conversions = await convertFontsToDataUrls(urls);

    fontFaceCss = replaceUrls(fontFaceCss, conversions);
  }

  return fontFaceCss;
};

// find every font face rule and join them into 1 big string
const getFontFaceRules = () => (
  Array.from(document.styleSheets)
    .flatMap((sheet) => Array.from(sheet.cssRules))
    .filter((rule) => rule instanceof CSSFontFaceRule)
    .map((rule) => rule.cssText)
    .join('\n')
);

const getFontFaceUrls = (fontFaceRules: string) => (
  // one difference here is that our regex looks for
  // quotes around the URL
  fontFaceRules.match(/"?https?:\/\/[^ )]+/g);
};

const convertFontsToDataUrls = async (
  urls: string[],
) => {
  // this lookup will be used to track what URL is being replaced
  // since the originals could be in the form `"https://site.com/font.woff"`
  // or just `https://site.com/font.woff`
  const urlLookup: Record<string, string> = {};
  // here we do each font request (through Axios) + track the URL
  // for later use
  const fontFetches = urls.map((url) => {
    const strippedUrl = url.replace(/(^"|"$)/g, '');

    urlLookup[strippedUrl] = url;

    return axios.get<Blob>(strippedUrl, { responseType: 'blob' });
  });
  // settlePromises is an internal utility we have to wrap Promise.allSettled
  // it returns a list of errors & a list of successes
  const [errors, success] = await settlePromises(fontFetches);

  if (errors.length) {
    throw new Error('Could not generate fonts in dashboard charts');
  }

  const conversions = (success as AxiosResponse<Blob>[]).map(async ({ config, data }) => {
    const base64 = await convertFont(data);
    const { url } = config;
    const originalUrl = urlLookup[url!] || url!;

    return {
      base64,
      url: originalUrl,
    };
  });

  return Promise.all(conversions);
};

// convert font sets up the `FileReader`, but wraps it in a `Promise`
// so we don't have to track completed conversions
const convertFont = async (data: Blob) => (
  new Promise<string>((resolve, reject) => {
    const reader = new FileReader();

    reader.onloadend = () => {
      resolve(reader.result as string);
    };
    reader.onerror = () => {
      reject(Error('Could not convert font to base64'));
    };

    reader.readAsDataURL(data);
  })
);

// this is extracted just to make the code less
// cluttered
const replaceUrls = (
  initialCss: string,
  conversions: FontConversion[],
) => (
  conversions.reduce((css, { base64, url }) => (
    css.replace(url, base64)
  ), initialCss)
);

We then call convertFontsToBase64 from within an onclone (one of the html2canvas options`):

  html2canvas(node, {
    onclone: async (doc) => {
      await applySvgStyles(doc);
    },
  });

// ...later in the file...
const applySvgStyles = async (doc: Document) => {
  const fontFaceCss = await convertFontsToBase64();
  const svgs = clonedDocument.querySelectorAll('svg');

  if (svgs.length) {
    svgs.forEach((svgElement) => {
      const fontFaceTag = document.createElement('style');

      fontFaceTag.innerHTML = fontFaceCss;
      svgElement.prepend(fontFaceTag);
    });
  }
};

@coderfin
Copy link

I am using html2pdf which uses html2canvasunder the hood. I was also running into font issues with SVG and non-SVG elements. I was inspired by @gonzofish but needed some additional changes. Async/await wasn't working for me in the onclone callback - by the time it was run it was too late for my application. Also, we have a lot of heavy fonts on the page and cloning everything was slowing the browser way down. I wanted to be able to choose specific fonts and specific elements (not just SVG). This is what I came up with that works in my React application.

convertFontsToBase64.js

// Heavily inspired by: https://github.com/niklasvh/html2canvas/issues/1463#issuecomment-1112428054

const convertFontsToDataUrls = async (urls) => {
  const fontFetches = urls.map((url) => {
    return fetch(url).then(async (response) => ({
      url,
      base64: await new Promise(async (resolve) => {
        const reader = new FileReader()

        reader.onloadend = () => {
          resolve(reader.result)
        }

        reader.readAsDataURL(await response.blob())
      }),
    }))
  })

  return Promise.allSettled(fontFetches).then((responses) =>
    responses.filter((response) => response.status === 'fulfilled').map((response) => response.value)
  )
}

export default async ({ fonts }) => {
  const fontFaceRules = Array.from(document.styleSheets)
    .flatMap((sheet) => Array.from(sheet.cssRules))
    .filter((rule) => rule instanceof CSSFontFaceRule)
    .map((rule) => rule.cssText)
    .join('\n')
  const fontsFaces = fontFaceRules
    .match(/@font-face {[^}]*}/g)
    .map((fontFace) => {
      const urls = fontFace.match(/(?<=url\(")(.*?)(?="\))/g)
      return fontFace.match(/(?<=font-family: "?)([^"]*?)(?="?;)/g).map((fontFamily) => ({
        family: fontFamily.toLowerCase(),
        urls,
      }))
    })
    .flat()

  let fontFaceCss = fontFaceRules
  if (fontsFaces.length) {
    const urlsToConvert = fontsFaces.filter((font) => fonts.includes(font.family)).flatMap((font) => font.urls)
    const conversions = await convertFontsToDataUrls(urlsToConvert)

    fontFaceCss = conversions.reduce((css, { base64, url }) => css.replace(url, base64), fontFaceCss)
  }

  return fontFaceCss
}

html2canvas onclone option

onclone = (clonedDocument) => {
  if (processFonts) {
    const styles = document.querySelectorAll('style[data-id="keep-font-face"]')
    styles.forEach((style) => {
      const xpath = getXPathForElement(style.parentElement).replace(/\[\d+\]/g, '[1]')
      const clonedElement = getElementByXPath(xpath, clonedDocument)

      if (clonedElement) {
        clonedElement.appendChild(style.cloneNode(true))
      }
    })
  }
}

In a React Hook

document.querySelectorAll('[data-id="keep-font-face"]').forEach((style) => {
  style.remove()
})
if (fonts.current?.length) {
  const elements = [...(fontElements.current || []), ...(document.querySelectorAll('svg:not([role="img"]') || [])]
  const fontFaceCss = await convertFontsToBase64({ fonts: fonts.current.map((font) => font.toLowerCase()) })
  elements.forEach((element) => {
    const style = document.createElement('style')
    style.setAttribute('data-id', 'keep-font-face')
    style.innerHTML = fontFaceCss
    element.prepend(style)
  })
}

usage:

  • Set fontElements.current to a list of all the elements that need a custom font.
    • ie. fontElements.current = [elementRef.current]
  • Set fonts.current to a list of all the font-family names that should be cloned for use.
    • ie. fontElements.current = ['museo']

@annielinnik
Copy link

Ran into this issue. I used @gonzofish's code, prepended the SVG with a <style> attribute. However, it didn't work for me in Safari. So, I tweaked the solution a bit:

  1. Added a data attribute to the <text> element with a custom font inside my SVG.
  2. In applySvgStyles(), changed the query to search by this data-attribute, not for SVGs.

Now it's working in Safari too.

@dvalim
Copy link

dvalim commented Nov 29, 2023

Managed to fix this issue using the embed-fonts-as-base64-in-style-tag solution as well. Based on code from previous commenters, here is what I ended up using:

/** Uses FileReader to read a blob */
const blobToData = (blob: Blob): Promise<string> => {
    return new Promise((resolve) => {
        const reader = new FileReader();
        reader.onloadend = () => resolve(reader.result + '');
        reader.readAsDataURL(blob);
    })
}

/** Loads an external font and returns it as an embeddable font-face with base64 source */
const getFontFace = async (familyName: string, url: string) => {
    try {
        const response = await fetch(url);
        const blob = await response.blob();
        const fontData = await blobToData(blob);

        return `@font-face {
            font-family: "${familyName}";
            src: url("${fontData}");
        }`;
    } catch (err) {
        console.error('Error getting font face', err);
        return '';
    }
}

/** Appends a <style> element with provided css to target */
const appendStyle = (css: string, target: Element) => {
    const styleEl = document.createElement('style');
    styleEl.appendChild(document.createTextNode(css));
    target.appendChild(styleEl);
}

Then, before invoking html2canvas, simply:

const fontFace = await getFontFace('Your font family name', YourExternalFontUrl);
appendStyle(fontFace, yourSvgElement);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet