Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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');
});
});
Loading