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' & ]]>',
+ );
+ 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('