@segnavia/render 0.4.0 → 0.6.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.
@@ -1,31 +1,50 @@
1
- import { toPress } from "../color/engine.js";
1
+ import { RenderRefusedError } from "../errors.js";
2
+ import { toGray, toPress } from "../color/engine.js";
2
3
  //#region src/press/color-policy.ts
3
4
  const NEUTRAL_TOLERANCE = .001;
4
- function pressColor(profile, rgb) {
5
+ function pressDestination(profile) {
6
+ return profile.colorSpace === "GRAY" ? "gray" : "cmyk";
7
+ }
8
+ function pdfMaskLuminosity([red, green, blue]) {
9
+ return [.3 * red + .59 * green + .11 * blue];
10
+ }
11
+ function assertNeutralTolerance(value) {
12
+ if (!Number.isFinite(value) || value < 0 || value > 255) throw new RenderRefusedError(`The press neutral tolerance is ${String(value)}; supported: a finite number from 0 to 255.`);
13
+ }
14
+ function snapsToNeutral(rgb, tolerance) {
15
+ return Math.max(...rgb) - Math.min(...rgb) <= Math.max(NEUTRAL_TOLERANCE, tolerance / 255) + Number.EPSILON;
16
+ }
17
+ function newlySnapsToNeutral(rgb, tolerance) {
18
+ return snapsToNeutral(rgb, tolerance) && !snapsToNeutral(rgb, 0);
19
+ }
20
+ function pressColor(profile, rgb, tolerance = 3) {
5
21
  const [red, green, blue] = rgb;
6
- if (Math.max(red, green, blue) - Math.min(red, green, blue) <= NEUTRAL_TOLERANCE) return [
22
+ if (snapsToNeutral(rgb, tolerance)) return [
7
23
  0,
8
24
  0,
9
25
  0,
10
- 1 - red
26
+ 1 - (red === green && green === blue ? red : (red + green + blue) / 3)
11
27
  ];
12
28
  return toPress(profile, rgb);
13
29
  }
30
+ function destinationColor(profile, rgb, tolerance = 3) {
31
+ return profile.colorSpace === "GRAY" ? toGray(profile, rgb) : pressColor(profile, rgb, tolerance);
32
+ }
14
33
  function formatPdfNumber(value) {
15
34
  const rounded = Math.round(value * 1e6) / 1e6;
16
35
  if (Object.is(rounded, -0)) return "0";
17
36
  return rounded.toFixed(6).replace(/(?:\.0+|(?<=\.[0-9]*?)0+)$/, "");
18
37
  }
19
- function cachedPressColor(profile) {
38
+ function cachedPressColor(profile, tolerance = 3) {
20
39
  const cache = /* @__PURE__ */ new Map();
21
40
  return (rgb) => {
22
- const key = rgb.join(",");
41
+ const key = `${tolerance}:${rgb.join(",")}`;
23
42
  const cached = cache.get(key);
24
43
  if (cached) return cached;
25
- const converted = pressColor(profile, rgb);
44
+ const converted = destinationColor(profile, rgb, tolerance);
26
45
  cache.set(key, converted);
27
46
  return converted;
28
47
  };
29
48
  }
30
49
  //#endregion
31
- export { cachedPressColor, formatPdfNumber, pressColor };
50
+ export { assertNeutralTolerance, cachedPressColor, destinationColor, formatPdfNumber, newlySnapsToNeutral, pdfMaskLuminosity, pressColor, pressDestination, snapsToNeutral };
@@ -120,7 +120,7 @@ function inlineHeader(source, at) {
120
120
  }
121
121
  return refusal("truncated BI operator", "an ID and EI pair");
122
122
  }
123
- function inlineImageEnd(source, at) {
123
+ function inlineImageEnd(source, at, destination) {
124
124
  const { colorSpace, id, imageMask } = inlineHeader(source, at);
125
125
  const match = /[\0\t\n\f\r ]EI(?=[\0\t\n\f\r ()<>[\]{}/%]|$)/g;
126
126
  match.lastIndex = id;
@@ -129,6 +129,7 @@ function inlineImageEnd(source, at) {
129
129
  const name = colorSpace?.startsWith("/") ? colorSpace.slice(1) : void 0;
130
130
  const policy = inlineImageAction(name, imageMask);
131
131
  if (policy.action === "refuse") refusal(`BI operator with color space ${policy.label}`, policy.supported);
132
+ if (destination === "gray" && policy.kind === "cmyk") refusal(`BI operator with color space ${policy.label}`, "RGB or gray input for a greyscale press");
132
133
  return end.index + end[0].length;
133
134
  }
134
135
  function skippedConstruct(source, cursor) {
@@ -144,7 +145,7 @@ function numeric(operands, count, operator) {
144
145
  return operands.map((token) => Number(token.raw));
145
146
  }
146
147
  function replacement(token, operands, values) {
147
- const operator = token.raw === "rg" || token.raw === "g" ? "k" : "K";
148
+ const operator = values.length === 1 ? token.raw === token.raw.toLowerCase() ? "g" : "G" : token.raw === token.raw.toLowerCase() ? "k" : "K";
148
149
  return {
149
150
  start: operands[0]?.start ?? token.start,
150
151
  end: token.end,
@@ -169,36 +170,43 @@ function replaceGray(token, operands, convert, counts) {
169
170
  gray
170
171
  ]));
171
172
  }
172
- function validateSpace(token, operands) {
173
+ function validateSpace(token, operands, destination) {
173
174
  if (operands.length !== 1 || operands[0]?.kind !== "name") refusal(`${token.raw} operator operands`, "one color space name");
174
175
  const name = operands[0].raw.slice(1);
175
- const policy = contentOperatorAction(token.raw, false, name);
176
+ const policy = contentOperatorAction(token.raw, false, name, false, destination);
176
177
  if (policy.action === "refuse") refusal(`${token.raw} operator selecting ${colorSpaceLabel({ name })}`, policy.supported);
177
178
  }
178
- function enforceOperatorPolicy(token, operands, policy) {
179
+ function enforceOperatorPolicy(token, operands, policy, destination) {
179
180
  if (policy.action !== "refuse") return;
180
181
  const operator = token.raw;
181
182
  if (operator === "cs" || operator === "CS") {
182
- validateSpace(token, operands);
183
+ validateSpace(token, operands, destination);
183
184
  return;
184
185
  }
185
186
  if (operator === "sc" || operator === "SC" || operator === "scn" || operator === "SCN") refusal(`${operator} operator with numeric operands`, policy.supported);
186
- refusal(`${operator === "g" || operator === "G" ? "gray" : "RGB"} color operator ${operator} after conversion`, policy.supported);
187
+ refusal(`${operator === "g" || operator === "G" ? "gray" : operator === "k" || operator === "K" ? "CMYK" : "RGB"} color operator ${operator} after conversion`, policy.supported);
187
188
  }
188
- function colorOperator(token, operands, convert, counts, inspect) {
189
+ function colorOperator(token, operands, convert, counts, inspect, destination) {
189
190
  const operator = token.raw;
190
191
  if (!isContentColorOperator(operator)) return void 0;
191
192
  const patternName = operands.length === 1 && operands[0]?.kind === "name";
192
- enforceOperatorPolicy(token, operands, contentOperatorAction(operator, inspect, void 0, patternName));
193
+ enforceOperatorPolicy(token, operands, contentOperatorAction(operator, inspect, void 0, patternName, destination), destination);
193
194
  if (operator === "rg" || operator === "RG") return replaceRgb(token, operands, convert, counts);
194
- if (operator === "g" || operator === "G") return replaceGray(token, operands, convert, counts);
195
- if (operator === "cs" || operator === "CS") validateSpace(token, operands);
195
+ if (operator === "g" || operator === "G") {
196
+ if (inspect && destination === "gray") {
197
+ numeric(operands, 1, operator);
198
+ counts[operator === "g" ? "fills" : "strokes"]++;
199
+ return;
200
+ }
201
+ return replaceGray(token, operands, convert, counts);
202
+ }
203
+ if (operator === "cs" || operator === "CS") validateSpace(token, operands, destination);
196
204
  else if (operator === "k" || operator === "K") {
197
205
  numeric(operands, 4, operator);
198
206
  counts[operator === "k" ? "fills" : "strokes"]++;
199
207
  }
200
208
  }
201
- function scan(source, convert, inspect, visit, operatorVisit) {
209
+ function scan(source, convert, inspect, destination, visit, operatorVisit) {
202
210
  const counts = {
203
211
  fills: 0,
204
212
  strokes: 0
@@ -218,11 +226,11 @@ function scan(source, convert, inspect, visit, operatorVisit) {
218
226
  }
219
227
  cursor = token.end;
220
228
  if (token.raw === "BI") {
221
- cursor = inlineImageEnd(source, cursor);
229
+ cursor = inlineImageEnd(source, cursor, destination);
222
230
  operands = [];
223
231
  } else if (token.kind === "operator") {
224
232
  operatorVisit?.(token, operands);
225
- const found = colorOperator(token, operands, convert, counts, inspect);
233
+ const found = colorOperator(token, operands, convert, counts, inspect, destination);
226
234
  if (found) visit?.(found);
227
235
  operands = [];
228
236
  } else {
@@ -241,7 +249,7 @@ function visitContentOperations(bytes, visit) {
241
249
  0,
242
250
  0,
243
251
  0
244
- ], false, void 0, (operator, operands) => {
252
+ ], false, "cmyk", void 0, (operator, operands) => {
245
253
  visit({
246
254
  end: operator.end,
247
255
  operands: operands.flatMap((operand) => operand.kind === "name" || operand.kind === "number" ? [{
@@ -255,14 +263,14 @@ function visitContentOperations(bytes, visit) {
255
263
  });
256
264
  });
257
265
  }
258
- function inspectContent(bytes) {
259
- return scan(decoder.decode(bytes), () => refusal("RGB color operator after conversion", "CMYK operators"), true);
266
+ function inspectContent(bytes, destination = "cmyk") {
267
+ return scan(decoder.decode(bytes), () => refusal("RGB color operator after conversion", `${destination === "gray" ? "gray" : "CMYK"} operators`), true, destination);
260
268
  }
261
- function rewriteContent(bytes, convert) {
269
+ function rewriteContent(bytes, convert, destination = "cmyk") {
262
270
  const source = decoder.decode(bytes);
263
271
  let outputLength = bytes.length;
264
272
  let changed = false;
265
- const counts = scan(source, convert, false, (found) => {
273
+ const counts = scan(source, convert, false, destination, (found) => {
266
274
  changed = true;
267
275
  outputLength += encoder.encode(found.text).length - (found.end - found.start);
268
276
  });
@@ -273,7 +281,7 @@ function rewriteContent(bytes, convert) {
273
281
  const output = new Uint8Array(outputLength);
274
282
  let inputAt = 0;
275
283
  let outputAt = 0;
276
- scan(source, convert, false, (found) => {
284
+ scan(source, convert, false, destination, (found) => {
277
285
  const untouched = bytes.subarray(inputAt, found.start);
278
286
  output.set(untouched, outputAt);
279
287
  outputAt += untouched.length;
@@ -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