Skip to content

Commit 3531a7b

Browse files
ralfstxclaude
andcommitted
✨ Support XMP fragments in document definition
XMP metadata is used in PDFs for various purposes, including declaring conformance to standards like PDF/A. Adding custom XMP metadata is already supported by `pdf-core` but was not yet exposed in this library. This commit adds first-class support for XMP fragments in the document definition. Consumers can now provide an `xmpFragments` array of objects with `namespaceUri`, `prefix`, and `unsafeInnerXML` properties to `DocumentDefinition`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2b7b65 commit 3531a7b

6 files changed

Lines changed: 123 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
output intents (e.g. for PDF/A or PDF/X conformance). Accepts raw ICC
99
profile data as `Uint8Array`, removing the need for consumers to
1010
depend on `@ralfstx/pdf-core`.
11+
- `xmpFragments` property on `DocumentDefinition` for merging custom
12+
XMP metadata into the document's auto-generated XMP. This is needed
13+
e.g. for declaring PDF/A conformance level alongside output intents.
1114

1215
## [0.6.1] - 2026-03-08
1316

src/api/document.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ export type DocumentDefinition = {
8989
*/
9090
outputIntents?: OutputIntent[];
9191

92+
/**
93+
* Custom XMP metadata fragments to merge into the document's
94+
* auto-generated XMP metadata. Each fragment declares a namespace
95+
* and provides raw XML content for that namespace.
96+
*/
97+
xmpFragments?: XmpFragment[];
98+
9299
dev?: {
93100
/**
94101
* When set to true, additional guides are drawn to help analyzing
@@ -273,3 +280,25 @@ export type OutputIntent = {
273280
*/
274281
info?: string;
275282
};
283+
284+
/**
285+
* A fragment of XMP metadata scoped to a single namespace, to be
286+
* merged into the document's auto-generated XMP.
287+
*/
288+
export type XmpFragment = {
289+
/**
290+
* The namespace URI (e.g. `'http://www.aiim.org/pdfa/ns/id/'`).
291+
*/
292+
namespaceUri: string;
293+
294+
/**
295+
* The namespace prefix used in the XML content (e.g. `'pdfaid'`).
296+
*/
297+
prefix: string;
298+
299+
/**
300+
* Raw XML content to place inside the `rdf:Description` element.
301+
* The caller is responsible for ensuring the XML is well-formed.
302+
*/
303+
unsafeInnerXML: string;
304+
};

src/read/read-document.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,58 @@ describe('readDocumentDefinition', () => {
286286
);
287287
});
288288
});
289+
290+
describe('xmpFragments', () => {
291+
it('accepts valid xmpFragments', () => {
292+
const xmpFragments = [
293+
{
294+
namespaceUri: 'http://www.aiim.org/pdfa/ns/id/',
295+
prefix: 'pdfaid',
296+
unsafeInnerXML: '<pdfaid:part>3</pdfaid:part>',
297+
},
298+
];
299+
300+
const def = readDocumentDefinition({ ...input, xmpFragments });
301+
302+
expect(def.xmpFragments).toEqual(xmpFragments);
303+
});
304+
305+
it('checks namespaceUri is required', () => {
306+
const xmpFragments = [{ prefix: 'pdfaid', unsafeInnerXML: '<pdfaid:part>3</pdfaid:part>' }];
307+
308+
expect(() => readDocumentDefinition({ ...input, xmpFragments })).toThrow(
309+
/Missing value for "namespaceUri"/,
310+
);
311+
});
312+
313+
it('checks prefix is required', () => {
314+
const xmpFragments = [
315+
{ namespaceUri: 'http://example.com/', unsafeInnerXML: '<x:y>1</x:y>' },
316+
];
317+
318+
expect(() => readDocumentDefinition({ ...input, xmpFragments })).toThrow(
319+
/Missing value for "prefix"/,
320+
);
321+
});
322+
323+
it('checks unsafeInnerXML is required', () => {
324+
const xmpFragments = [{ namespaceUri: 'http://example.com/', prefix: 'x' }];
325+
326+
expect(() => readDocumentDefinition({ ...input, xmpFragments })).toThrow(
327+
/Missing value for "unsafeInnerXML"/,
328+
);
329+
});
330+
331+
it('rejects non-string values', () => {
332+
const xmpFragments = [
333+
{ namespaceUri: 123, prefix: 'pdfaid', unsafeInnerXML: '<pdfaid:part>3</pdfaid:part>' },
334+
];
335+
336+
expect(() => readDocumentDefinition({ ...input, xmpFragments })).toThrow(
337+
/Invalid value for "xmpFragments\/0\/namespaceUri"/,
338+
);
339+
});
340+
});
289341
});
290342

291343
function mkIccProfile(colorSpace = 'RGB '): Uint8Array {

src/read/read-document.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export type DocumentDefinition = {
4646
registryName?: string;
4747
info?: string;
4848
}[];
49+
xmpFragments?: {
50+
namespaceUri: string;
51+
prefix: string;
52+
unsafeInnerXML: string;
53+
}[];
4954
onRenderDocument?: (pdfDoc: PDFDocument) => void | Promise<void>;
5055
};
5156

@@ -77,6 +82,7 @@ export function readDocumentDefinition(input: unknown): DocumentDefinition {
7782
customData: optional(readCustomData),
7883
embeddedFiles: optional(types.array(readEmbeddedFiles)),
7984
outputIntents: optional(types.array(readOutputIntent)),
85+
xmpFragments: optional(types.array(readXmpFragment)),
8086
onRenderDocument: optional(),
8187
});
8288
if (def1.language && !def1.defaultStyle?.language) {
@@ -199,3 +205,11 @@ function readIccProfile(input: unknown): Uint8Array {
199205
}
200206
return input;
201207
}
208+
209+
function readXmpFragment(input: unknown) {
210+
return readObject(input, {
211+
namespaceUri: required(types.string()),
212+
prefix: required(types.string()),
213+
unsafeInnerXML: required(types.string()),
214+
});
215+
}

src/render/render-document.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,25 @@ describe('renderDocument', () => {
158158
expect(dataString).toMatch(/\/Info <FEFF/);
159159
expect(dataString).toMatch(/\/DestOutputProfile/);
160160
});
161+
162+
it('renders XMP fragments', async () => {
163+
const def = {
164+
content: [],
165+
xmpFragments: [
166+
{
167+
namespaceUri: 'http://www.aiim.org/pdfa/ns/id/',
168+
prefix: 'pdfaid',
169+
unsafeInnerXML:
170+
'<pdfaid:part>3</pdfaid:part>\n<pdfaid:conformance>B</pdfaid:conformance>',
171+
},
172+
],
173+
};
174+
175+
const pdfData = await renderDocument(def, [], noObjectStreams);
176+
const dataString = new TextDecoder().decode(pdfData);
177+
178+
expect(dataString).toContain('pdfaid:part');
179+
expect(dataString).toContain('pdfaid:conformance');
180+
expect(dataString).toContain('http://www.aiim.org/pdfa/ns/id/');
181+
});
161182
});

src/render/render-document.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export async function renderDocument(
4343
});
4444
}
4545

46+
for (const fragment of def.xmpFragments ?? []) {
47+
pdfDoc.addXMPFragment(fragment);
48+
}
49+
4650
await def.onRenderDocument?.(pdfDoc);
4751

4852
return pdfDoc.write(writeOptions);

0 commit comments

Comments
 (0)