From 8f66a4dbe7311434aee583c0f3fabd38fe8a07bd Mon Sep 17 00:00:00 2001 From: AC Date: Thu, 15 Oct 2026 14:03:12 +0800 Subject: [PATCH] Escape document information when writing XMP metadata --- CHANGELOG.md | 2 + docs/getting_started.md | 4 ++ lib/mixins/metadata.js | 30 +++++++++--- tests/unit/metadata_info.spec.js | 80 ++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 tests/unit/metadata_info.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6b9729..8d9204a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Unreleased +- Escape document information in XMP metadata and omit characters XML 1.0 cannot represent, without changing the PDF Info dictionary. Fixes #1817 + - 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 - Add `lineBreak: 'whitespace'` to `doc.text()`, which breaks a line only at whitespace, a soft hyphen or a zero-width space. The Unicode line breaking rules the wrapper follows also allow a break after a hyphen or a slash, so `design-system` or `https://github.com/foliojs/pdfkit` could be cut at a line end, and a text extractor that joins the lines, such as a résumé parser, reads two words - Add an `objectStreams` option, which packs the objects that are not streams into compressed object streams and writes a cross-reference stream instead of the xref table, making documents with many such objects, e.g. tagged ones, much smaller. It requires a `pdfVersion` of `1.5` or higher, and cannot be used with encryption or PDF/A-1. Fixes #1810 diff --git a/docs/getting_started.md b/docs/getting_started.md index 0b9ba61c..6dd8e381 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -297,6 +297,10 @@ title, or author of the document. You can add that information by adding it to the `doc.info` object, or by passing an info object into the document at creation time. +Pass metadata as plain text; PDFKit escapes it when writing XML (XMP) metadata. +Characters that XML 1.0 cannot represent are omitted from XMP. The original +values in `doc.info` and the PDF Info dictionary are not changed. + Here is a list of all of the properties you can add to the document metadata. According to the PDF spec, each property must have its first letter capitalized. 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('