Skip to content

Word-wrapped text and html on images/canvas

When I was asked render some text on an image to hide the text from the search engine crawlers, I thought it would be straightforward. As usual, I was wrong.

I followed Rik Schennink's approach to use SVG's foreignObject to render the text and then draw that SVG to an img or canvas element.

However, the resulting image does not integrate well with the rest of the design as all document styles are lost when the SVG is rendered as an src attribute of an img element. Only basic user agent styles remain since the SVG is rendered in a detached context.

Soooo I decided to make a very small (<2k) utility that allows you to render markup on an image while trying to keep the design close to other elements the location of the document where the image is then inserted.

Demo

Most importantly, here's a demo of the utility in action.

Usage

the function is asynchronous and returns a promise that resolves to an img element.

js
const options = {};

//async
const img = await textToImage('Hello, World!', options);

//promise
textToImage('Hello, World!', options).then(img => {
  //do something with the img
});

The text argument contains the markup or text that you want to render. Make sure to read about the xhtml and DOM issues below if you're experiencing blank output.

Options

OptionTypeDefaultDescription
scalenumberwindow.devicePixelRatioThe internal scale of the generated image. Prevents blurry output when you have a <canvas> in your render chain
contextHTMLElementdocument.bodyThe context node to source styles and dimensions from
stylestringnullCSS styles to apply to the div that wraps the text inside the foreignObject. See below
widthnumbernullWhen given, this will override the width derived from context

Styles are inserted to the div-selector that matches the div inside the foreignObject. If you want to apply styles to elements inside that div (i.e. your markup), use CSS Nesting to target those elements OR use inline styles.

The textToImage function

js
/**
 * convert markup to image.
 * integrate, package and minify this function as needed.
 *
 * @author: Martin Tillmann <mtillmann@gmail.com>
 * @license: MIT
 */

async function textToImage (text, options = {}) {
  options = {
    scale: window.devicePixelRatio,
    context: document.body,
    ...options
  }

  const width = options.width ?? options.context.getBoundingClientRect().width
  const rawComputedStyle = window.getComputedStyle(options.context)
  const computedStyle = Object.values(rawComputedStyle).map((key) => [key, rawComputedStyle.getPropertyValue(key)])

  const styles = computedStyle.reduce((acc, [key, value]) => {
    if (/^line-|background|color|font|text/.test(key) && !['normal', 'auto', ''].includes(value)) {
      key = key.replaceAll(/[A-Z]/g, m => `-${m.toLowerCase()}`)
      acc.push(`${key}:${value}`)
    }
    return acc
  }, []).join(';')

  let svg = [
        `<svg width="${width}" height="0" xmlns="http://www.w3.org/2000/svg">`,
        '<foreignObject x="0" y="0" width="100%" height="100%">',
        '<style>',
        `#foreignObject-root { 
                ${styles}; 
                transform: scale(1); 
                transform-origin:0 0; 
                width: ${width}px;
                padding-bottom: .5%;
                ${options.styles};
            }`,
        '</style>',
        '<div id="foreignObject-root" xmlns="http://www.w3.org/1999/xhtml">',
        `${text}`,
        '</div>',
        '</foreignObject>',
        '</svg>'
  ].join('')

  // use iframe to avoid having the svg tainted by document styles
  const iframe = document.createElement('iframe')
  iframe.style.position = 'absolute'
  iframe.style.left = '-9999px'

  document.body.appendChild(iframe)
  iframe.contentDocument.body.innerHTML = svg
  const contentHeight = iframe.contentDocument.body.querySelector('svg foreignObject > div').getBoundingClientRect().height
  iframe.remove()

  // apply scaling and height to the svg here because the dims
  // will glitch if applied in the svg string before determining the content height
  svg = svg
    .replace('height="0"', `height="${contentHeight * options.scale}"`)
    .replace('transform: scale(1)', `transform: scale(${options.scale})`)
    .replace(`width="${width}"`, `width="${width * options.scale}"`)

  svg = unescape(encodeURIComponent(svg))

  const img = document.createElement('img')
  img.width = width
  img.height = contentHeight

  await new Promise((resolve) => { img.onload = resolve; img.src = `data:image/svg+xml;base64,${btoa(svg)}` })

  return img
}

Using with Canvas

Applying the function's output to a canvas element is straightforward:

js
const img = await textToImage('Hello, World!')

const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.style.width = img.width + 'px'
canvas.style.height = img.height + 'px'
canvas.getContext('2d').drawImage(img, 0, 0)

SEO, hiding text from crawlers and accessibility

If you want to superficially hide text from crawlers, use the canvas method. The text will probably not be indexed by search engines. However, consider how you source the text - if it's part of the DOM tree, it probably will be indexed. Also consider the accessibility implications of using this method: screen readers will not be able to read the text and the moment you provide alternative text, that alternative text will be visible to the crawlers as well.

If you use the img method, the text will most likely be indexed by search engines, since the content is part of the svg markup that is rendered as an image.

Issues

Fonts

External Webfonts will not work in an SVG that is converted to an image.

Make sure that you specify a web-safe fallback.

As Rik Schennink points out, fonts must be embedded in the SVG as no other method will survive the conversion to an image. Thomas Yip has a solid article that explores the issue in more detail.

Line height

When no line-height is set or found on the context-node's computed style, the converted image's line height will be off. To avoid this, either set the line-height on the context-node (or any of its ascendants) or pass it in the style option.

Cut-off at the bottom

As a combination of both issues above, the image may be cut off at the bottom. To avoid this, you can pass padding-bottom in the style option. The default value is 0.5%.

You can also have your content have some spacing at the bottom to avoid the cut-off.

XHTML and DOM

Since SVG's foreignObject inner div must use the xhtml namespace, the markup that's passed into the foreignObject must be valid xhtml.

This basically means that you need to use <br /> instead of <br> and <img src="..." /> instead of <img src="...">.

When using html5 DOM sources for images, you must make sure that those tags are the correct format as your innerHTML will likely be plain html(<br>), even if you authored it as xhtml(<br />).

If your content is not valid xhtml, the foreignObject will not render the content and you will see no warnings or errors in the console.

The Safari OCR Situation

Safari will automatically OCR all text off images. This means that any text in an image will be selectable by users. The only way to prevent this is to use a plain <canvas> element. See the demo for an example.

Conclusion

The utility is a small step towards making it easier to render text on images. It's not perfect, but it's a start. I hope you find it useful.

There are more powerful libraries (html2canvas) or approaches out there that can do this and more, but I wanted to keep it simple and lightweight.

Maybe I'll add more features in the future, especially to collect and inline styles from the nodes of the markup that is rendered.


Clicking this button loads third-party content from utteranc.es and github.com