@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.
- package/dist/book-pdf.js +15 -8
- package/dist/book-render.js +3 -2
- package/dist/browser-jpeg.js +42 -0
- package/dist/color/black-point.js +11 -8
- package/dist/color/engine.js +9 -2
- package/dist/color/interpolation.js +11 -1
- package/dist/color/profile.js +17 -7
- package/dist/color/tables.js +47 -13
- package/dist/color/types.js +4 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +42 -14
- package/dist/options.d.ts +34 -0
- package/dist/overflow.js +123 -0
- package/dist/page-paper.js +12 -7
- package/dist/pages-pdf.js +7 -4
- package/dist/press/adapter.js +97 -42
- package/dist/press/color-policy.js +12 -3
- package/dist/press/content.js +28 -20
- package/dist/press/image.js +327 -0
- package/dist/press/policy.js +27 -10
- package/dist/press/report.js +24 -13
- package/dist/press/sample-shading.js +9 -19
- package/dist/press/types.d.ts +7 -2
- package/dist/press/visitor.js +32 -30
- package/package.json +4 -4
|
@@ -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 };
|
package/dist/press/policy.js
CHANGED
|
@@ -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 ===
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
package/dist/press/report.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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 ===
|
|
59
|
+
if (space.kind === destination) state.report.groups++;
|
|
57
60
|
}
|
|
58
|
-
|
|
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,
|
|
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
|
|
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 *
|
|
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 *
|
|
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 =
|
|
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,
|
|
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 };
|
package/dist/press/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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. */
|