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.
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
Option | Type | Default | Description |
---|---|---|---|
scale | number | window.devicePixelRatio | The internal scale of the generated image. Prevents blurry output when you have a <canvas> in your render chain |
context | HTMLElement | document.body | The context node to source styles and dimensions from |
style | string | null | CSS styles to apply to the div that wraps the text inside the foreignObject . See below |
width | number | null | When 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
/**
* 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:
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