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' & ]]>',
+ );
+ 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${marker}>`);
+ 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('