@segnavia/render 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,327 @@
1
+ import { RenderRefusedError } from "../errors.js";
2
+ import { destinationColor, pdfMaskLuminosity } from "./color-policy.js";
3
+ import { nameOf, numberOf, rawStreamOf } from "./pdf-objects.js";
4
+ import { PDFArray, PDFDict, PDFHexString, PDFName, PDFNumber, PDFRef, PDFString } from "pdf-lib";
5
+ import { deflateSync, inflateSync } from "node:zlib";
6
+ //#region src/press/image.ts
7
+ const MAX_IMAGE_PIXELS = 5e7;
8
+ const MAX_TOTAL_IMAGE_PIXELS = 4e8;
9
+ const key = (value) => PDFName.of(value);
10
+ const refuse = (found, supported) => {
11
+ throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
12
+ };
13
+ const resolved = (document, object) => object instanceof PDFRef ? document.context.lookup(object) : object;
14
+ function bytesOf(document, object) {
15
+ const value = resolved(document, object);
16
+ if (value instanceof PDFString || value instanceof PDFHexString) return value.asBytes();
17
+ const stream = rawStreamOf(value);
18
+ if (!stream) return void 0;
19
+ const filter = nameOf(resolved(document, stream.dict.get(key("Filter"))));
20
+ if (!filter) return stream.contents;
21
+ if (filter === "FlateDecode" || filter === "Fl") return inflate(stream.contents, "Indexed lookup");
22
+ }
23
+ function baseSpace(document, object) {
24
+ const value = resolved(document, object);
25
+ const name = nameOf(value);
26
+ if (name === "DeviceRGB") return {
27
+ channels: 3,
28
+ kind: "rgb",
29
+ label: name
30
+ };
31
+ if (name === "DeviceGray") return {
32
+ channels: 1,
33
+ kind: "gray",
34
+ label: name
35
+ };
36
+ if (!(value instanceof PDFArray) || nameOf(resolved(document, value.get(0))) !== "ICCBased") return void 0;
37
+ const profile = rawStreamOf(resolved(document, value.get(1)));
38
+ const channels = profile ? numberOf(profile.dict, "N") : void 0;
39
+ if (channels === 1) return {
40
+ channels,
41
+ kind: "gray",
42
+ label: "ICCBased N 1"
43
+ };
44
+ if (channels === 3) return {
45
+ channels,
46
+ kind: "rgb",
47
+ label: "ICCBased N 3"
48
+ };
49
+ }
50
+ function imageSpace(document, object) {
51
+ const base = baseSpace(document, object);
52
+ if (base) return base;
53
+ const value = resolved(document, object);
54
+ if (value instanceof PDFArray && nameOf(resolved(document, value.get(0))) === "Indexed") {
55
+ const indexedBase = baseSpace(document, value.get(1));
56
+ const high = resolved(document, value.get(2));
57
+ const lookup = bytesOf(document, value.get(3));
58
+ if (indexedBase && high instanceof PDFNumber && lookup) return {
59
+ base: indexedBase,
60
+ high: high.asNumber(),
61
+ kind: "indexed",
62
+ label: `Indexed over ${indexedBase.label}`,
63
+ lookup
64
+ };
65
+ }
66
+ const label = nameOf(value) ?? (value instanceof PDFArray ? nameOf(resolved(document, value.get(0))) ?? "array" : "missing");
67
+ return refuse(`image color space ${label}`, "DeviceRGB, DeviceGray, ICCBased with N 1 or 3, or Indexed over one of those");
68
+ }
69
+ function filterName(document, image) {
70
+ const value = resolved(document, image.dict.get(key("Filter")));
71
+ if (!value) return "none";
72
+ if (value instanceof PDFArray) refuse("image filter chain", "no filter, FlateDecode, or DCTDecode");
73
+ const name = nameOf(value);
74
+ if (name === "FlateDecode" || name === "Fl") return "flate";
75
+ if (name === "DCTDecode" || name === "DCT") return "jpeg";
76
+ return refuse(`image filter ${name ?? "other"}`, "no filter, FlateDecode, or DCTDecode");
77
+ }
78
+ function inflate(bytes, structure, maximum) {
79
+ try {
80
+ return inflateSync(bytes, maximum === void 0 ? void 0 : { maxOutputLength: maximum });
81
+ } catch (error) {
82
+ if (maximum !== void 0 && error instanceof Error && "code" in error && error.code === "ERR_BUFFER_TOO_LARGE") return refuse(`${structure} data length greater than ${maximum}`, `${maximum} bytes`);
83
+ return refuse(`${structure} FlateDecode data`, "valid FlateDecode data");
84
+ }
85
+ }
86
+ const paeth = (left, above, upperLeft) => {
87
+ const estimate = left + above - upperLeft;
88
+ const leftDistance = Math.abs(estimate - left);
89
+ const aboveDistance = Math.abs(estimate - above);
90
+ const upperLeftDistance = Math.abs(estimate - upperLeft);
91
+ return leftDistance <= aboveDistance && leftDistance <= upperLeftDistance ? left : aboveDistance <= upperLeftDistance ? above : upperLeft;
92
+ };
93
+ const predictorAddition = (filter, left, above, upperLeft) => {
94
+ if (filter === 1) return left;
95
+ if (filter === 2) return above;
96
+ if (filter === 3) return Math.floor((left + above) / 2);
97
+ if (filter === 4) return paeth(left, above, upperLeft);
98
+ return 0;
99
+ };
100
+ function undoPngPredictor(bytes, width, height, colors, bits) {
101
+ const rowBytes = Math.ceil(width * colors * bits / 8);
102
+ const pixelBytes = Math.max(1, Math.ceil(colors * bits / 8));
103
+ if (bytes.length !== (rowBytes + 1) * height) refuse(`image predictor data length ${bytes.length}`, `${height} rows of ${rowBytes + 1} bytes`);
104
+ const output = new Uint8Array(rowBytes * height);
105
+ let source = 0;
106
+ for (let row = 0; row < height; row++) {
107
+ const filter = bytes[source++] ?? 0;
108
+ if (filter > 4) refuse(`image PNG predictor filter ${filter}`, "filters 0 through 4");
109
+ const target = row * rowBytes;
110
+ for (let column = 0; column < rowBytes; column++) {
111
+ const raw = bytes[source++] ?? 0;
112
+ const left = output[target + column - pixelBytes] ?? 0;
113
+ const above = output[target + column - rowBytes] ?? 0;
114
+ const upperLeft = output[target + column - rowBytes - pixelBytes] ?? 0;
115
+ const add = predictorAddition(filter, left, above, upperLeft);
116
+ output[target + column] = raw + add & 255;
117
+ }
118
+ }
119
+ return output;
120
+ }
121
+ function decodeFlate(state, image, width, height, channels, bits) {
122
+ const rawParms = resolved(state.document, image.dict.get(key("DecodeParms")));
123
+ const rowBytes = Math.ceil(width * channels * bits / 8);
124
+ if (!rawParms) return inflate(image.contents, "image", rowBytes * height);
125
+ if (!(rawParms instanceof PDFDict)) refuse("image DecodeParms", "no DecodeParms or a PNG predictor 10 through 15");
126
+ const parms = rawParms;
127
+ const predictor = numberOf(parms, "Predictor") ?? 1;
128
+ if (predictor < 10 || predictor > 15) refuse(`image predictor ${predictor}`, "a PNG predictor 10 through 15");
129
+ const colors = numberOf(parms, "Colors") ?? 1;
130
+ const columns = numberOf(parms, "Columns") ?? 1;
131
+ const predictorBits = numberOf(parms, "BitsPerComponent") ?? 8;
132
+ if (columns !== width || colors !== channels || predictorBits !== bits) refuse("image PNG predictor parameters", `Colors ${channels}, Columns ${width}, and BitsPerComponent ${bits}`);
133
+ const expected = (rowBytes + 1) * height;
134
+ return undoPngPredictor(inflate(image.contents, "image predictor", expected), width, height, colors, predictorBits);
135
+ }
136
+ function isDefaultDecode(image, channels, bits, indexed) {
137
+ const decode = image.dict.lookupMaybe(key("Decode"), PDFArray);
138
+ if (!decode) return true;
139
+ if (decode.size() !== channels * 2) return false;
140
+ const high = 2 ** bits - 1;
141
+ for (let channel = 0; channel < channels; channel++) {
142
+ const lowValue = decode.lookupMaybe(channel * 2, PDFNumber)?.asNumber();
143
+ const highValue = decode.lookupMaybe(channel * 2 + 1, PDFNumber)?.asNumber();
144
+ if (lowValue !== 0 || highValue !== (indexed ? high : 1)) return false;
145
+ }
146
+ return true;
147
+ }
148
+ function grayTable(profile) {
149
+ const table = /* @__PURE__ */ new Uint8Array(256);
150
+ for (let value = 0; value < 256; value++) table[value] = Math.round(destinationColor(profile, [
151
+ value / 255,
152
+ value / 255,
153
+ value / 255
154
+ ], 0)[0] * 255);
155
+ return table;
156
+ }
157
+ function buildGrayImageLookup(profile, size = 33) {
158
+ const table = new Uint8Array(size ** 3);
159
+ const at = (red, green, blue) => (red * size + green) * size + blue;
160
+ for (let red = 0; red < size; red++) for (let green = 0; green < size; green++) for (let blue = 0; blue < size; blue++) {
161
+ const value = destinationColor(profile, [
162
+ red / (size - 1),
163
+ green / (size - 1),
164
+ blue / (size - 1)
165
+ ], 0)[0];
166
+ table[at(red, green, blue)] = Math.round(Math.max(0, Math.min(1, value)) * 255);
167
+ }
168
+ return (red, green, blue) => {
169
+ const scale = (size - 1) / 255;
170
+ const r = red * scale, g = green * scale, b = blue * scale;
171
+ const r0 = Math.floor(r), g0 = Math.floor(g), b0 = Math.floor(b);
172
+ const r1 = Math.min(size - 1, r0 + 1), g1 = Math.min(size - 1, g0 + 1), b1 = Math.min(size - 1, b0 + 1);
173
+ const rf = r - r0, gf = g - g0, bf = b - b0;
174
+ const mix = (first, second, amount) => first + (second - first) * amount;
175
+ const low = mix(mix(table[at(r0, g0, b0)] ?? 0, table[at(r1, g0, b0)] ?? 0, rf), mix(table[at(r0, g1, b0)] ?? 0, table[at(r1, g1, b0)] ?? 0, rf), gf);
176
+ const high = mix(mix(table[at(r0, g0, b1)] ?? 0, table[at(r1, g0, b1)] ?? 0, rf), mix(table[at(r0, g1, b1)] ?? 0, table[at(r1, g1, b1)] ?? 0, rf), gf);
177
+ return Math.round(mix(low, high, bf));
178
+ };
179
+ }
180
+ function streamDictionary(stream) {
181
+ const dictionary = { Filter: key("FlateDecode") };
182
+ for (const [name, value] of stream.dict.entries()) {
183
+ const label = name.toString().slice(1);
184
+ if (![
185
+ "Filter",
186
+ "DecodeParms",
187
+ "Length",
188
+ "ColorSpace",
189
+ "BitsPerComponent",
190
+ "Decode"
191
+ ].includes(label)) dictionary[label] = value;
192
+ }
193
+ return dictionary;
194
+ }
195
+ function profileRgb(state, source, space, mask, pixels) {
196
+ const expected = pixels * space.channels;
197
+ if (source.length !== expected) refuse(`image sample length ${source.length}`, `${expected} bytes for its dimensions and color space`);
198
+ const output = new Uint8Array(pixels);
199
+ for (let pixel = 0; pixel < pixels; pixel++) {
200
+ const first = source[pixel * space.channels] ?? 0;
201
+ const red = first, green = space.channels === 1 ? first : source[pixel * 3 + 1] ?? 0, blue = space.channels === 1 ? first : source[pixel * 3 + 2] ?? 0;
202
+ output[pixel] = mask === "none" ? space.kind === "gray" ? state.grayLookup[first] ?? 0 : state.rgbLookup(red, green, blue) : Math.round(pdfMaskLuminosity([
203
+ red / 255,
204
+ green / 255,
205
+ blue / 255
206
+ ])[0] * 255);
207
+ }
208
+ return output;
209
+ }
210
+ function paletteToGray(state, space, mask) {
211
+ const count = space.high + 1;
212
+ const expected = count * space.base.channels;
213
+ if (!Number.isInteger(space.high) || space.high < 0 || space.high > 255 || space.lookup.length < expected) refuse(`Indexed image lookup length ${space.lookup.length}`, `${expected} bytes for entries 0 through ${space.high}`);
214
+ return profileRgb(state, space.lookup.subarray(0, expected), space.base, mask, count);
215
+ }
216
+ async function sourceBytes(state, image, filter, width, height, channels, bits) {
217
+ if (filter === "none") return image.contents;
218
+ if (filter === "flate") return decodeFlate(state, image, width, height, channels, bits);
219
+ if (!state.decodeJpeg) refuse("DCTDecode image without a JPEG decoder", "a JPEG needs the renderer's decoder");
220
+ const decoder = state.decodeJpeg;
221
+ const decoded = await decodeJpeg(decoder, image.contents);
222
+ if (decoded.width !== width || decoded.height !== height) refuse(`decoded JPEG dimensions ${decoded.width}x${decoded.height}`, `the image Width and Height ${width}x${height}`);
223
+ if (decoded.rgb.length !== width * height * 3) refuse(`decoded JPEG sample length ${decoded.rgb.length}`, `${width * height * 3} RGB bytes`);
224
+ return decoded.rgb;
225
+ }
226
+ async function decodeJpeg(decoder, bytes) {
227
+ try {
228
+ return await decoder(bytes);
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : String(error);
231
+ return refuse(`JPEG decoding failure: ${message}`, "a JPEG the renderer's browser can decode");
232
+ }
233
+ }
234
+ function createImageState(document, profile, decodeJpeg) {
235
+ return {
236
+ counted: /* @__PURE__ */ new WeakSet(),
237
+ converted: /* @__PURE__ */ new WeakMap(),
238
+ decodeJpeg,
239
+ document,
240
+ profile,
241
+ rgbLookup: buildGrayImageLookup(profile),
242
+ grayLookup: grayTable(profile),
243
+ pixels: 0,
244
+ total: 0
245
+ };
246
+ }
247
+ function countSuppliedImage(state, image, mask) {
248
+ if (mask !== "none" || state.counted.has(image)) return;
249
+ state.counted.add(image);
250
+ state.total++;
251
+ }
252
+ function imageGeometry(image) {
253
+ const width = numberOf(image.dict, "Width") ?? refuse("image Width missing", "a positive Width number");
254
+ const height = numberOf(image.dict, "Height") ?? refuse("image Height missing", "a positive Height number");
255
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) refuse(`image dimensions ${width}x${height}`, "positive integer Width and Height");
256
+ const pixels = width * height;
257
+ if (pixels > 5e7) refuse(`image pixel count ${pixels}`, `at most ${MAX_IMAGE_PIXELS} pixels in one image`);
258
+ return {
259
+ height,
260
+ pixels,
261
+ width
262
+ };
263
+ }
264
+ function chargePixels(state, pixels) {
265
+ state.pixels += pixels;
266
+ if (state.pixels > 4e8) refuse(`total image pixel count ${state.pixels}`, `at most ${MAX_TOTAL_IMAGE_PIXELS} pixels in one render`);
267
+ }
268
+ async function convertSuppliedImage(state, image, mask) {
269
+ const context = mask === "none" ? "profile" : "luminosity";
270
+ const variants = state.converted.get(image);
271
+ const existing = variants?.[context];
272
+ if (existing) {
273
+ countSuppliedImage(state, image, mask);
274
+ return existing;
275
+ }
276
+ if (image.dict.has(key("SegnaviaGeneratedEffect")) || image.dict.get(key("ImageMask"))?.toString() === "true") return void 0;
277
+ const { width, height, pixels } = imageGeometry(image);
278
+ chargePixels(state, pixels);
279
+ const space = imageSpace(state.document, image.dict.get(key("ColorSpace")));
280
+ const bits = numberOf(image.dict, "BitsPerComponent");
281
+ const allowedBits = space.kind === "indexed" ? [
282
+ 1,
283
+ 2,
284
+ 4,
285
+ 8
286
+ ] : [8];
287
+ if (bits === void 0 || !allowedBits.includes(bits)) refuse(`image BitsPerComponent ${bits ?? "missing"}`, allowedBits.join(", "));
288
+ const componentBits = bits;
289
+ const channels = space.kind === "indexed" ? 1 : space.channels;
290
+ if (!isDefaultDecode(image, channels, componentBits, space.kind === "indexed")) refuse("image Decode array", "the default Decode array");
291
+ const filter = filterName(state.document, image);
292
+ if (space.kind === "indexed" && filter === "jpeg") refuse("Indexed image filter DCTDecode", "no filter or FlateDecode");
293
+ const source = await sourceBytes(state, image, filter, width, height, filter === "jpeg" ? 3 : channels, componentBits);
294
+ const dictionary = streamDictionary(image);
295
+ let output;
296
+ if (space.kind === "indexed") {
297
+ const expected = Math.ceil(width * componentBits / 8) * height;
298
+ if (source.length !== expected) refuse(`Indexed image sample length ${source.length}`, `${expected} bytes for its dimensions`);
299
+ output = source;
300
+ const palette = paletteToGray(state, space, mask);
301
+ dictionary.ColorSpace = state.document.context.obj([
302
+ "Indexed",
303
+ "DeviceGray",
304
+ space.high,
305
+ PDFHexString.of(Buffer.from(palette).toString("hex"))
306
+ ]);
307
+ dictionary.BitsPerComponent = state.document.context.obj(componentBits);
308
+ } else {
309
+ output = profileRgb(state, source, filter === "jpeg" ? {
310
+ channels: 3,
311
+ kind: "rgb",
312
+ label: "decoded JPEG"
313
+ } : space, mask, pixels);
314
+ dictionary.ColorSpace = key("DeviceGray");
315
+ dictionary.BitsPerComponent = state.document.context.obj(8);
316
+ }
317
+ const replacement = state.document.context.stream(deflateSync(output), dictionary);
318
+ state.converted.set(image, {
319
+ ...variants,
320
+ [context]: replacement
321
+ });
322
+ countSuppliedImage(state, image, mask);
323
+ return replacement;
324
+ }
325
+ const convertedImageCount = (state) => state.total;
326
+ //#endregion
327
+ export { MAX_IMAGE_PIXELS, MAX_TOTAL_IMAGE_PIXELS, buildGrayImageLookup, convertSuppliedImage, convertedImageCount, createImageState };
@@ -39,23 +39,29 @@ function contentFilter(names) {
39
39
  encoding: "unsupported"
40
40
  };
41
41
  }
42
- function groupAction(kind, label, maskSource) {
42
+ function groupAction(kind, label, maskSource, destination = "cmyk") {
43
+ if (destination === "gray") {
44
+ const supported = "DeviceGray, DeviceRGB, or no declared space";
45
+ if (kind === "gray") return decision("keep", supported);
46
+ if (label === "missing" || label === "DeviceRGB") return decision("convert", supported);
47
+ return decision("refuse", supported);
48
+ }
43
49
  const supported = "DeviceCMYK, DeviceGray, DeviceRGB on a mask source, or no declared space";
44
50
  if (kind === "gray" || kind === "cmyk" || maskSource && (label === "DeviceRGB" || label === "missing")) return decision("keep", supported);
45
51
  if (label === "missing" || label === "DeviceRGB") return decision("convert", supported);
46
52
  return decision("refuse", supported);
47
53
  }
48
- function shadingAction(kind, type, functionType) {
54
+ function shadingAction(kind, type, functionType, destination = "cmyk") {
49
55
  if (type === 1) {
50
56
  const supported = "ShadingType 1 in an RGB space with FunctionType 4, or DeviceCMYK";
51
- if (kind === "cmyk") return decision("keep", supported);
57
+ if (kind === destination) return decision("keep", supported);
52
58
  if (kind === "rgb" && functionType === 4) return decision("convert", supported);
53
59
  return decision("refuse", supported);
54
60
  }
55
61
  const supported = "ShadingType 2 or 3 in an RGB or DeviceCMYK color space";
56
62
  if (type !== 2 && type !== 3) return decision("refuse", supported);
57
63
  if (kind === "rgb") return decision("convert", supported);
58
- if (kind === "cmyk") return decision("keep", supported);
64
+ if (kind === destination) return decision("keep", supported);
59
65
  return decision("refuse", supported);
60
66
  }
61
67
  function shadingFunctionAction(type, dictionary, functions) {
@@ -96,8 +102,10 @@ function inlineImageAction(name, imageMask) {
96
102
  label: colorSpaceLabel(value)
97
103
  };
98
104
  }
99
- function contentColorSpaceAction(name) {
100
- return decision(name === "Pattern" || name === "DeviceCMYK" ? "keep" : "refuse", "Pattern or DeviceCMYK");
105
+ function contentColorSpaceAction(name, destination = "cmyk") {
106
+ const device = destination === "gray" ? "DeviceGray" : "DeviceCMYK";
107
+ const supported = `Pattern or ${device}`;
108
+ return decision(name === "Pattern" || name === device ? "keep" : "refuse", supported);
101
109
  }
102
110
  const colorOperators = [
103
111
  "rg",
@@ -116,10 +124,19 @@ const colorOperators = [
116
124
  function isContentColorOperator(operator) {
117
125
  return colorOperators.includes(operator);
118
126
  }
119
- function contentOperatorAction(operator, inspect, selectedSpace, patternName = false) {
120
- if (operator === "rg" || operator === "RG" || operator === "g" || operator === "G") return decision(inspect ? "refuse" : "convert", inspect ? "CMYK operators" : "RGB or DeviceGray color operands");
121
- if (operator === "k" || operator === "K") return decision("keep", "four CMYK operands");
122
- if (operator === "cs" || operator === "CS") return contentColorSpaceAction(selectedSpace);
127
+ function deviceColorAction(operator, inspect, destination) {
128
+ const rgb = operator === "rg" || operator === "RG";
129
+ const gray = operator === "g" || operator === "G";
130
+ const cmyk = operator === "k" || operator === "K";
131
+ if (!rgb && !gray && !cmyk) return void 0;
132
+ if (!inspect && (rgb || gray)) return decision("convert", "RGB or DeviceGray color operands");
133
+ if (destination === "gray") return decision(gray ? "keep" : "refuse", "RGB or gray input for a greyscale press");
134
+ return decision(cmyk ? "keep" : "refuse", "CMYK operators");
135
+ }
136
+ function contentOperatorAction(operator, inspect, selectedSpace, patternName = false, destination = "cmyk") {
137
+ const device = deviceColorAction(operator, inspect, destination);
138
+ if (device) return device;
139
+ if (operator === "cs" || operator === "CS") return contentColorSpaceAction(selectedSpace, destination);
123
140
  return decision((operator === "scn" || operator === "SCN") && patternName ? "keep" : "refuse", "a pattern name with scn or SCN");
124
141
  }
125
142
  //#endregion
@@ -1,9 +1,9 @@
1
1
  import { RenderRefusedError } from "../errors.js";
2
2
  import { groupAction, shadingAction } from "./policy.js";
3
3
  import { inspectContent } from "./content.js";
4
- import { decodeContent, dictionaryOf, nameOf, numberOf, resolvedColorSpace } from "./pdf-objects.js";
4
+ import { decodeContent, dictionaryOf, nameOf, numberOf, rawStreamOf, resolvedColorSpace } from "./pdf-objects.js";
5
5
  import { walkPressGraph } from "./visitor.js";
6
- import { PDFDict, PDFDocument, PDFName, PDFRef } from "pdf-lib";
6
+ import { PDFArray, PDFDict, PDFDocument, PDFName, PDFRef } from "pdf-lib";
7
7
  //#region src/press/report.ts
8
8
  const key = (name) => PDFName.of(name);
9
9
  const refuse = (found, supported) => {
@@ -15,6 +15,7 @@ function inspectImage(state, image) {
15
15
  if (state.images.has(image)) return;
16
16
  state.images.add(image);
17
17
  const space = resolvedColorSpace(state.document, resolved(state, image.dict.get(key("ColorSpace"))));
18
+ if (state.report.destination === "gray" && (space.kind === "rgb" || space.kind === "cmyk")) refuse(`image color space ${space.label}`, "DeviceGray or Indexed over DeviceGray");
18
19
  if (space.kind !== "rgb") return;
19
20
  const width = requiredDimension(numberOf(image.dict, "Width"), "Width");
20
21
  const height = requiredDimension(numberOf(image.dict, "Height"), "Height");
@@ -24,18 +25,19 @@ function inspectImage(state, image) {
24
25
  colorSpace: space.label
25
26
  });
26
27
  }
27
- function inspectFunction(state, fn, policy) {
28
+ function inspectFunction(state, fn, policy, _mask) {
28
29
  if (policy.action === "refuse") refuse(policy.found, policy.supported);
29
30
  if (fn && numberOf(fn, "FunctionType") === 2) state.report.gradientStops += 2;
30
31
  }
31
- function inspectShading(state, shading) {
32
+ function inspectShading(state, shading, _mask) {
32
33
  if (state.shadings.has(shading)) return false;
33
34
  state.shadings.add(shading);
34
35
  const type = numberOf(shading, "ShadingType");
35
36
  const fn = dictionaryOf(resolved(state, shading.get(key("Function"))));
36
37
  const functionType = fn ? numberOf(fn, "FunctionType") : void 0;
37
38
  const space = resolvedColorSpace(state.document, resolved(state, shading.get(key("ColorSpace"))));
38
- const policy = shadingAction(space.kind, type, functionType);
39
+ const destination = state.report.destination;
40
+ const policy = shadingAction(space.kind, type, functionType, destination);
39
41
  if (policy.action !== "keep") {
40
42
  if (type === 1) refuse(`ShadingType 1 FunctionType ${functionType ?? "missing"} color space ${space.label}`, policy.supported);
41
43
  refuse(`ShadingType ${type ?? "missing"} color space ${space.label}`, policy.supported);
@@ -46,24 +48,33 @@ function inspectShading(state, shading) {
46
48
  }
47
49
  return true;
48
50
  }
49
- function inspectGroup(state, owner, maskSource) {
51
+ function inspectGroup(state, owner, mask) {
50
52
  const group = owner.lookupMaybe(key("Group"), PDFDict);
51
53
  if (!group || nameOf(group.get(key("S"))) !== "Transparency" || state.groups.has(group)) return;
52
54
  state.groups.add(group);
53
55
  const space = resolvedColorSpace(state.document, resolved(state, group.get(key("CS"))));
54
- const policy = groupAction(space.kind, space.label, maskSource);
56
+ const destination = state.report.destination;
57
+ const policy = groupAction(space.kind, space.label, mask !== "none", destination);
55
58
  if (policy.action !== "keep") refuse(`transparency group color space ${space.label}`, policy.supported);
56
- if (space.kind === "cmyk") state.report.groups++;
59
+ if (space.kind === destination) state.report.groups++;
57
60
  }
58
- async function inspectPressPdf(bytes) {
61
+ function outputDestination(document) {
62
+ const intent = document.catalog.lookupMaybe(key("OutputIntents"), PDFArray)?.lookup(0, PDFDict);
63
+ const profile = rawStreamOf(intent?.get(key("DestOutputProfile")) ? document.context.lookup(intent.get(key("DestOutputProfile"))) : void 0);
64
+ return profile && numberOf(profile.dict, "N") === 1 ? "gray" : "cmyk";
65
+ }
66
+ async function inspectPressPdf(bytes, requested) {
59
67
  const document = await PDFDocument.load(bytes, { updateMetadata: false });
68
+ const destination = requested ?? outputDestination(document);
60
69
  const state = {
61
70
  budget: { decodedBytes: 0 },
62
71
  document,
63
72
  groups: /* @__PURE__ */ new WeakSet(),
64
73
  images: /* @__PURE__ */ new WeakSet(),
65
74
  report: {
75
+ destination,
66
76
  images: [],
77
+ convertedImages: 0,
67
78
  fills: 0,
68
79
  strokes: 0,
69
80
  gradientStops: 0,
@@ -75,14 +86,14 @@ async function inspectPressPdf(bytes) {
75
86
  };
76
87
  walkPressGraph(document, {
77
88
  content: (_object, stream) => {
78
- const counts = inspectContent(decodeContent(stream, state.budget));
89
+ const counts = inspectContent(decodeContent(stream, state.budget), destination);
79
90
  state.report.fills += counts.fills;
80
91
  state.report.strokes += counts.strokes;
81
92
  },
82
- function: (fn, decision) => inspectFunction(state, fn, decision),
83
- group: (owner, _stream, maskSource) => inspectGroup(state, owner, maskSource),
93
+ function: (fn, decision, mask) => inspectFunction(state, fn, decision, mask),
94
+ group: (owner, _stream, mask) => inspectGroup(state, owner, mask),
84
95
  image: (image) => inspectImage(state, image),
85
- shading: (shading) => inspectShading(state, shading)
96
+ shading: (shading, _pattern, mask) => inspectShading(state, shading, mask)
86
97
  });
87
98
  return state.report;
88
99
  }
@@ -10,7 +10,6 @@ const MAX_SHADING_SAMPLES = 1048576;
10
10
  const MAX_DOCUMENT_SHADING_SAMPLES = 4 * MAX_SHADING_SAMPLES;
11
11
  const MAX_DOCUMENT_CALCULATOR_OPERATIONS = 8 * MAX_SHADING_SAMPLES * 52;
12
12
  const MAX_SAMPLING_COLOR_CACHE = 4096;
13
- const SAMPLE_CHANNELS = 4;
14
13
  const SAMPLE_BYTES = 1;
15
14
  function refuse(found, supported) {
16
15
  throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
@@ -186,12 +185,12 @@ function domainValue(min, max, index, size) {
186
185
  if (size <= 1) return min;
187
186
  return min + (max - min) * index / (size - 1);
188
187
  }
189
- function sampleShadingToCmyk(program, domain, size, convert) {
188
+ function sampleShading(program, domain, size, convert, channels) {
190
189
  const x0 = domain[0] ?? 0;
191
190
  const x1 = domain[1] ?? 0;
192
191
  const y0 = domain[2] ?? 0;
193
192
  const y1 = domain[3] ?? 0;
194
- const samples = new Uint8Array(size.n * size.m * SAMPLE_CHANNELS * SAMPLE_BYTES);
193
+ const samples = new Uint8Array(size.n * size.m * channels * SAMPLE_BYTES);
195
194
  const toCmyk = createSamplingColorConverter(convert).convert;
196
195
  let offset = 0;
197
196
  for (let j = 0; j < size.m; j++) {
@@ -213,8 +212,8 @@ function sampleShadingToCmyk(program, domain, size, convert) {
213
212
  }
214
213
  return samples;
215
214
  }
216
- function chargeShadingSamples(budget, samples, operations) {
217
- const bytes = samples * SAMPLE_CHANNELS * SAMPLE_BYTES;
215
+ function chargeShadingSamples(budget, samples, operations, channels = 4) {
216
+ const bytes = samples * channels * SAMPLE_BYTES;
218
217
  if (!Number.isFinite(samples) || samples < 0 || !Number.isFinite(bytes) || bytes < 0) refuse(`document shading samples ${samples}`, "a finite non-negative sample count and byte charge");
219
218
  if (!Number.isFinite(operations) || operations < 0) refuse(`document calculator operations ${operations}`, "a finite non-negative operator count");
220
219
  const totalSamples = (budget.shadingSamples ?? 0) + samples;
@@ -228,7 +227,7 @@ function chargeShadingSamples(budget, samples, operations) {
228
227
  budget.calculatorOperations = totalOperations;
229
228
  }
230
229
  const resolved = (document, object) => object instanceof PDFRef ? document.context.lookup(object) : object;
231
- function replaceType1Function(document, shading, pattern, convert, budget, painted, sampleDomain, sampleSize) {
230
+ function replaceType1Function(document, shading, pattern, convert, budget, painted, sampleDomain, sampleSize, channels = 4) {
232
231
  const existing = shading.get(PDFName.of("Function"));
233
232
  const stream = rawStreamOf(resolved(document, existing));
234
233
  if (!stream) return refuse("shading FunctionType 4 stream", "a FunctionType 4 stream");
@@ -239,21 +238,12 @@ function replaceType1Function(document, shading, pattern, convert, budget, paint
239
238
  const size = sampleSize ?? shadingSampleGrid(domain, matrix);
240
239
  const program = parseCalculator(new TextDecoder("latin1").decode(decodeContent(stream, budget)));
241
240
  const samples = size.n * size.m;
242
- chargeShadingSamples(budget, samples, samples * staticOperatorCount(program));
243
- const data = sampleShadingToCmyk(program, domain, size, convert);
241
+ chargeShadingSamples(budget, samples, samples * staticOperatorCount(program), channels);
242
+ const data = sampleShading(program, domain, size, convert, channels);
244
243
  const replacement = document.context.stream(deflateSync(data), {
245
244
  FunctionType: 0,
246
245
  Domain: domain,
247
- Range: [
248
- 0,
249
- 1,
250
- 0,
251
- 1,
252
- 0,
253
- 1,
254
- 0,
255
- 1
256
- ],
246
+ Range: Array.from({ length: channels }, () => [0, 1]).flat(),
257
247
  Size: [size.n, size.m],
258
248
  BitsPerSample: 8,
259
249
  Order: 1,
@@ -263,4 +253,4 @@ function replaceType1Function(document, shading, pattern, convert, budget, paint
263
253
  return size;
264
254
  }
265
255
  //#endregion
266
- export { MAX_DOCUMENT_CALCULATOR_OPERATIONS, MAX_DOCUMENT_SHADING_SAMPLES, MAX_SAMPLING_COLOR_CACHE, MAX_SHADING_SAMPLES, SHADING_SAMPLE_SPACING_PT, chargeShadingSamples, createSamplingColorConverter, domainValue, matrixOf, packedRgb8, replaceType1Function, sampleDomainForPainted, sampleDomainForUses, sampleShadingToCmyk, shadingDeviceMatrix, shadingDomainOf, shadingSampleGrid, shadingSampleGridForUses, unionSampleDomain, unpackRgb8 };
256
+ export { MAX_DOCUMENT_CALCULATOR_OPERATIONS, MAX_DOCUMENT_SHADING_SAMPLES, MAX_SAMPLING_COLOR_CACHE, MAX_SHADING_SAMPLES, SHADING_SAMPLE_SPACING_PT, chargeShadingSamples, createSamplingColorConverter, domainValue, matrixOf, packedRgb8, replaceType1Function, sampleDomainForPainted, sampleDomainForUses, sampleShading, shadingDeviceMatrix, shadingDomainOf, shadingSampleGrid, shadingSampleGridForUses, unionSampleDomain, unpackRgb8 };
@@ -4,14 +4,19 @@ export interface PressImage {
4
4
  height: number;
5
5
  colorSpace: 'DeviceRGB' | 'CalRGB' | 'ICCBased' | 'Indexed';
6
6
  }
7
+ export type PressDestination = 'cmyk' | 'gray';
7
8
  export interface PressReport {
9
+ /** Device color space written for press-ready vector and generated-image output. */
10
+ destination: PressDestination;
8
11
  /** Authored raster images left in RGB, read back from the finished PDF. */
9
12
  images: PressImage[];
10
- /** CMYK painting and group counts read back from the finished PDF. */
13
+ /** Supplied raster image objects converted to DeviceGray. */
14
+ convertedImages: number;
15
+ /** Destination-space painting and group counts read back from the finished PDF. */
11
16
  fills: number;
12
17
  strokes: number;
13
18
  gradientStops: number;
14
- /** Function-based shadings rewritten as DeviceCMYK sampled functions. */
19
+ /** Function-based shadings rewritten as sampled functions in the destination space. */
15
20
  sampledShadings: number;
16
21
  groups: number;
17
22
  /** Color conversions snapped to black ink by the configured tolerance but not by the exact-neutral rule. */