From 15fb90d5e6524f87ddbbed08e30db935ad89d7cc Mon Sep 17 00:00:00 2001 From: Diego Muracciole Date: Thu, 13 Aug 2026 01:07:42 +0200 Subject: [PATCH] Use Uint8Array instead of Buffer Buffer is a Uint8Array subclass, and Readable.push() converts Uint8Array to Buffer zero-copy, so Buffer inputs and Buffer output chunks keep working on Node. --- CHANGELOG.md | 1 + lib/binary.js | 43 +++++++++++++++++++++++++++++++++++ lib/document.js | 3 ++- lib/font/embedded.js | 2 +- lib/image.js | 4 ++-- lib/image/png.js | 8 +++---- lib/mixins/attachments.js | 9 ++++---- lib/mixins/metadata.js | 2 +- lib/object.js | 38 ++++++++++--------------------- lib/reference.js | 5 ++-- lib/security.js | 22 +++++++++--------- lib/virtual-fs.js | 9 ++++++-- tests/unit/helpers.js | 2 +- tests/unit/security.spec.js | 26 ++++++++++----------- tests/unit/virtual-fs.spec.js | 20 ++++++++-------- 15 files changed, 116 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0201e600..d3a753618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [BREAKING CHANGE] Stop automatically uppercasing annotation option keys. - Do not mutate options passed to `doc.annotate()` and its convenience methods (link, note, strike, lineAnnotation, rectAnnotation, ellipseAnnotation, textAnnotation, fileAnnotation) - Persist font options when adding a new page. Fixes #1739 +- Use `Uint8Array` instead of Node's `Buffer` internally. `pdfkit/virtual-fs` now returns a `Uint8Array` from `readFileSync()` ### [v0.19.1] - 2026-06-10 diff --git a/lib/binary.js b/lib/binary.js index 9b9eeda0e..28070d192 100644 --- a/lib/binary.js +++ b/lib/binary.js @@ -12,6 +12,14 @@ export const toBinaryString = (bytes) => { return out; }; +export const fromBinaryString = (str) => { + const out = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + out[i] = str.charCodeAt(i) & 0xff; + } + return out; +}; + export const fromBase64 = (b64) => { const binary = atob(b64); const out = new Uint8Array(binary.length); @@ -21,6 +29,41 @@ export const fromBase64 = (b64) => { return out; }; +export const toBase64 = (bytes) => { + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + const end = Math.min(i + chunkSize, bytes.length); + binary += String.fromCharCode.apply(null, bytes.subarray(i, end)); + } + return btoa(binary); +}; + +export const concat = (chunks) => { + let length = 0; + for (const chunk of chunks) length += chunk.length; + + const out = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +}; + +export const toUTF16BE = (str) => { + const out = new Uint8Array((str.length + 1) * 2); + out[0] = 0xfe; + out[1] = 0xff; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + out[i * 2 + 2] = code >> 8; + out[i * 2 + 3] = code & 0xff; + } + return out; +}; + export const readUInt16BE = (bytes, offset = 0) => ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0; diff --git a/lib/document.js b/lib/document.js index caad45846..1b83d9921 100644 --- a/lib/document.js +++ b/lib/document.js @@ -23,6 +23,7 @@ import LineWrapper from './line_wrapper'; import SubsetMixin from './mixins/subsets'; import TableMixin from './mixins/table'; import MetadataMixin from './mixins/metadata'; +import { fromBinaryString } from './binary'; class PDFDocument extends stream.Readable { constructor(options = {}) { @@ -265,7 +266,7 @@ class PDFDocument extends stream.Readable { _write(data) { if (!(data instanceof Uint8Array)) { - data = Buffer.from(data + '\n', 'binary'); + data = fromBinaryString(data + '\n'); } this.push(data); diff --git a/lib/font/embedded.js b/lib/font/embedded.js index 5b5c91edf..b02273b21 100644 --- a/lib/font/embedded.js +++ b/lib/font/embedded.js @@ -182,7 +182,7 @@ class EmbeddedFont extends PDFFont { if (this.document.subset && this.document.subset === 1) { const maxCID = this.widths.length - 1; - const cidSetBuffer = Buffer.alloc(Math.ceil((maxCID + 1) / 8), 0); + const cidSetBuffer = new Uint8Array(Math.ceil((maxCID + 1) / 8)); for (let cid = 0; cid <= maxCID; cid++) { if (this.widths[cid] != null) { cidSetBuffer[Math.floor(cid / 8)] |= 0x80 >> cid % 8; diff --git a/lib/image.js b/lib/image.js index 528cfa6f7..ff84486fa 100644 --- a/lib/image.js +++ b/lib/image.js @@ -11,10 +11,10 @@ import PNG from './image/png'; class PDFImage { static open(src, label) { let data; - if (Buffer.isBuffer(src)) { + if (src instanceof Uint8Array) { data = src; } else if (src instanceof ArrayBuffer) { - data = Buffer.from(new Uint8Array(src)); + data = new Uint8Array(src); } else { const match = /^data:.+?;base64,(.*)$/.exec(src); if (match) { diff --git a/lib/image/png.js b/lib/image/png.js index b526d1193..f3b405c38 100644 --- a/lib/image/png.js +++ b/lib/image/png.js @@ -50,7 +50,7 @@ class PNGImage { } else { // embed the color palette in the PDF as an object stream const palette = document.ref(); - palette.end(Buffer.from(image.palette)); + palette.end(new Uint8Array(image.palette)); // build the color space array for the image obj.data['ColorSpace'] = [ @@ -126,8 +126,8 @@ class PNGImage { let a, p; const colorCount = this.image.colors; const pixelCount = this.width * this.height; - const imgData = Buffer.alloc(pixelCount * colorCount); - const alphaChannel = Buffer.alloc(pixelCount); + const imgData = new Uint8Array(pixelCount * colorCount); + const alphaChannel = new Uint8Array(pixelCount); let i = (p = a = 0); const len = pixels.length; @@ -152,7 +152,7 @@ class PNGImage { const transparency = this.image.transparency.indexed; const isInterlaced = this.image.interlaceMethod === 1; return this.image.decodePixels((pixels) => { - const alphaChannel = Buffer.alloc(this.width * this.height); + const alphaChannel = new Uint8Array(this.width * this.height); let i = 0; for (let j = 0, end = pixels.length; j < end; j++) { diff --git a/lib/mixins/attachments.js b/lib/mixins/attachments.js index 60950383b..52d3b0423 100644 --- a/lib/mixins/attachments.js +++ b/lib/mixins/attachments.js @@ -1,11 +1,12 @@ import fs from 'fs'; import { md5Hex } from '../crypto/md5'; import { escapeName } from '../object.js'; +import { fromBase64 } from '../binary'; export default { /** * Embed contents of `src` in PDF - * @param {Buffer | ArrayBuffer | string} src input Buffer, ArrayBuffer, base64 encoded string or path to file + * @param {Buffer | Uint8Array | ArrayBuffer | string} src input Buffer, Uint8Array, ArrayBuffer, base64 encoded string or path to file * @param {object} options * * options.name: filename to be shown in PDF, will use `src` if none set * * options.type: filetype to be shown in PDF @@ -29,17 +30,17 @@ export default { if (!src) { throw new Error('No src specified'); } - if (Buffer.isBuffer(src)) { + if (src instanceof Uint8Array) { data = src; } else if (src instanceof ArrayBuffer) { - data = Buffer.from(new Uint8Array(src)); + data = new Uint8Array(src); } else { const match = /^data:(.*?);base64,(.*)$/.exec(src); if (match) { if (match[1]) { refBody.Subtype = escapeName(match[1]); } - data = Buffer.from(match[2], 'base64'); + data = fromBase64(match[2]); } else { data = fs.readFileSync(src); if (!data) { diff --git a/lib/mixins/metadata.js b/lib/mixins/metadata.js index 67b2b6cde..6857d7878 100644 --- a/lib/mixins/metadata.js +++ b/lib/mixins/metadata.js @@ -93,7 +93,7 @@ export default { Subtype: 'XML', }); this.metadataRef.compress = false; - this.metadataRef.write(Buffer.from(this.metadata.getXML(), 'utf-8')); + this.metadataRef.write(new TextEncoder().encode(this.metadata.getXML())); this.metadataRef.end(); this._root.data.Metadata = this.metadataRef; } diff --git a/lib/object.js b/lib/object.js index b69dad533..ff8e0dbd0 100644 --- a/lib/object.js +++ b/lib/object.js @@ -3,9 +3,11 @@ PDFObject - converts JavaScript types into their corresponding PDF types. By Devon Govett */ +import { bytesToHex } from '@noble/hashes/utils'; import PDFAbstractReference from './abstract_reference'; import PDFTree from './tree'; import SpotColor from './spotcolor'; +import { fromBinaryString, toBinaryString, toUTF16BE } from './binary'; const pad = (str, length) => (Array(length + 1).join('0') + str).slice(-length); @@ -58,22 +60,6 @@ export const escapeName = function (name) { return escapedName; }; -// Convert little endian UTF-16 to big endian -const swapBytes = function (buff) { - const l = buff.length; - if (l & 0x01) { - throw new Error('Buffer length must be even'); - } else { - for (let i = 0, end = l - 1; i < end; i += 2) { - const a = buff[i]; - buff[i] = buff[i + 1]; - buff[i + 1] = a; - } - } - - return buff; -}; - class PDFObject { static convert(object, encryptFn = null) { // String literals are converted to the PDF name type @@ -93,28 +79,28 @@ class PDFObject { } // If so, encode it as big endian UTF-16 - let stringBuffer; + let stringBytes; if (isUnicode) { - stringBuffer = swapBytes(Buffer.from(`\ufeff${string}`, 'utf16le')); + stringBytes = toUTF16BE(string.valueOf()); } else { - stringBuffer = Buffer.from(string.valueOf(), 'ascii'); + stringBytes = fromBinaryString(string.valueOf()); } // Encrypt the string when necessary if (encryptFn) { - string = encryptFn(stringBuffer).toString('binary'); - } else { - string = stringBuffer.toString('binary'); + stringBytes = encryptFn(stringBytes); } + string = toBinaryString(stringBytes); + // Escape characters as required by the spec string = string.replace(escapableRe, (c) => escapable[c]); return `(${string})`; - // Buffers are converted to PDF hex strings - } else if (Buffer.isBuffer(object)) { - return `<${object.toString('hex')}>`; + // Byte arrays are converted to PDF hex strings + } else if (object instanceof Uint8Array) { + return `<${bytesToHex(object)}>`; } else if ( object instanceof PDFAbstractReference || object instanceof PDFTree || @@ -133,7 +119,7 @@ class PDFObject { // Encrypt the string when necessary if (encryptFn) { - string = encryptFn(Buffer.from(string, 'ascii')).toString('binary'); + string = toBinaryString(encryptFn(fromBinaryString(string))); // Escape characters as required by the spec string = string.replace(escapableRe, (c) => escapable[c]); diff --git a/lib/reference.js b/lib/reference.js index 71d33767a..0e27bd531 100644 --- a/lib/reference.js +++ b/lib/reference.js @@ -6,6 +6,7 @@ By Devon Govett import zlib from '#zlib'; import PDFAbstractReference from './abstract_reference'; import PDFObject from './object'; +import { concat, fromBinaryString } from './binary'; class PDFReference extends PDFAbstractReference { constructor(document, id, data = {}) { @@ -21,7 +22,7 @@ class PDFReference extends PDFAbstractReference { write(chunk) { if (!(chunk instanceof Uint8Array)) { - chunk = Buffer.from(chunk + '\n', 'binary'); + chunk = fromBinaryString(chunk + '\n'); } this.uncompressedLength += chunk.length; @@ -50,7 +51,7 @@ class PDFReference extends PDFAbstractReference { : null; if (this.buffer.length) { - this.buffer = Buffer.concat(this.buffer); + this.buffer = concat(this.buffer); if (this.compress) { this.buffer = zlib.deflateSync(this.buffer); } diff --git a/lib/security.js b/lib/security.js index 9ddd9706c..f0ffcd849 100644 --- a/lib/security.js +++ b/lib/security.js @@ -23,7 +23,7 @@ class PDFSecurity { infoStr += `${key}: ${info[key].valueOf()}\n`; } - return Buffer.from(md5Hash(infoStr)); + return md5Hash(infoStr); } static generateRandomWordArray(bytes) { @@ -147,8 +147,8 @@ class PDFSecurity { encDict.StrF = 'StdCF'; } encDict.R = r; - encDict.O = Buffer.from(ownerPasswordEntry); - encDict.U = Buffer.from(userPasswordEntry); + encDict.O = ownerPasswordEntry; + encDict.U = userPasswordEntry; encDict.P = permissions; } @@ -204,12 +204,12 @@ class PDFSecurity { encDict.StmF = 'StdCF'; encDict.StrF = 'StdCF'; encDict.R = 5; - encDict.O = Buffer.from(ownerPasswordEntry); - encDict.OE = Buffer.from(ownerEncryptionKeyEntry); - encDict.U = Buffer.from(userPasswordEntry); - encDict.UE = Buffer.from(userEncryptionKeyEntry); + encDict.O = ownerPasswordEntry; + encDict.OE = ownerEncryptionKeyEntry; + encDict.U = userPasswordEntry; + encDict.UE = userEncryptionKeyEntry; encDict.P = permissions; - encDict.Perms = Buffer.from(permsEntry); + encDict.Perms = permsEntry; } getEncryptFn(obj, gen) { @@ -230,7 +230,7 @@ class PDFSecurity { let key = md5Hash(digest); const keyLen = Math.min(16, this.keyBits / 8 + 5); key = key.slice(0, keyLen); - return (buffer) => Buffer.from(rc4(new Uint8Array(buffer), key)); + return (buffer) => rc4(buffer, key); } let key; @@ -245,8 +245,8 @@ class PDFSecurity { const iv = PDFSecurity.generateRandomWordArray(16); return (buffer) => { - const encrypted = aesCbcEncrypt(new Uint8Array(buffer), key, iv, true); - return Buffer.from(concatBytes(iv, encrypted)); + const encrypted = aesCbcEncrypt(buffer, key, iv, true); + return concatBytes(iv, encrypted); }; } diff --git a/lib/virtual-fs.js b/lib/virtual-fs.js index 029189d7f..a6d4494d4 100644 --- a/lib/virtual-fs.js +++ b/lib/virtual-fs.js @@ -1,3 +1,5 @@ +import { fromBase64, toBase64 } from './binary'; + class VirtualFileSystem { constructor() { this.files = {}; @@ -12,10 +14,13 @@ class VirtualFileSystem { if (encoding) { // return a string - return typeof data === 'string' ? data : data.toString(encoding); + if (typeof data === 'string') return data; + return encoding === 'base64' + ? toBase64(data) + : new TextDecoder(encoding).decode(data); } - return Buffer.from(data, typeof data === 'string' ? 'base64' : undefined); + return typeof data === 'string' ? fromBase64(data) : new Uint8Array(data); } writeFileSync(fileName, content) { diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 2c189d66f..792b3dcc9 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -25,7 +25,7 @@ function logData(doc) { const loggedData = []; const originalMethod = doc._write; doc._write = function (data) { - loggedData.push(data); + loggedData.push(data instanceof Uint8Array ? Buffer.from(data) : data); originalMethod.call(this, data); }; return loggedData; diff --git a/tests/unit/security.spec.js b/tests/unit/security.spec.js index 7a6d875c1..d9008ef52 100644 --- a/tests/unit/security.spec.js +++ b/tests/unit/security.spec.js @@ -17,13 +17,13 @@ function createMockDocument(id = null) { describe('PDFSecurity', () => { describe('generateFileID', () => { - test('returns 16-byte Buffer', () => { + test('returns 16 bytes', () => { const info = { CreationDate: new Date('2024-01-01T00:00:00Z'), Title: 'Test', }; const result = PDFSecurity.generateFileID(info); - expect(Buffer.isBuffer(result)).toBe(true); + expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBe(16); }); @@ -112,8 +112,8 @@ describe('PDFSecurity', () => { pdfVersion: '1.3', }); - expect(Buffer.isBuffer(security.dictionary.data.O)).toBe(true); - expect(Buffer.isBuffer(security.dictionary.data.U)).toBe(true); + expect(security.dictionary.data.O).toBeInstanceOf(Uint8Array); + expect(security.dictionary.data.U).toBeInstanceOf(Uint8Array); expect(security.dictionary.data.O.length).toBe(32); expect(security.dictionary.data.U.length).toBe(32); }); @@ -130,7 +130,7 @@ describe('PDFSecurity', () => { const plaintext = Buffer.from('Hello, World!'); const encrypted = encryptFn(plaintext); - expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted).toBeInstanceOf(Uint8Array); expect(encrypted.length).toBe(plaintext.length); expect(encrypted).not.toEqual(plaintext); }); @@ -173,7 +173,7 @@ describe('PDFSecurity', () => { const plaintext = Buffer.from('Test data'); const encrypted = encryptFn(plaintext); - expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted).toBeInstanceOf(Uint8Array); expect(encrypted).not.toEqual(plaintext); }); }); @@ -216,7 +216,7 @@ describe('PDFSecurity', () => { const plaintext = Buffer.from('Test data for AES'); const encrypted = encryptFn(plaintext); - expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted).toBeInstanceOf(Uint8Array); // AES output includes 16-byte IV prefix expect(encrypted.length).toBeGreaterThan(plaintext.length); // First 16 bytes are IV @@ -248,11 +248,11 @@ describe('PDFSecurity', () => { pdfVersion: '1.7ext3', }); - expect(Buffer.isBuffer(security.dictionary.data.O)).toBe(true); - expect(Buffer.isBuffer(security.dictionary.data.U)).toBe(true); - expect(Buffer.isBuffer(security.dictionary.data.OE)).toBe(true); - expect(Buffer.isBuffer(security.dictionary.data.UE)).toBe(true); - expect(Buffer.isBuffer(security.dictionary.data.Perms)).toBe(true); + expect(security.dictionary.data.O).toBeInstanceOf(Uint8Array); + expect(security.dictionary.data.U).toBeInstanceOf(Uint8Array); + expect(security.dictionary.data.OE).toBeInstanceOf(Uint8Array); + expect(security.dictionary.data.UE).toBeInstanceOf(Uint8Array); + expect(security.dictionary.data.Perms).toBeInstanceOf(Uint8Array); expect(security.dictionary.data.O.length).toBe(48); expect(security.dictionary.data.U.length).toBe(48); @@ -272,7 +272,7 @@ describe('PDFSecurity', () => { const plaintext = Buffer.from('Test data for AES-256'); const encrypted = encryptFn(plaintext); - expect(Buffer.isBuffer(encrypted)).toBe(true); + expect(encrypted).toBeInstanceOf(Uint8Array); expect(encrypted.length).toBeGreaterThan(plaintext.length); }); }); diff --git a/tests/unit/virtual-fs.spec.js b/tests/unit/virtual-fs.spec.js index 81637498a..dc76d4072 100644 --- a/tests/unit/virtual-fs.spec.js +++ b/tests/unit/virtual-fs.spec.js @@ -35,15 +35,15 @@ describe('virtual-fs', function () { fs.writeFileSync('files/binary', new Uint8Array([4, 3, 1, 2])); const encodedData = fs.readFileSync('files/encoded'); - expect(encodedData).toBeInstanceOf(Buffer); - expect(encodedData.toString('utf8')).toEqual('File content'); + expect(encodedData).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(encodedData)).toEqual('File content'); const rawData = fs.readFileSync('files/raw', 'utf8'); expect(rawData).toEqual('File content'); const binaryData = fs.readFileSync('files/binary'); - expect(binaryData).toBeInstanceOf(Buffer); - expect(binaryData.toJSON()).toEqual({ data: [4, 3, 1, 2], type: 'Buffer' }); + expect(binaryData).toBeInstanceOf(Uint8Array); + expect(Array.from(binaryData)).toEqual([4, 3, 1, 2]); }); test('bindFileData', function () { @@ -59,15 +59,15 @@ describe('virtual-fs', function () { }); const encodedData = fs.readFileSync('files/encoded'); - expect(encodedData).toBeInstanceOf(Buffer); - expect(encodedData.toString('utf8')).toEqual('File content'); + expect(encodedData).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(encodedData)).toEqual('File content'); let rawData = fs.readFileSync('files/raw', 'utf8'); expect(rawData).toEqual('File content'); let binaryData = fs.readFileSync('files/binary'); - expect(binaryData).toBeInstanceOf(Buffer); - expect(binaryData.toJSON()).toEqual({ data: [4, 3, 1, 2], type: 'Buffer' }); + expect(binaryData).toBeInstanceOf(Uint8Array); + expect(Array.from(binaryData)).toEqual([4, 3, 1, 2]); // reset option fs.bindFileData( @@ -84,7 +84,7 @@ describe('virtual-fs', function () { expect(rawData).toEqual('New File content'); binaryData = fs.readFileSync('files/binary2'); - expect(binaryData).toBeInstanceOf(Buffer); - expect(binaryData.toJSON()).toEqual({ data: [4, 3, 1, 2], type: 'Buffer' }); + expect(binaryData).toBeInstanceOf(Uint8Array); + expect(Array.from(binaryData)).toEqual([4, 3, 1, 2]); }); });