diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26fb99743..8a8077ec1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](http://semver.org/).
### Changed
### Added
### Fixed
+* Decode percent-encoded image data URLs (#2126)
* Load images from Node.js object URLs (#2525)
3.2.3
diff --git a/lib/image.js b/lib/image.js
index a8713b61f..f266c1cec 100644
--- a/lib/image.js
+++ b/lib/image.js
@@ -13,6 +13,7 @@
const bindings = require('./bindings')
const Image = module.exports = bindings.Image
const util = require('util')
+const parseDataURL = require('./parse-data-url')
const { resolveObjectURL } = require('node:buffer')
const { GetSource, SetSource } = bindings
@@ -40,11 +41,7 @@ Object.defineProperty(Image.prototype, 'src', {
if (typeof val === 'string') {
if (/^\s*data:/.test(val)) { // data: URI
- const commaI = val.indexOf(',')
- // 'base64' must come before the comma
- const isBase64 = val.lastIndexOf('base64', commaI) !== -1
- const content = val.slice(commaI + 1)
- setSource(this, Buffer.from(content, isBase64 ? 'base64' : 'utf8'), val)
+ setSource(this, parseDataURL(val), val)
} else if (/^\s*blob:nodedata:/.test(val)) { // Node.js object URL
const blob = resolveObjectURL(val)
if (!blob) {
diff --git a/lib/parse-data-url.js b/lib/parse-data-url.js
new file mode 100644
index 000000000..ba48b3c18
--- /dev/null
+++ b/lib/parse-data-url.js
@@ -0,0 +1,38 @@
+'use strict'
+
+// Decode the payload as bytes: decodeURIComponent would reject binary images
+// containing non-UTF-8 bytes and literal percentages in otherwise valid SVGs.
+module.exports = function parseDataURL (url) {
+ const commaI = url.indexOf(',')
+ const isBase64 = url.lastIndexOf('base64', commaI) !== -1
+ const content = url.slice(commaI + 1)
+ if (!content.includes('%')) {
+ return Buffer.from(content, isBase64 ? 'base64' : 'utf8')
+ }
+
+ const buffer = Buffer.from(content)
+ let length = 0
+ for (let i = 0; i < buffer.length; i++) {
+ if (buffer[i] === 0x25 && i + 2 < buffer.length) {
+ const high = hexValue(buffer[i + 1])
+ const low = hexValue(buffer[i + 2])
+ if (high !== -1 && low !== -1) {
+ buffer[length++] = (high << 4) | low
+ i += 2
+ continue
+ }
+ }
+ buffer[length++] = buffer[i]
+ }
+
+ const decoded = buffer.subarray(0, length)
+ // The data-URL processing algorithm percent-decodes before base64 decoding.
+ return isBase64 ? Buffer.from(decoded.toString('latin1'), 'base64') : decoded
+}
+
+function hexValue (byte) {
+ if (byte >= 0x30 && byte <= 0x39) return byte - 0x30
+ if (byte >= 0x41 && byte <= 0x46) return byte - 0x41 + 10
+ if (byte >= 0x61 && byte <= 0x66) return byte - 0x61 + 10
+ return -1
+}
diff --git a/test/image.test.js b/test/image.test.js
index 9dd6610e6..a3e33efa3 100644
--- a/test/image.test.js
+++ b/test/image.test.js
@@ -110,6 +110,34 @@ describe('Image', function () {
}
})
+ it('loads percent-encoded PNG data URLs as bytes, not UTF-8 text', async function () {
+ const png = fs.readFileSync(pngCheckers)
+ const encoded = Array.from(png, byte => '%' + byte.toString(16).padStart(2, '0')).join('')
+ const dataURL = `data:image/png,${encoded}`
+ const img = await loadImage(dataURL)
+ const expected = await loadImage(png)
+
+ assert.strictEqual(img.src, dataURL)
+ assert.strictEqual(img.width, 2)
+ assert.strictEqual(img.height, 2)
+ const pixels = image => {
+ const ctx = createCanvas(2, 2).getContext('2d')
+ ctx.drawImage(image, 0, 0)
+ return ctx.getImageData(0, 0, 2, 2).data
+ }
+ assert.deepStrictEqual(pixels(img), pixels(expected))
+ })
+
+ it('percent-decodes base64 image data before base64 decoding', async function () {
+ const base64 = fs.readFileSync(pngCheckers, 'base64')
+ const encoded = Array.from(base64, char => '%' + char.charCodeAt(0).toString(16)).join('')
+ const dataURL = `data:image/png;base64,${encoded}`
+ const img = await loadImage(dataURL)
+ assert.strictEqual(img.src, dataURL)
+ assert.strictEqual(img.width, 2)
+ assert.strictEqual(img.height, 2)
+ })
+
it('rejects revoked object URLs', async function () {
const objectURL = URL.createObjectURL(new Blob([
fs.readFileSync(pngClock)
@@ -175,6 +203,44 @@ describe('Image', function () {
})
})
+ it('loads percent-encoded SVG data URLs (GH-2126)', async function () {
+ const svg = ''
+ const payloads = [
+ encodeURIComponent(svg),
+ svg.replace(/#/g, '%23'),
+ encodeURIComponent(svg).toLowerCase()
+ ]
+ for (const payload of payloads) {
+ const dataURL = `data:image/svg+xml;charset=utf-8,${payload}`
+ const img = await loadImage(dataURL)
+ assert.strictEqual(img.src, dataURL)
+ assert.strictEqual(img.complete, true)
+ assert.strictEqual(img.onerror, null)
+ assert.strictEqual(img.onload, null)
+ const ctx = createCanvas(2, 2).getContext('2d')
+ ctx.drawImage(img, 0, 0)
+ assert.deepStrictEqual(Array.from(ctx.getImageData(0, 0, 1, 1).data), [255, 0, 255, 255])
+ }
+ })
+
+ it('preserves raw SVG percentages and literal invalid escapes', async function () {
+ const svg = ''
+ const img = await loadImage(`data:image/svg+xml,${svg}`)
+ const ctx = createCanvas(2, 2).getContext('2d')
+ ctx.drawImage(img, 0, 0)
+ assert.deepStrictEqual(Array.from(ctx.getImageData(0, 0, 1, 1).data), [255, 0, 0, 255])
+ })
+
+ it('reports invalid percent-encoded image data through onerror', function (done) {
+ const img = new Image()
+ img.onload = () => done(new Error('Invalid image unexpectedly loaded'))
+ img.onerror = err => {
+ assert(err instanceof Error)
+ done()
+ }
+ img.src = 'data:image/png,%FF%00%FE'
+ })
+
it('calls Image#onload multiple times', function () {
return loadImage(pngClock).then((img) => {
let onloadCalled = 0
diff --git a/test/parse-data-url.test.js b/test/parse-data-url.test.js
new file mode 100644
index 000000000..59a77f825
--- /dev/null
+++ b/test/parse-data-url.test.js
@@ -0,0 +1,37 @@
+/* eslint-env mocha */
+
+'use strict'
+
+const assert = require('assert')
+const parseDataURL = require('../lib/parse-data-url')
+
+describe('Data URL payload decoding', function () {
+ const cases = [
+ ['raw UTF-8', 'data:,café 😀', Buffer.from('café 😀')],
+ ['encoded UTF-8', 'data:,%63af%C3%A9%20%F0%9F%98%80', Buffer.from('café 😀')],
+ ['mixed raw and encoded UTF-8', 'data:,café%20😀', Buffer.from('café 😀')],
+ ['binary bytes', 'data:,%00%80%FF', Buffer.from([0, 128, 255])],
+ ['hex letter case', 'data:,%aB%Cd%eF', Buffer.from([171, 205, 239])],
+ ['literal plus', 'data:,a+b%2Bc', Buffer.from('a+b+c')],
+ ['one decoding pass', 'data:,%2520%2525', Buffer.from('%20%25')],
+ ['invalid escapes', 'data:,%%2%GG%2G%G2%20%', Buffer.from('%%2%GG%2G%G2 %')],
+ ['empty payload', 'data:,', Buffer.alloc(0)],
+ ['raw base64', 'data:;base64,AP/+', Buffer.from([0, 255, 254])],
+ ['encoded base64', 'data:;base64,AP%2f%2b', Buffer.from([0, 255, 254])],
+ ['encoded base64 padding', 'data:;base64,/w%3D%3D', Buffer.from([255])],
+ ['encoded base64 whitespace', 'data:;base64,%20/w%3D%3D%0A', Buffer.from([255])],
+ ['commas in payload', 'data:text/plain;charset=utf-8,a%2Cb,c', Buffer.from('a,b,c')]
+ ]
+
+ for (const [name, dataURL, expected] of cases) {
+ it(name, function () {
+ assert.deepStrictEqual(parseDataURL(dataURL), expected)
+ })
+ }
+
+ it('decodes every possible byte', function () {
+ const expected = Buffer.from(Array.from({ length: 256 }, (_, byte) => byte))
+ const encoded = Array.from(expected, byte => '%' + byte.toString(16).padStart(2, '0')).join('')
+ assert.deepStrictEqual(parseDataURL(`data:,${encoded}`), expected)
+ })
+})