diff --git a/packages/eslint-config/eslint.js b/packages/eslint-config/eslint.js index 7aca630a..9139b3dd 100644 --- a/packages/eslint-config/eslint.js +++ b/packages/eslint-config/eslint.js @@ -34,7 +34,16 @@ module.exports = { "jest/no-conditional-expect": "off", "import/prefer-default-export": "off", "no-bitwise": "off", - "jest/valid-title": ["error", {"ignoreTypeOfDescribeName": true}] + "no-continue": "off", + "jest/valid-title": ["error", {"ignoreTypeOfDescribeName": true}], + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] }, settings: { "import/resolver": { diff --git a/packages/placeholder-plain/dist/readPdf.d.ts b/packages/placeholder-plain/dist/readPdf.d.ts index b5d2a226..27e24705 100644 --- a/packages/placeholder-plain/dist/readPdf.d.ts +++ b/packages/placeholder-plain/dist/readPdf.d.ts @@ -6,7 +6,6 @@ export type ReadPdfReturnType = { root: Buffer; infoRef: string; trailerStart: number; - previousXrefs: any[]; xRefPosition: number; }; /** @@ -16,7 +15,6 @@ export type ReadPdfReturnType = { * @prop {Buffer} root * @prop {string} infoRef * @prop {number} trailerStart - * @prop {*[]} previousXrefs * @prop {number} xRefPosition */ /** diff --git a/packages/placeholder-plain/dist/readPdf.js b/packages/placeholder-plain/dist/readPdf.js index 1ecee0b1..51dcbb33 100644 --- a/packages/placeholder-plain/dist/readPdf.js +++ b/packages/placeholder-plain/dist/readPdf.js @@ -32,7 +32,6 @@ const getValue = (trailer, key) => { * @prop {Buffer} root * @prop {string} infoRef * @prop {number} trailerStart - * @prop {*[]} previousXrefs * @prop {number} xRefPosition */ @@ -63,7 +62,6 @@ const readPdf = pdfBuffer => { root, infoRef, trailerStart, - previousXrefs: [], xRefPosition }; }; diff --git a/packages/placeholder-plain/src/PdfDictionary.js b/packages/placeholder-plain/src/PdfDictionary.js new file mode 100644 index 00000000..39c5c67e --- /dev/null +++ b/packages/placeholder-plain/src/PdfDictionary.js @@ -0,0 +1,261 @@ +import {SignPdfError} from '@signpdf/utils'; + +const CHARS = { + NUL: 0, + HT: 9, + LF: 10, + FF: 12, + CR: 13, + SP: 32, + NUMSIGN: 35, + PERCENT: 37, + LPAREN: 40, + RPAREN: 41, + FSLASH: 47, + LT: 60, + GT: 62, + LSQUARE: 91, + RSQUARE: 93, + LBRACE: 123, + RBRACE: 125, +}; + +const spaces = [ + CHARS.NUL, + CHARS.HT, + CHARS.LF, + CHARS.FF, + CHARS.CR, + CHARS.SP, +]; + +const newLines = [ + CHARS.LF, + CHARS.CR, +]; + +const terminators = [ + CHARS.LPAREN, + CHARS.RPAREN, + CHARS.LT, + CHARS.GT, + CHARS.LSQUARE, + CHARS.RSQUARE, + CHARS.LBRACE, + CHARS.RBRACE, + CHARS.FSLASH, + CHARS.PERCENT, +]; + +const delimiter = [ + ...spaces, + ...terminators, +]; + +const pairs = [ + {open: CHARS.LPAREN, close: CHARS.RPAREN}, + {open: CHARS.LT, close: CHARS.GT}, + {open: CHARS.LSQUARE, close: CHARS.RSQUARE}, + {open: CHARS.LBRACE, close: CHARS.RBRACE}, +]; +const pairOpeners = pairs.map((pair) => pair.open); +const pairClosers = pairs.map((pair) => pair.close); + +export default class PdfDictionary { + /** + * @param {Buffer} buffer + */ + constructor(buffer) { + this.buffer = buffer; + + try { + this.map = this.parseBuffer(); + } catch (e) { + throw new SignPdfError( + `Failed to parse PDF dictionary: ${e.message}`, + SignPdfError.TYPE_PARSE, + ); + } + } + + toString() { + return this.buffer.toString(); + } + + get(key) { + return this.map.get(key); + } + + has(key) { + return this.map.has(key); + } + + #skipSpacesAt(start) { + let index = start; + while (index < this.buffer.length) { + const char = this.buffer[index]; + if (spaces.includes(char)) { + index += 1; + continue; + } + return index; + } + return index; + } + + #skipCommentsAt(start) { + let index = start; + if (this.buffer[index] !== CHARS.NUMSIGN) { + return index; + } + + while (index < this.buffer.length) { + const char = this.buffer[index]; + index += 1; + if (newLines.includes(char)) { + return index; + } + } + + return index; + } + + #skipSpacesAndCommentsAt(start) { + let index = this.#skipSpacesAt(start); + index = this.#skipCommentsAt(index); + index = this.#skipSpacesAt(index); + return index; + } + + /** + * @param {number} start + */ + #parseNameAt(start) { + let current = ''; + let index = start; + while (index < this.buffer.length) { + const char = this.buffer[index]; + if (current === '') { + if (char === CHARS.FSLASH) { + current += Buffer.from([char]).toString(); + index += 1; + continue; + } + + break; + } + + if (delimiter.includes(char)) { + return { + value: current, + lastIndex: index - 1, + }; + } + + current += Buffer.from([char]).toString(); + index += 1; + } + + if (index === this.buffer.length) { + // reached the end + return { + value: current, + lastIndex: index, + }; + } + + return { + value: undefined, + lastIndex: index, + }; + } + + #parseValueAt(start) { + let current = ''; + let index = start; + + const parsedName = this.#parseNameAt(index); + if (parsedName.value !== undefined) { + return parsedName; + } + + const stack = []; + + while (index < this.buffer.length) { + const char = this.buffer[index]; + if (pairOpeners.includes(char)) { + stack.push(char); + } else if (stack.length > 0) { + if (pairClosers.includes(char)) { + const closerIndex = pairClosers.indexOf(char); + const lastOpener = stack.pop(); + const openerIndex = pairOpeners.indexOf(lastOpener); + if (openerIndex !== closerIndex) { + throw new SignPdfError( + `Unbalanced parentheses in PDF dictionary. Expected "${pairClosers[openerIndex]}" but got "${char}".`, + SignPdfError.TYPE_PARSE, + ); + } + if (stack.length === 0) { + current += Buffer.from([char]).toString(); + return { + lastIndex: index, + value: current, + }; + } + } + } else if (char === CHARS.FSLASH) { + return { + lastIndex: index - 1, + value: current.trim(), + }; + } + + current += Buffer.from([char]).toString(); + index += 1; + } + + if (stack.length > 0) { + const readable = stack.map((char) => Buffer.from([char]).toString()).join('", "'); + throw new SignPdfError( + `Unbalanced parentheses in PDF dictionary. Opened but did not close "${readable}".`, + SignPdfError.TYPE_PARSE, + ); + } + + return { + lastIndex: index, + value: current.trim(), + }; + } + + parseBuffer() { + const result = new Map(); + + let index = 0; + let key = ''; + let value = ''; + + while (index < this.buffer.length) { + index = this.#skipSpacesAndCommentsAt(index); + const parsedKey = this.#parseNameAt(index); + if (parsedKey.value === undefined) { + throw new SignPdfError( + 'Failed to parse key in dictionary.', + SignPdfError.TYPE_PARSE, + ); + } + index = parsedKey.lastIndex + 1; + key = parsedKey.value; + + index = this.#skipSpacesAndCommentsAt(index); + const parsedValue = this.#parseValueAt(index); + index = parsedValue.lastIndex + 1; + value = parsedValue.value; + + result.set(key, value); + } + + return result; + } +} diff --git a/packages/placeholder-plain/src/PdfDictionary.test.js b/packages/placeholder-plain/src/PdfDictionary.test.js new file mode 100644 index 00000000..f758520a --- /dev/null +++ b/packages/placeholder-plain/src/PdfDictionary.test.js @@ -0,0 +1,47 @@ +import {SignPdfError} from '@signpdf/utils'; +import PdfDictionary from './PdfDictionary'; + +describe(PdfDictionary, () => { + it('Throws an error when dictionary is not valid', () => { + const dictionary = Buffer.from('Not a dictionary.'); + try { + // eslint-disable-next-line no-new + new PdfDictionary(dictionary); + expect('here').not.toBe('here'); + } catch (e) { + expect(e instanceof SignPdfError).toBe(true); + expect(e.type).toBe(SignPdfError.TYPE_PARSE); + expect(e.message).toMatchInlineSnapshot('"Failed to parse PDF dictionary: Failed to parse key in dictionary."'); + } + }); + it('Parses a dictionary', () => { + const dictionary = Buffer.from(' /Type /Catalog /Pages 2 0 R '); + const pdfDictionary = new PdfDictionary(dictionary); + + expect(pdfDictionary.get('/Type')).toBe('/Catalog'); + expect(pdfDictionary.get('/Pages')).toBe('2 0 R'); + }); + it('Parses a dictionary with spaces and different types of values', () => { + const dictionary = Buffer.from(` + /Key1 /Catalog + /Key2 2 0 R + /Key3 [3 0 R] + /Key4/Value + /Key5 (Value) + /Key6 <> # Subdictionary to mess things up + /Key7/Value7/Key8/Value8 # Multiple keys in a row and a comment + /Key9 + /Value9`); + const pdfDictionary = new PdfDictionary(dictionary); + + expect(pdfDictionary.get('/Key1')).toBe('/Catalog'); + expect(pdfDictionary.get('/Key2')).toBe('2 0 R'); + expect(pdfDictionary.get('/Key3')).toBe('[3 0 R]'); + expect(pdfDictionary.get('/Key4')).toBe('/Value'); + expect(pdfDictionary.get('/Key5')).toBe('(Value)'); + expect(pdfDictionary.get('/Key6')).toBe('<>'); + expect(pdfDictionary.get('/Key7')).toBe('/Value7'); + expect(pdfDictionary.get('/Key8')).toBe('/Value8'); + expect(pdfDictionary.get('/Key9')).toBe('/Value9'); + }); +}); diff --git a/packages/placeholder-plain/src/__snapshots__/readRefTable.test.js.snap b/packages/placeholder-plain/src/__snapshots__/readRefTable.test.js.snap index 3d1517e2..cfe9ebed 100644 --- a/packages/placeholder-plain/src/__snapshots__/readRefTable.test.js.snap +++ b/packages/placeholder-plain/src/__snapshots__/readRefTable.test.js.snap @@ -1,72 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`getXref Throws an error when next EOF is not found 1`] = `"Expected EOF after xref and trailer but could not find one."`; - -exports[`getXref Throws an error when size has unexpected value 1`] = `"Failed to parse size of xref table."`; - -exports[`getXref Throws an error when size is not found 1`] = `"Size not found in xref table."`; - -exports[`getXref Throws an error when xref is not at its expected position 1`] = `"Expected xref at 2 but found other content."`; - -exports[`getXref Throws an error when xref is not found at position 1`] = `"Could not find xref anywhere at or after 0."`; - -exports[`readRefTable Expects to merge correctly the refTable of resources 1`] = ` -Object { - "maxIndex": 19, - "offsets": Map { - 1 => 19012, - 2 => 19, - 3 => 224, - 4 => 12330, - 5 => 244, - 6 => 11154, - 7 => 11176, - 8 => 11368, - 9 => 11709, - 10 => 11910, - 11 => 11943, - 12 => 12140, - 13 => 12196, - 14 => 18928, - 15 => 12494, - 17 => 13264, - 18 => 18742, - 19 => 18860, - }, - "startingIndex": 0, -} -`; - -exports[`readRefTable Expects to merge correctly the refTable of resources 2`] = ` -Object { - "maxIndex": 21, - "offsets": Map { - 1 => 25091, - 2 => 19, - 3 => 224, - 4 => 12330, - 5 => 244, - 6 => 11154, - 7 => 11176, - 8 => 11368, - 9 => 11709, - 10 => 11910, - 11 => 11943, - 12 => 12140, - 13 => 12196, - 14 => 18948, - 15 => 12494, - 17 => 13264, - 18 => 18742, - 19 => 25016, - 20 => 19431, - 21 => 24878, - }, - "startingIndex": 0, -} -`; - -exports[`readRefTable Expects to merge correctly the refTable of resources 3`] = ` +exports[`readRefTable Expects to merge correctly the refTable of contributing.pdf: contributing.pdf 1`] = ` Object { "maxIndex": 24, "offsets": Map { @@ -99,7 +33,7 @@ Object { } `; -exports[`readRefTable Expects to merge correctly the refTable of resources 4`] = ` +exports[`readRefTable Expects to merge correctly the refTable of formexample.pdf: formexample.pdf 1`] = ` Object { "maxIndex": 62, "offsets": Map { @@ -170,7 +104,7 @@ Object { } `; -exports[`readRefTable Expects to merge correctly the refTable of resources 5`] = ` +exports[`readRefTable Expects to merge correctly the refTable of incrementally_signed.pdf: incrementally_signed.pdf 1`] = ` Object { "maxIndex": 18, "offsets": Map { @@ -197,7 +131,7 @@ Object { } `; -exports[`readRefTable Expects to merge correctly the refTable of resources 6`] = ` +exports[`readRefTable Expects to merge correctly the refTable of signed.pdf: signed.pdf 1`] = ` Object { "maxIndex": 13, "offsets": Map { @@ -219,7 +153,63 @@ Object { } `; -exports[`readRefTable Expects to merge correctly the refTable of resources 7`] = ` +exports[`readRefTable Expects to merge correctly the refTable of signed-once.pdf: signed-once.pdf 1`] = ` +Object { + "maxIndex": 19, + "offsets": Map { + 1 => 19012, + 2 => 19, + 3 => 224, + 4 => 12330, + 5 => 244, + 6 => 11154, + 7 => 11176, + 8 => 11368, + 9 => 11709, + 10 => 11910, + 11 => 11943, + 12 => 12140, + 13 => 12196, + 14 => 18928, + 15 => 12494, + 17 => 13264, + 18 => 18742, + 19 => 18860, + }, + "startingIndex": 0, +} +`; + +exports[`readRefTable Expects to merge correctly the refTable of signed-twice.pdf: signed-twice.pdf 1`] = ` +Object { + "maxIndex": 21, + "offsets": Map { + 1 => 25091, + 2 => 19, + 3 => 224, + 4 => 12330, + 5 => 244, + 6 => 11154, + 7 => 11176, + 8 => 11368, + 9 => 11709, + 10 => 11910, + 11 => 11943, + 12 => 12140, + 13 => 12196, + 14 => 18948, + 15 => 12494, + 17 => 13264, + 18 => 18742, + 19 => 25016, + 20 => 19431, + 21 => 24878, + }, + "startingIndex": 0, +} +`; + +exports[`readRefTable Expects to merge correctly the refTable of w3dummy.pdf: w3dummy.pdf 1`] = ` Object { "maxIndex": 15, "offsets": Map { diff --git a/packages/placeholder-plain/src/createBufferTrailer.test.js b/packages/placeholder-plain/src/createBufferTrailer.test.js index 52c8d3c7..ebe6197c 100644 --- a/packages/placeholder-plain/src/createBufferTrailer.test.js +++ b/packages/placeholder-plain/src/createBufferTrailer.test.js @@ -19,7 +19,6 @@ describe(createBufferTrailer, () => { root: '\n/Type /Catalog\n/Pages 1 0 R\n/AcroForm 9 0 R\n', infoRef: '2 0 R', trailerStart: 4510, - previousXrefs: [], xRefPosition: 4220, }; const addedReferences = new Map(); diff --git a/packages/placeholder-plain/src/findObject.js b/packages/placeholder-plain/src/findObject.js index b1ff9691..372ae614 100644 --- a/packages/placeholder-plain/src/findObject.js +++ b/packages/placeholder-plain/src/findObject.js @@ -1,4 +1,36 @@ import getIndexFromRef from './getIndexFromRef'; +import PdfDictionary from './PdfDictionary'; + +/** + * @typedef {object} FindObjectAtReturnType + * @property {Buffer} dictionary + * @property {Buffer} stream + */ + +/** + * @param {Buffer} pdf + * @param {number} position + * @returns {FindObjectAtReturnType} + */ +export const findObjectAt = (pdf, position) => { + let slice = pdf.subarray(position); + slice = slice.subarray(slice.indexOf('obj') + 3, slice.indexOf('endobj', 'utf8') - 1); + // ^ Buffer from the start position until the first endobj. + + const dictionary = slice.subarray( + slice.indexOf('<<', 'utf8') + 2, + slice.lastIndexOf('>>', 'utf8'), + ); + const stream = slice.subarray( + slice.indexOf('stream', 'utf8') + 6, + slice.indexOf('endstream', 'utf8') - 1, + ); + + return { + dictionary: new PdfDictionary(dictionary), + stream, + }; +}; /** * @param {Buffer} pdf @@ -9,13 +41,7 @@ const findObject = (pdf, refTable, ref) => { const index = getIndexFromRef(refTable, ref); const offset = refTable.offsets.get(index); - let slice = pdf.slice(offset); - slice = slice.slice(0, slice.indexOf('endobj', 'utf8')); - - // FIXME: What if it is a stream? - slice = slice.slice(slice.indexOf('<<', 'utf8') + 2); - slice = slice.slice(0, slice.lastIndexOf('>>', 'utf8')); - return slice; + return findObjectAt(pdf, offset).stream; }; export default findObject; diff --git a/packages/placeholder-plain/src/readPdf.js b/packages/placeholder-plain/src/readPdf.js index ebbebba9..de534028 100644 --- a/packages/placeholder-plain/src/readPdf.js +++ b/packages/placeholder-plain/src/readPdf.js @@ -1,25 +1,6 @@ -import readRefTable from './readRefTable'; -import findObject from './findObject'; - -/** - * @param {Buffer} trailer - * @param {string} key - * @returns {string} - */ -export const getValue = (trailer, key) => { - let index = trailer.indexOf(key); - - if (index === -1) { - return undefined; - } - - const slice = trailer.slice(index); - index = slice.indexOf('/', 1); - if (index === -1) { - index = slice.indexOf('>', 1); - } - return slice.slice(key.length + 1, index).toString().trim(); // key + at least one space -}; +import {SignPdfError} from '@signpdf/utils'; +import readRefTable, {getLastXrefPosition} from './readRefTable'; +import findObject, {findObjectAt} from './findObject'; /** * @typedef {object} ReadPdfReturnType @@ -28,7 +9,6 @@ export const getValue = (trailer, key) => { * @prop {Buffer} root * @prop {string} infoRef * @prop {number} trailerStart - * @prop {*[]} previousXrefs * @prop {number} xRefPosition */ @@ -43,27 +23,33 @@ export const getValue = (trailer, key) => { */ const readPdf = (pdfBuffer) => { // Extract the trailer dictionary. - const trailerStart = pdfBuffer.lastIndexOf('trailer'); - // The trailer is followed by xref. Then an EOF. EOF's length is 6 characters. - const trailer = pdfBuffer.slice(trailerStart, pdfBuffer.length - 6); - - let xRefPosition = trailer.slice(trailer.lastIndexOf('startxref') + 10).toString(); - - xRefPosition = parseInt(xRefPosition); - const refTable = readRefTable(pdfBuffer); - - const rootRef = getValue(trailer, '/Root'); + const xRefPosition = getLastXrefPosition(pdfBuffer); + + let refTable; + + const trailerObject = findObjectAt(pdfBuffer, xRefPosition); + if (trailerObject.stream.indexOf('trailer') !== -1) { + // assuming trailer + refTable = readRefTable(pdfBuffer, xRefPosition); + } else { + // assuming stream + if (!trailerObject.dictionary.has('/Filter')) { + throw new Error('Expected /Filter in trailer with streams.'); + } + throw new SignPdfError( + '/Filter is not implemented.', + SignPdfError.TYPE_PARSE, + ); + } + const rootRef = trailerObject.dictionary.get('/Root'); const root = findObject(pdfBuffer, refTable, rootRef).toString(); - - const infoRef = getValue(trailer, '/Info'); + const infoRef = trailerObject.dictionary.get('/Info'); return { xref: refTable, rootRef, root, infoRef, - trailerStart, - previousXrefs: [], xRefPosition, }; }; diff --git a/packages/placeholder-plain/src/readPdf.test.js b/packages/placeholder-plain/src/readPdf.test.js index 30b8f550..cd48956a 100644 --- a/packages/placeholder-plain/src/readPdf.test.js +++ b/packages/placeholder-plain/src/readPdf.test.js @@ -1,22 +1,31 @@ -import {getValue} from './readPdf'; +import {readTestResource} from '@signpdf/internal-utils'; +import readPdf from './readPdf'; -describe(getValue, () => { - it('matches snapshots', () => { - const trailer = Buffer.from(`trailer - << - /Size 14 - /Root 2 0 R - /Info 10 0 R - /ID [<455364dbb1253da25540322def2a672b> <455364dbb1253da25540322def2a672b>] - /AtTheEnd 14 - >> - startxref - 4220 - %%EOF`); +describe(readPdf, () => { + it.each([ + { + resource: 'signed-once.pdf', + xRefPosition: 19174, + root: 14, + rootByteOffset: 18928, + info: 15, + }, + { + resource: 'contributing.pdf', + xRefPosition: 72203, + root: 12, + rootByteOffset: 4394, + info: 1, + }, + ])('reads $resource', ({ + resource, root, info, xRefPosition, rootByteOffset, + }) => { + const pdfBuffer = readTestResource(resource); + const result = readPdf(pdfBuffer); - expect(getValue(trailer, '/Root')).toBe('2 0 R'); - expect(getValue(trailer, '/Info')).toBe('10 0 R'); - expect(getValue(trailer, '/Unknown')).toBe(undefined); - expect(getValue(trailer, '/AtTheEnd')).toBe('14'); + expect(result.xRefPosition).toBe(xRefPosition); + expect(result.rootRef).toBe(`${root} 0 R`); + expect(result.infoRef).toBe(`${info} 0 R`); + expect(result.xref.offsets.get(root)).toBe(rootByteOffset); }); }); diff --git a/packages/placeholder-plain/src/readRefTable.js b/packages/placeholder-plain/src/readRefTable.js index 5bded0d9..d3cc7c8d 100644 --- a/packages/placeholder-plain/src/readRefTable.js +++ b/packages/placeholder-plain/src/readRefTable.js @@ -1,46 +1,49 @@ import {SignPdfError} from '@signpdf/utils'; import xrefToRefMap from './xrefToRefMap'; -export const getLastTrailerPosition = (pdf) => { - const trailerStart = pdf.lastIndexOf(Buffer.from('trailer', 'utf8')); - const trailer = pdf.slice(trailerStart, pdf.length - 6); - - const xRefPosition = trailer - .slice(trailer.lastIndexOf(Buffer.from('startxref', 'utf8')) + 10) - .toString(); - - return parseInt(xRefPosition); +/** + * @param {Buffer} pdf + * @returns {number} + */ +export const getLastXrefPosition = (pdf) => { + const xRefPosition = pdf + .subarray( + pdf.lastIndexOf(Buffer.from('startxref', 'utf8')) + 10, + pdf.lastIndexOf(Buffer.from('%%EOF', 'utf8')), + ) + .toString() + .trim(); + + const lastXrefPosition = parseInt(xRefPosition); + if (`${lastXrefPosition}` !== xRefPosition) { + throw new SignPdfError( + `Expected an integer startxref position but got ${xRefPosition} instead.`, + SignPdfError.TYPE_PARSE, + ); + } + return lastXrefPosition; }; -export const getXref = (pdf, position) => { - let refTable = pdf.slice(position); // slice starting from where xref starts +/** + * @param {Buffer} pdfSlice + * @param {number} position + * @returns {GetXRefReturnType | null} + */ +const readXrefTableAt = (pdfSlice, position) => { + let refTable = pdfSlice.subarray(position); // slice starting from where xref starts const realPosition = refTable.indexOf(Buffer.from('xref', 'utf8')); if (realPosition === -1) { - throw new SignPdfError( - `Could not find xref anywhere at or after ${position}.`, - SignPdfError.TYPE_PARSE, - ); + return null; } if (realPosition > 0) { - const prefix = refTable.slice(0, realPosition); + const prefix = refTable.subarray(0, realPosition); if (prefix.toString().replace(/\s*/g, '') !== '') { - throw new SignPdfError( - `Expected xref at ${position} but found other content.`, - SignPdfError.TYPE_PARSE, - ); + return null; } } - const nextEofPosition = refTable.indexOf(Buffer.from('%%EOF', 'utf8')); - if (nextEofPosition === -1) { - throw new SignPdfError( - 'Expected EOF after xref and trailer but could not find one.', - SignPdfError.TYPE_PARSE, - ); - } - refTable = refTable.slice(0, nextEofPosition); - refTable = refTable.slice(realPosition + 4); // move ahead with the "xref" - refTable = refTable.slice(refTable.indexOf('\n') + 1); // move after the next new line + // move ahead with the "xref\n" + refTable = refTable.subarray(realPosition + 5); // extract the size let size = refTable.toString().split('/Size')[1]; @@ -81,23 +84,44 @@ export const getXref = (pdf, position) => { }; }; +/** + * @typedef {object} GetXRefReturnType + * // TODO + */ + +/** + * @param {Buffer} pdf + * @param {number} position + * @returns {GetXRefReturnType} + * @throws {SignPdfError} + */ +export const getXref = (pdf, position) => { + const table = readXrefTableAt(pdf, position); + if (!table) { + throw new SignPdfError( + `Could not find xref anywhere at or after startxref position ${position}.`, + SignPdfError.TYPE_PARSE, + ); + } + return table; +}; + /** * @typedef {Map<*, *>} GetFullXrefTableReturnType */ /** * @param {Buffer} pdf + * @param {number} xRefPosition * @returns {GetFullXrefTableReturnType} */ -export const getFullXrefTable = (pdf) => { - const lastTrailerPosition = getLastTrailerPosition(pdf); - const lastXrefTable = getXref(pdf, lastTrailerPosition); +export const getFullXrefTable = (pdf, xRefPosition) => { + const lastXrefTable = getXref(pdf, xRefPosition); if (lastXrefTable.prev === undefined) { return lastXrefTable.xRefContent; } - const pdfWithoutLastTrailer = pdf.slice(0, lastTrailerPosition); - const partOfXrefTable = getFullXrefTable(pdfWithoutLastTrailer); + const partOfXrefTable = getFullXrefTable(pdf, lastXrefTable.prev); const mergedXrefTable = new Map([ ...partOfXrefTable, @@ -116,10 +140,11 @@ export const getFullXrefTable = (pdf) => { /** * @param {Buffer} pdfBuffer + * @param {number} xRefPosition * @returns {ReadRefTableReturnType} */ -const readRefTable = (pdf) => { - const fullXrefTable = getFullXrefTable(pdf); +const readRefTable = (pdf, xRefPosition) => { + const fullXrefTable = getFullXrefTable(pdf, xRefPosition); const startingIndex = 0; const maxIndex = Math.max(...fullXrefTable.keys()); diff --git a/packages/placeholder-plain/src/readRefTable.test.js b/packages/placeholder-plain/src/readRefTable.test.js index a2450e20..0c17081f 100644 --- a/packages/placeholder-plain/src/readRefTable.test.js +++ b/packages/placeholder-plain/src/readRefTable.test.js @@ -1,6 +1,39 @@ import {readTestResource} from '@signpdf/internal-utils'; import {SignPdfError} from '@signpdf/utils'; -import readRefTable, {getXref} from './readRefTable'; +import readRefTable, {getFullXrefTable, getXref} from './readRefTable'; + +describe(getFullXrefTable, () => { + it('skips unreferenced xref tables', () => { + const pdf = Buffer.from(`xref +0 3 +0000000000 65535 f +0000000123 00000 n +0000000234 00000 n + +xref +2 1 +0000000555 00000 n + +xref +0 1 +0000000666 00000 n + +trailer +<< +/Size 3 +/Root 1 0 R +>> +startxref +0 +%%EOF +`); + + expect(getFullXrefTable(pdf)).toEqual(new Map([ + [1, 123], + [2, 234], + ])); + }); +}); describe(getXref, () => { it('Throws an error when xref is not found at position', () => { @@ -12,7 +45,7 @@ describe(getXref, () => { } catch (e) { expect(e instanceof SignPdfError).toBe(true); expect(e.type).toBe(SignPdfError.TYPE_PARSE); - expect(e.message).toMatchSnapshot(); + expect(e.message).toMatchInlineSnapshot('"Could not find xref anywhere at or after startxref position 0."'); } }); it('Throws an error when xref is not at its expected position', () => { @@ -24,7 +57,7 @@ describe(getXref, () => { } catch (e) { expect(e instanceof SignPdfError).toBe(true); expect(e.type).toBe(SignPdfError.TYPE_PARSE); - expect(e.message).toMatchSnapshot(); + expect(e.message).toMatchInlineSnapshot('"Could not find xref anywhere at or after startxref position 2."'); } }); it('Throws an error when size is not found', () => { @@ -36,7 +69,7 @@ describe(getXref, () => { } catch (e) { expect(e instanceof SignPdfError).toBe(true); expect(e.type).toBe(SignPdfError.TYPE_PARSE); - expect(e.message).toMatchSnapshot(); + expect(e.message).toMatchInlineSnapshot('"Size not found in xref table."'); } }); it('Throws an error when size has unexpected value', () => { @@ -48,37 +81,26 @@ describe(getXref, () => { } catch (e) { expect(e instanceof SignPdfError).toBe(true); expect(e.type).toBe(SignPdfError.TYPE_PARSE); - expect(e.message).toMatchSnapshot(); - } - }); - it('Throws an error when next EOF is not found', () => { - const pdf = Buffer.from('xref\n then /Size 123 and then but no EOF'); - const position = 0; - try { - getXref(pdf, position); - expect('here').not.toBe('here'); - } catch (e) { - expect(e instanceof SignPdfError).toBe(true); - expect(e.type).toBe(SignPdfError.TYPE_PARSE); - expect(e.message).toMatchSnapshot(); + expect(e.message).toMatchInlineSnapshot('"Failed to parse size of xref table."'); } }); }); describe(readRefTable, () => { - it('Expects to merge correctly the refTable of resources', () => { - [ - 'signed-once.pdf', - 'signed-twice.pdf', - 'contributing.pdf', - 'formexample.pdf', - 'incrementally_signed.pdf', - 'signed.pdf', - 'w3dummy.pdf', - ].forEach((fileName) => { - const pdf = readTestResource(fileName); - const r = readRefTable(pdf); - expect(r).toMatchSnapshot(); - }); - }); + it.each([ + {resource: 'signed-once.pdf', startxref: 19174}, + {resource: 'signed-twice.pdf', startxref: 25264}, + {resource: 'contributing.pdf', startxref: 72203}, + {resource: 'formexample.pdf', startxref: 64251}, + {resource: 'incrementally_signed.pdf', startxref: 17125}, + {resource: 'signed.pdf', startxref: 4220}, + {resource: 'w3dummy.pdf', startxref: 12787}, + ])( + 'Expects to merge correctly the refTable of $resource', + ({resource, startxref}) => { + const pdf = readTestResource(resource); + const r = readRefTable(pdf, startxref); + expect(r).toMatchSnapshot(resource); + }, + ); }); diff --git a/packages/placeholder-plain/src/xrefToRefMap.js b/packages/placeholder-plain/src/xrefToRefMap.js index cec63ee7..a99042fa 100644 --- a/packages/placeholder-plain/src/xrefToRefMap.js +++ b/packages/placeholder-plain/src/xrefToRefMap.js @@ -1,29 +1,42 @@ import {SignPdfError} from '@signpdf/utils'; +/** + * @param {string} xrefString + * @returns {Map} + */ const xrefToRefMap = (xrefString) => { const lines = xrefString.split('\n').filter((l) => l !== ''); let index = 0; let expectedLines = 0; + + /** + * @type {Map} + */ const xref = new Map(); - lines.forEach((line) => { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; const split = line.split(' '); if (split.length === 2) { + // We've found a line that states the next index and number of expected lines. + // e.g.: + // 39 35 # <--- this is the line + // 0000655893 00000 n + // 0000656101 00000 n index = parseInt(split[0]); expectedLines = parseInt(split[1]); - return; + continue; } if (expectedLines <= 0) { - throw new SignPdfError( - 'Too many lines in xref table.', - SignPdfError.TYPE_PARSE, - ); + // We don't expect any more lines. Whatever is left is not part of the xref table. + break; } expectedLines -= 1; const [offset, , inUse] = split; if (inUse.trim() === 'f') { + // unused object index += 1; - return; + continue; } if (inUse.trim() !== 'n') { throw new SignPdfError( @@ -37,10 +50,11 @@ const xrefToRefMap = (xrefString) => { SignPdfError.TYPE_PARSE, ); } + const storeOffset = parseInt(offset.trim()); xref.set(index, storeOffset); index += 1; - }); + } return xref; }; diff --git a/packages/placeholder-plain/src/xrefToRefMap.test.js b/packages/placeholder-plain/src/xrefToRefMap.test.js index c4cbcd05..68260b9a 100644 --- a/packages/placeholder-plain/src/xrefToRefMap.test.js +++ b/packages/placeholder-plain/src/xrefToRefMap.test.js @@ -85,23 +85,18 @@ const xrefStrings = [ 0000673269 00000 n`, ]; -describe('xrefToRefMap', () => { +describe(xrefToRefMap, () => { it('Predefined xrefs match their snapshots', () => { xrefStrings.forEach((xref) => { expect(xrefToRefMap(xref)).toMatchSnapshot(); }); }); - it('Throws an error when number of items does not match statement', () => { - try { - xrefToRefMap(`0 1 + it('Only reads the stated number of lines', () => { + const result = xrefToRefMap(`0 2 000 000 f 001 000 n -002 100 n`); - expect('here').not.toBe('here'); - } catch (e) { - expect(e instanceof SignPdfError).toBe(true); - expect(e.type).toBe(SignPdfError.TYPE_PARSE); - } +002 100 n`); // the third line is ignored + expect(result.size).toBe(1); }); it('Throws an error when unknown in-use flag is uses', () => { try { diff --git a/resources/issue-79-test.pdf b/resources/issue-79-test.pdf new file mode 100644 index 00000000..739c7774 Binary files /dev/null and b/resources/issue-79-test.pdf differ