Skip to content

Commit 8c75d91

Browse files
ralfstxclaude
andcommitted
✨ Add outputIntents to document definition
Consumers that need PDF/A or PDF/X conformance must register output intents with ICC profiles. Previously this required depending on `@ralfstx/pdf-core` directly to instantiate `ICCColorSpace`. Add an `outputIntents` array to `DocumentDefinition` that accepts raw ICC profile bytes as `Uint8Array`. The library auto-detects `numComponents` from the ICC header and creates the `ICCColorSpace` internally. The read phase validates the ICC header structure (minimum length, `acsp` signature, supported color space). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 97d0c7a commit 8c75d91

6 files changed

Lines changed: 290 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Added
6+
7+
- `outputIntents` property on `DocumentDefinition` for declaring PDF
8+
output intents (e.g. for PDF/A or PDF/X conformance). Accepts raw ICC
9+
profile data as `Uint8Array`, removing the need for consumers to
10+
depend on `@ralfstx/pdf-core`.
11+
312
## [0.6.1] - 2026-03-08
413

514
### Changed

src/api/document.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export type DocumentDefinition = {
8383
*/
8484
embeddedFiles?: EmbeddedFile[];
8585

86+
/**
87+
* Output intents that describe the intended output conditions for the
88+
* document (e.g. for PDF/A or PDF/X conformance).
89+
*/
90+
outputIntents?: OutputIntent[];
91+
8692
dev?: {
8793
/**
8894
* When set to true, additional guides are drawn to help analyzing
@@ -229,3 +235,41 @@ export type PageInfo = {
229235
*/
230236
readonly pageCount?: number;
231237
};
238+
239+
/**
240+
* An output intent that describes the intended output condition for the
241+
* document. Output intents are required for PDF/A and PDF/X conformance.
242+
*/
243+
export type OutputIntent = {
244+
/**
245+
* The output intent subtype, e.g. `'GTS_PDFA1'` for PDF/A or
246+
* `'GTS_PDFX'` for PDF/X.
247+
*/
248+
subtype: string;
249+
250+
/**
251+
* An identifier for the intended output condition, e.g.
252+
* `'sRGB IEC61966-2.1'`.
253+
*/
254+
outputConditionIdentifier: string;
255+
256+
/**
257+
* The raw ICC profile data for the destination output condition.
258+
*/
259+
iccProfile: Uint8Array;
260+
261+
/**
262+
* A human-readable description of the intended output condition.
263+
*/
264+
outputCondition?: string;
265+
266+
/**
267+
* A URL for an ICC profile registry where the condition is defined.
268+
*/
269+
registryName?: string;
270+
271+
/**
272+
* Additional information about the intended output condition.
273+
*/
274+
info?: string;
275+
};

src/read/read-document.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,120 @@ describe('readDocumentDefinition', () => {
185185
),
186186
);
187187
});
188+
189+
describe('outputIntents', () => {
190+
it('accepts valid output intent', () => {
191+
const iccProfile = mkIccProfile('RGB ');
192+
const outputIntents = [
193+
{
194+
subtype: 'GTS_PDFA1',
195+
outputConditionIdentifier: 'sRGB IEC61966-2.1',
196+
iccProfile,
197+
outputCondition: 'sRGB',
198+
registryName: 'http://www.color.org',
199+
info: 'sRGB IEC61966-2.1',
200+
},
201+
];
202+
203+
const def = readDocumentDefinition({ ...input, outputIntents });
204+
205+
expect(def.outputIntents).toEqual(outputIntents);
206+
});
207+
208+
it('accepts output intent with only required fields', () => {
209+
const iccProfile = mkIccProfile('RGB ');
210+
const outputIntents = [
211+
{ subtype: 'GTS_PDFA1', outputConditionIdentifier: 'sRGB', iccProfile },
212+
];
213+
214+
const def = readDocumentDefinition({ ...input, outputIntents });
215+
216+
expect(def.outputIntents).toEqual(outputIntents);
217+
});
218+
219+
it('checks subtype is required', () => {
220+
const outputIntents = [{ outputConditionIdentifier: 'sRGB', iccProfile: mkIccProfile() }];
221+
222+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
223+
/Missing value for "subtype"/,
224+
);
225+
});
226+
227+
it('checks outputConditionIdentifier is required', () => {
228+
const outputIntents = [{ subtype: 'GTS_PDFA1', iccProfile: mkIccProfile() }];
229+
230+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
231+
/Missing value for "outputConditionIdentifier"/,
232+
);
233+
});
234+
235+
it('checks iccProfile is required', () => {
236+
const outputIntents = [{ subtype: 'GTS_PDFA1', outputConditionIdentifier: 'sRGB' }];
237+
238+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
239+
/Missing value for "iccProfile"/,
240+
);
241+
});
242+
243+
it('rejects non-Uint8Array iccProfile', () => {
244+
const outputIntents = [
245+
{ subtype: 'GTS_PDFA1', outputConditionIdentifier: 'sRGB', iccProfile: 'not-bytes' },
246+
];
247+
248+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
249+
/Invalid value for "outputIntents\/0\/iccProfile": Expected Uint8Array/,
250+
);
251+
});
252+
253+
it('rejects ICC profile that is too short', () => {
254+
const outputIntents = [
255+
{
256+
subtype: 'GTS_PDFA1',
257+
outputConditionIdentifier: 'sRGB',
258+
iccProfile: new Uint8Array(10),
259+
},
260+
];
261+
262+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
263+
/Invalid value for "outputIntents\/0\/iccProfile": ICC profile is too short/,
264+
);
265+
});
266+
267+
it('rejects ICC profile with invalid signature', () => {
268+
const iccProfile = new Uint8Array(128);
269+
const outputIntents = [
270+
{ subtype: 'GTS_PDFA1', outputConditionIdentifier: 'sRGB', iccProfile },
271+
];
272+
273+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
274+
/Invalid value for "outputIntents\/0\/iccProfile": Invalid ICC profile: expected signature 'acsp'/,
275+
);
276+
});
277+
278+
it('rejects ICC profile with unsupported color space', () => {
279+
const iccProfile = mkIccProfile('Lab ');
280+
const outputIntents = [
281+
{ subtype: 'GTS_PDFA1', outputConditionIdentifier: 'sRGB', iccProfile },
282+
];
283+
284+
expect(() => readDocumentDefinition({ ...input, outputIntents })).toThrow(
285+
/Invalid value for "outputIntents\/0\/iccProfile": Unsupported ICC profile color space 'Lab'/,
286+
);
287+
});
288+
});
188289
});
290+
291+
function mkIccProfile(colorSpace = 'RGB '): Uint8Array {
292+
const data = new Uint8Array(128);
293+
// color space signature at offset 16
294+
data[16] = colorSpace.charCodeAt(0);
295+
data[17] = colorSpace.charCodeAt(1);
296+
data[18] = colorSpace.charCodeAt(2);
297+
data[19] = colorSpace.charCodeAt(3);
298+
// 'acsp' signature at offset 36
299+
data[36] = 0x61; // a
300+
data[37] = 0x63; // c
301+
data[38] = 0x73; // s
302+
data[39] = 0x70; // p
303+
return data;
304+
}

src/read/read-document.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export type DocumentDefinition = {
3838
modificationDate?: Date;
3939
relationship?: FileRelationShip;
4040
}[];
41+
outputIntents?: {
42+
subtype: string;
43+
outputConditionIdentifier: string;
44+
iccProfile: Uint8Array;
45+
outputCondition?: string;
46+
registryName?: string;
47+
info?: string;
48+
}[];
4149
onRenderDocument?: (pdfDoc: PDFDocument) => void | Promise<void>;
4250
};
4351

@@ -68,6 +76,7 @@ export function readDocumentDefinition(input: unknown): DocumentDefinition {
6876
dev: optional(types.object({ guides: optional(types.boolean()) })),
6977
customData: optional(readCustomData),
7078
embeddedFiles: optional(types.array(readEmbeddedFiles)),
79+
outputIntents: optional(types.array(readOutputIntent)),
7180
onRenderDocument: optional(),
7281
});
7382
if (def1.language && !def1.defaultStyle?.language) {
@@ -147,3 +156,46 @@ function readData(input: unknown): Uint8Array {
147156
if (input instanceof Uint8Array) return input;
148157
throw typeError('Uint8Array', input);
149158
}
159+
160+
function readOutputIntent(input: unknown) {
161+
const intent = readObject(input, {
162+
subtype: required(types.string()),
163+
outputConditionIdentifier: required(types.string()),
164+
iccProfile: required(readIccProfile),
165+
outputCondition: optional(types.string()),
166+
registryName: optional(types.string()),
167+
info: optional(types.string()),
168+
});
169+
return intent;
170+
}
171+
172+
function readIccProfile(input: unknown): Uint8Array {
173+
if (!(input instanceof Uint8Array)) {
174+
throw typeError('Uint8Array', input);
175+
}
176+
if (input.length < 128) {
177+
throw new TypeError('ICC profile is too short');
178+
}
179+
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
180+
const signature = String.fromCharCode(
181+
view.getUint8(36),
182+
view.getUint8(37),
183+
view.getUint8(38),
184+
view.getUint8(39),
185+
);
186+
if (signature !== 'acsp') {
187+
throw new TypeError(`Invalid ICC profile: expected signature 'acsp', got '${signature}'`);
188+
}
189+
const colorSpace = String.fromCharCode(
190+
view.getUint8(16),
191+
view.getUint8(17),
192+
view.getUint8(18),
193+
view.getUint8(19),
194+
);
195+
if (!['RGB ', 'CMYK', 'GRAY'].includes(colorSpace)) {
196+
throw new TypeError(
197+
`Unsupported ICC profile color space '${colorSpace.trim()}', expected RGB, CMYK, or GRAY`,
198+
);
199+
}
200+
return input;
201+
}

src/render/render-document.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import { renderDocument } from './render-document.ts';
55

66
const noObjectStreams = { useObjectStreams: false } as const;
77

8+
function mkIccProfile(colorSpace = 'RGB '): Uint8Array {
9+
const data = new Uint8Array(128);
10+
data[16] = colorSpace.charCodeAt(0);
11+
data[17] = colorSpace.charCodeAt(1);
12+
data[18] = colorSpace.charCodeAt(2);
13+
data[19] = colorSpace.charCodeAt(3);
14+
data[36] = 0x61; // a
15+
data[37] = 0x63; // c
16+
data[38] = 0x73; // s
17+
data[39] = 0x70; // p
18+
return data;
19+
}
20+
821
describe('renderDocument', () => {
922
beforeEach(() => {
1023
vi.stubEnv('TZ', 'UTC');
@@ -117,4 +130,32 @@ describe('renderDocument', () => {
117130

118131
expect(dataString).toMatch(/\/Title <FEFF0074006500730074002D007400690074006C0065>/);
119132
});
133+
134+
it('renders output intents', async () => {
135+
const iccProfile = mkIccProfile('RGB ');
136+
const def = {
137+
content: [],
138+
outputIntents: [
139+
{
140+
subtype: 'GTS_PDFA1',
141+
outputConditionIdentifier: 'sRGB IEC61966-2.1',
142+
iccProfile,
143+
outputCondition: 'sRGB',
144+
registryName: 'http://www.color.org',
145+
info: 'sRGB IEC61966-2.1',
146+
},
147+
],
148+
};
149+
150+
const pdfData = await renderDocument(def, [], noObjectStreams);
151+
const dataString = new TextDecoder().decode(pdfData);
152+
153+
expect(dataString).toMatch(/\/OutputIntents/);
154+
expect(dataString).toMatch(/\/S \/GTS_PDFA1/);
155+
expect(dataString).toMatch(/\/OutputConditionIdentifier \(sRGB IEC61966-2.1\)/);
156+
expect(dataString).toMatch(/\/OutputCondition \(sRGB\)/);
157+
expect(dataString).toMatch(/\/RegistryName \(http:\/\/www.color.org\)/);
158+
expect(dataString).toMatch(/\/Info <FEFF/);
159+
expect(dataString).toMatch(/\/DestOutputProfile/);
160+
});
120161
});

src/render/render-document.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PDFContext, PDFDict, WriteOptions } from '@ralfstx/pdf-core';
2-
import { PDFDocument, PDFStream } from '@ralfstx/pdf-core';
2+
import { ICCColorSpace, PDFDocument, PDFStream } from '@ralfstx/pdf-core';
33

44
import type { Page } from '../page.ts';
55
import type { DocumentDefinition, Metadata } from '../read/read-document.ts';
@@ -29,6 +29,20 @@ export async function renderDocument(
2929
});
3030
}
3131

32+
for (const intent of def.outputIntents ?? []) {
33+
pdfDoc.addOutputIntent({
34+
subtype: intent.subtype,
35+
outputConditionIdentifier: intent.outputConditionIdentifier,
36+
destOutputProfile: ICCColorSpace.of({
37+
data: intent.iccProfile,
38+
numComponents: iccNumComponents(intent.iccProfile),
39+
}),
40+
outputCondition: intent.outputCondition,
41+
registryName: intent.registryName,
42+
info: intent.info,
43+
});
44+
}
45+
3246
await def.onRenderDocument?.(pdfDoc);
3347

3448
return pdfDoc.write(writeOptions);
@@ -62,3 +76,16 @@ function setCustomData(data: Record<string, string | Uint8Array>, doc: PDFDocume
6276
});
6377
}
6478
}
79+
80+
function iccNumComponents(data: Uint8Array): 1 | 3 | 4 {
81+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
82+
const colorSpace = String.fromCharCode(
83+
view.getUint8(16),
84+
view.getUint8(17),
85+
view.getUint8(18),
86+
view.getUint8(19),
87+
);
88+
if (colorSpace === 'RGB ') return 3;
89+
if (colorSpace === 'CMYK') return 4;
90+
return 1;
91+
}

0 commit comments

Comments
 (0)