diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ad313e..4384a1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Unreleased +- Escape document information in XMP metadata and omit characters XML 1.0 cannot represent, without changing the PDF Info dictionary. Fixes #1817 - Fix text in a standard font garbling or dropping the characters after a tab, a carriage return or any other character code below `0x10`, which was written into the hex string of the text as a single digit, so the following digits paired up wrongly: a `\r` before a line break showed as `Ð`, and `Tab\there` was read back as `Tab–†W&P`. Fixes #1425, #606 - Reuse the glyph width and kerning tables of a standard font across documents instead of building them again for every document that uses it, which made a short document using two standard fonts more than twice as slow to create - Fix `doc.table()` parsing a font passed as binary data (e.g. registered with `registerFont(name, uint8Array)`) again for every cell, when it restores the font after measuring and drawing the cell, which made tables with such fonts slower. A font loaded from the same `Uint8Array` is now reused without parsing it again diff --git a/lib/mixins/metadata.js b/lib/mixins/metadata.js index 6857d787..1446074d 100644 --- a/lib/mixins/metadata.js +++ b/lib/mixins/metadata.js @@ -1,5 +1,23 @@ import PDFMetadata from '../metadata'; +function escapeXML(value) { + return ( + String(value) + // XML 1.0 cannot represent these characters, even as character references. + .replace( + // eslint-disable-next-line no-control-regex + /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, + '', + ) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\r/g, ' ') + ); +} + export default { initMetadata() { this.metadata = new PDFMetadata(); @@ -13,7 +31,7 @@ export default { this.appendXML(` ${this.info.CreationDate.toISOString().split('.')[0] + 'Z'} - ${this.info.Creator} + ${escapeXML(this.info.Creator)} `); @@ -26,7 +44,7 @@ export default { this.appendXML(` - ${this.info.Title} + ${escapeXML(this.info.Title)} `); @@ -36,7 +54,7 @@ export default { this.appendXML(` - ${this.info.Author} + ${escapeXML(this.info.Author)} `); @@ -46,7 +64,7 @@ export default { this.appendXML(` - ${this.info.Subject} + ${escapeXML(this.info.Subject)} `); @@ -60,14 +78,14 @@ export default { this.appendXML( ` - ${this.info.Producer}`, + ${escapeXML(this.info.Producer)}`, false, ); if (this.info.Keywords) { this.appendXML( ` - ${this.info.Keywords}`, + ${escapeXML(this.info.Keywords)}`, false, ); } diff --git a/tests/unit/metadata_info.spec.js b/tests/unit/metadata_info.spec.js new file mode 100644 index 00000000..81f96a1a --- /dev/null +++ b/tests/unit/metadata_info.spec.js @@ -0,0 +1,80 @@ +import PDFDocument from '../../lib/document'; +import { toBytes } from '../../lib/output'; + +async function finish(doc) { + const bytes = toBytes(doc); + doc.end(); + return new TextDecoder().decode(await bytes); +} + +describe('document information in XMP', () => { + test.each(['Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer'])( + 'escapes %s without changing the original information', + async (key) => { + const value = `R&D "quoted" 'text' & ]]>`; + const doc = new PDFDocument({ + pdfVersion: '1.7', + info: { [key]: value }, + }); + const pdf = await finish(doc); + const xml = pdf.match(//)[0]; + expect(xml).toContain( + 'R&D <draft> "quoted" 'text' &amp; ]]>', + ); + expect(doc.info[key]).toBe(value); + }, + ); + + test('omits invalid XML characters and preserves Unicode and whitespace', async () => { + const valid = 'A\tB\nC\rD 中文 😀 \uD7FF\uE000\uFFFD'; + const invalid = + '\u0000\u0001\u0008\u000B\u000C\u000E\u001F\uFFFE\uFFFF\uD800x\uDC00'; + const doc = new PDFDocument({ + pdfVersion: '1.7', + info: { Title: valid + invalid }, + }); + const pdf = await finish(doc); + expect(pdf).toContain( + `${valid.replace('\r', ' ')}x`, + ); + expect(doc.info.Title).toBe(valid + invalid); + }); + + test('escapes the latest values while preserving explicitly appended XML', async () => { + const doc = new PDFDocument({ + pdfVersion: '1.7', + info: { Title: 'original' }, + }); + const xml = + 'A&B'; + doc.appendXML(xml); + doc.info.Title = 'Updated & '; + const pdf = await finish(doc); + expect(pdf).toContain(xml); + expect(pdf).toContain('Updated & <final>'); + }); + + test.each([ + { pdfVersion: '1.4', subset: 'PDF/A-1', marker: 'pdfaid:part' }, + { + pdfVersion: '1.7', + subset: 'PDF/UA', + tagged: true, + marker: 'pdfuaid:part', + }, + ])( + 'preserves the $subset metadata structure', + async ({ marker, ...options }) => { + const doc = new PDFDocument({ ...options, info: { Title: 'R&D' } }); + const pdf = await finish(doc); + expect(pdf).toContain(`<${marker}>1`); + expect(pdf).toContain('R&D'); + }, + ); + + test('does not emit XMP in PDF 1.3', async () => { + const doc = new PDFDocument({ pdfVersion: '1.3', info: { Title: 'R&D' } }); + expect(await finish(doc)).not.toContain('