Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions lib/mixins/attachments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
44 changes: 42 additions & 2 deletions lib/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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('>>');
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/attachments.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
20 changes: 20 additions & 0 deletions tests/unit/color.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' +
'>>]',
]);
});
});
11 changes: 11 additions & 0 deletions tests/unit/object.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,21 @@ 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 directly, 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);
Expand Down
Loading