Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 24 additions & 6 deletions lib/mixins/metadata.js
Original file line number Diff line number Diff line change
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\r/g, '&#xD;')
);
}

export default {
initMetadata() {
this.metadata = new PDFMetadata();
Expand All @@ -13,7 +31,7 @@ export default {
this.appendXML(`
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:CreateDate>${this.info.CreationDate.toISOString().split('.')[0] + 'Z'}</xmp:CreateDate>
<xmp:CreatorTool>${this.info.Creator}</xmp:CreatorTool>
<xmp:CreatorTool>${escapeXML(this.info.Creator)}</xmp:CreatorTool>
</rdf:Description>
`);

Expand All @@ -26,7 +44,7 @@ export default {
this.appendXML(`
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="x-default">${this.info.Title}</rdf:li>
<rdf:li xml:lang="x-default">${escapeXML(this.info.Title)}</rdf:li>
</rdf:Alt>
</dc:title>
`);
Expand All @@ -36,7 +54,7 @@ export default {
this.appendXML(`
<dc:creator>
<rdf:Seq>
<rdf:li>${this.info.Author}</rdf:li>
<rdf:li>${escapeXML(this.info.Author)}</rdf:li>
</rdf:Seq>
</dc:creator>
`);
Expand All @@ -46,7 +64,7 @@ export default {
this.appendXML(`
<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">${this.info.Subject}</rdf:li>
<rdf:li xml:lang="x-default">${escapeXML(this.info.Subject)}</rdf:li>
</rdf:Alt>
</dc:description>
`);
Expand All @@ -60,14 +78,14 @@ export default {
this.appendXML(
`
<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<pdf:Producer>${this.info.Producer}</pdf:Producer>`,
<pdf:Producer>${escapeXML(this.info.Producer)}</pdf:Producer>`,
false,
);

if (this.info.Keywords) {
this.appendXML(
`
<pdf:Keywords>${this.info.Keywords}</pdf:Keywords>`,
<pdf:Keywords>${escapeXML(this.info.Keywords)}</pdf:Keywords>`,
false,
);
}
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/metadata_info.spec.js
Original file line number Diff line number Diff line change
@@ -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 <draft> "quoted" 'text' &amp; ]]>`;
const doc = new PDFDocument({
pdfVersion: '1.7',
info: { [key]: value },
});
const pdf = await finish(doc);
const xml = pdf.match(/<x:xmpmeta[\s\S]*?<\/x:xmpmeta>/)[0];
expect(xml).toContain(
'R&amp;D &lt;draft&gt; &quot;quoted&quot; &apos;text&apos; &amp;amp; ]]&gt;',
);
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(
`<rdf:li xml:lang="x-default">${valid.replace('\r', '&#xD;')}x</rdf:li>`,
);
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 =
'<rdf:Description rdf:about="" xmlns:test="urn:test"><test:value>A&amp;B</test:value></rdf:Description>';
doc.appendXML(xml);
doc.info.Title = 'Updated & <final>';
const pdf = await finish(doc);
expect(pdf).toContain(xml);
expect(pdf).toContain('Updated &amp; &lt;final&gt;</rdf:li>');
});

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&amp;D</rdf:li>');
},
);

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('<x:xmpmeta');
expect(doc.info.Title).toBe('R&D');
});
});