From 703ec5bec1c6a96c644a958fb95169ea2e4231f4 Mon Sep 17 00:00:00 2001 From: Tristan Blackwell Date: Fri, 10 Apr 2026 09:37:00 +0100 Subject: [PATCH 1/2] fix: escaping PDF Object Names (spot color name fix) --- CHANGELOG.md | 1 + lib/mixins/attachments.js | 4 ++-- lib/object.js | 44 ++++++++++++++++++++++++++++++++-- tests/unit/attachments.spec.js | 15 ++++++++++++ tests/unit/color.spec.js | 20 ++++++++++++++++ tests/unit/object.spec.js | 9 +++++++ 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 635e1112e..d8a9c7c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fix PDF/UA compliance issues in kitchen-sink-accessible example - Add bbox and placement options to PDFStructureElement for PDF/UA compliance - Extend `roundedRect` with `borderRadius` as number for all corners or per-corner array (CSS order) +- Fix PDF Name escaping for spot colors with spaces ([#1644](https://github.com/foliojs/pdfkit/issues/1644)) ### [v0.18.0] - 2026-03-14 diff --git a/lib/mixins/attachments.js b/lib/mixins/attachments.js index 063a76e6b..64b0b084a 100644 --- a/lib/mixins/attachments.js +++ b/lib/mixins/attachments.js @@ -36,7 +36,7 @@ export default { const match = /^data:(.*?);base64,(.*)$/.exec(src); if (match) { if (match[1]) { - refBody.Subtype = match[1].replace('/', '#2F'); + refBody.Subtype = match[1]; } data = Buffer.from(match[2], 'base64'); } else { @@ -61,7 +61,7 @@ export default { } // add optional subtype if (options.type) { - refBody.Subtype = options.type.replace('/', '#2F'); + refBody.Subtype = options.type; } // add checksum and size information diff --git a/lib/object.js b/lib/object.js index d4fcec371..cd49b17f7 100644 --- a/lib/object.js +++ b/lib/object.js @@ -9,6 +9,29 @@ import SpotColor from './spotcolor'; const pad = (str, length) => (Array(length + 1).join('0') + str).slice(-length); +// PDF Name objects must escape delimiter characters and whitespace. We keep +// non-ASCII characters unescaped for backward compatibility in existing output. +const isSafeNameChar = (char) => { + const code = char.charCodeAt(0); + if (code > 0x7f) return true; // keep non-ASCII characters as-is + + return ( + code > 0x20 && // exclude NUL/control chars + space (0x00-0x20) + code !== 0x7f && // exclude DEL + char !== '#' && // # (escape marker) + char !== '%' && // % (comment introducer) + char !== '(' && + char !== ')' && + char !== '/' && + char !== '<' && + char !== '>' && + char !== '[' && + char !== ']' && + char !== '{' && + char !== '}' + ); +}; + const escapableRe = /[\n\r\t\b\f()\\]/g; const escapable = { '\n': '\\n', @@ -38,10 +61,25 @@ const swapBytes = function (buff) { }; class PDFObject { + static escapeName(value) { + let escapedName = ''; + + for (const char of value) { + if (isSafeNameChar(char)) { + escapedName += char; + } else { + const code = char.charCodeAt(0); + escapedName += `#${code.toString(16).toUpperCase().padStart(2, '0')}`; + } + } + + return escapedName; + } + static convert(object, encryptFn = null) { // String literals are converted to the PDF name type if (typeof object === 'string') { - return `/${object}`; + return `/${PDFObject.escapeName(object)}`; // String objects are converted to PDF strings (UTF-16) } else if (object instanceof String) { @@ -112,7 +150,9 @@ class PDFObject { const out = ['<<']; for (let key in object) { const val = object[key]; - out.push(`/${key} ${PDFObject.convert(val, encryptFn)}`); + out.push( + `/${PDFObject.escapeName(key)} ${PDFObject.convert(val, encryptFn)}`, + ); } out.push('>>'); diff --git a/tests/unit/attachments.spec.js b/tests/unit/attachments.spec.js index dfea68289..d82376fdf 100644 --- a/tests/unit/attachments.spec.js +++ b/tests/unit/attachments.spec.js @@ -120,6 +120,21 @@ describe('file', () => { ]); }); + test('uses data URI MIME type as escaped subtype', () => { + const docData = logData(document); + document.file('data:text/plain;base64,ZXhhbXBsZSB0ZXh0', { + name: 'file.txt', + creationDate: date, + modifiedDate: date, + }); + document.end(); + + const dataStr = docData.map((item) => item.toString()).join('\n'); + expect(dataStr).toContain('/Subtype /text#2Fplain'); + expect(dataStr).not.toContain('/Subtype /text/plain'); + expect(dataStr).not.toContain('/Subtype /text#232Fplain'); // double escaped + }); + test('with hidden option', () => { const docData = logData(document); diff --git a/tests/unit/color.spec.js b/tests/unit/color.spec.js index 0b482205d..49a179459 100644 --- a/tests/unit/color.spec.js +++ b/tests/unit/color.spec.js @@ -56,4 +56,24 @@ describe('color', function () { '>>', ]); }); + + test('spot color escapes color name in Separation color space', function () { + const doc = new PDFDocument(); + const data = logData(doc); + doc.addSpotColor('PANTONE 295 C', 100, 53, 0, 67); + doc.fillColor('PANTONE 295 C').text('This text uses spaced spot color!'); + doc.end(); + + expect(data).toContainChunk([ + `8 0 obj`, + '[/Separation /PANTONE#20295#20C /DeviceCMYK <<\n' + + '/Range [0 1 0 1 0 1 0 1]\n' + + '/C0 [0 0 0 0]\n' + + '/C1 [1 0.53 0 0.67]\n' + + '/FunctionType 2\n' + + '/Domain [0 1]\n' + + '/N 1\n' + + '>>]', + ]); + }); }); diff --git a/tests/unit/object.spec.js b/tests/unit/object.spec.js index de2fe0fed..2598ed859 100644 --- a/tests/unit/object.spec.js +++ b/tests/unit/object.spec.js @@ -10,10 +10,19 @@ describe('PDFObject', () => { expect(PDFObject.convert('αβγδ')).toEqual('/αβγδ'); }); + test('string literal with spaces should escape', () => { + expect(PDFObject.convert('PANTONE 295 C')).toEqual('/PANTONE#20295#20C'); + }); + test('String object', () => { expect(PDFObject.convert(new String('test'))).toEqual('(test)'); }); + test('String object with spaces should not escape', () => { + // Objects are not used to represent PDF object names, so they should not be escaped + expect(PDFObject.convert(new String('test with spaces'))).toEqual('(test with spaces)'); + }); + test('String object with unicode', () => { const result = PDFObject.convert(new String('αβγδ')); expect(result.length).toEqual(12); From 481c013cd393e9b6134e42cec31e114158a04a24 Mon Sep 17 00:00:00 2001 From: Tristan Blackwell Date: Fri, 10 Apr 2026 10:15:56 +0100 Subject: [PATCH 2/2] fix linting issue --- tests/unit/object.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/object.spec.js b/tests/unit/object.spec.js index 2598ed859..9caa1d4f3 100644 --- a/tests/unit/object.spec.js +++ b/tests/unit/object.spec.js @@ -19,8 +19,10 @@ describe('PDFObject', () => { }); test('String object with spaces should not escape', () => { - // Objects are not used to represent PDF object names, so they should not be escaped - expect(PDFObject.convert(new String('test with spaces'))).toEqual('(test with spaces)'); + // Objects are not used to represent PDF object names directly, so they should not be escaped + expect(PDFObject.convert(new String('test with spaces'))).toEqual( + '(test with spaces)', + ); }); test('String object with unicode', () => {