@segnavia/render 0.1.0 → 0.2.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/color/binary.js +27 -0
- package/dist/color/black-point.js +121 -0
- package/dist/color/engine.js +18 -0
- package/dist/color/interpolation.js +74 -0
- package/dist/color/profile.js +98 -0
- package/dist/color/spaces.js +138 -0
- package/dist/color/tables.js +255 -0
- package/dist/color/types.js +21 -0
- package/dist/deck-pdf.js +152 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +14 -8
- package/dist/index.js +87 -76
- package/dist/page-paper.js +94 -0
- package/dist/pages-imposition.js +65 -0
- package/dist/pages-pdf.js +66 -0
- package/dist/pdf-compose.js +35 -0
- package/dist/pdf-geometry.js +27 -0
- package/dist/press/adapter.js +145 -0
- package/dist/press/color-policy.js +31 -0
- package/dist/press/content.js +269 -0
- package/dist/press/pdf-objects.js +88 -0
- package/dist/press/policy.js +120 -0
- package/dist/press/report.js +79 -0
- package/dist/press/types.d.ts +16 -0
- package/dist/press/visitor.js +140 -0
- package/package.json +3 -3
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { RenderRefusedError } from "../errors.js";
|
|
2
|
+
import { formatPdfNumber } from "./color-policy.js";
|
|
3
|
+
import { colorSpaceLabel, contentOperatorAction, inlineImageAction, isContentColorOperator } from "./policy.js";
|
|
4
|
+
const decoder = new TextDecoder("latin1");
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const numberPattern = /^[+-]?(?:\d+\.?\d*|\.\d+)$/;
|
|
7
|
+
const whitespace = (character) => character !== void 0 && /[\0\t\n\f\r ]/.test(character);
|
|
8
|
+
const delimiter = (character) => character !== void 0 && /[()<>{}[\]/%]/.test(character);
|
|
9
|
+
function refusal(found, supported) {
|
|
10
|
+
throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
|
|
11
|
+
}
|
|
12
|
+
function skipComment(source, at) {
|
|
13
|
+
let cursor = at + 1;
|
|
14
|
+
while (cursor < source.length && source[cursor] !== "\r" && source[cursor] !== "\n") cursor++;
|
|
15
|
+
return cursor;
|
|
16
|
+
}
|
|
17
|
+
function skipLiteral(source, at) {
|
|
18
|
+
let cursor = at + 1;
|
|
19
|
+
let depth = 1;
|
|
20
|
+
while (cursor < source.length) {
|
|
21
|
+
const character = source[cursor];
|
|
22
|
+
if (character === "\\") {
|
|
23
|
+
cursor += 2;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (character === "(" && ++depth > 64) refusal(`literal string nesting over 64`, `at most 64 levels`);
|
|
27
|
+
if (character === ")" && --depth === 0) return cursor + 1;
|
|
28
|
+
cursor++;
|
|
29
|
+
}
|
|
30
|
+
return refusal("truncated literal string", "a closing parenthesis");
|
|
31
|
+
}
|
|
32
|
+
function skipHex(source, at) {
|
|
33
|
+
const end = source.indexOf(">", at + 1);
|
|
34
|
+
if (end < 0) return refusal("truncated hex string", "a closing >");
|
|
35
|
+
return end + 1;
|
|
36
|
+
}
|
|
37
|
+
function skipComposite(source, at, dictionary, depth = 1) {
|
|
38
|
+
if (depth > 64) refusal(`dictionary or array nesting over 64`, `at most 64 levels`);
|
|
39
|
+
let cursor = at + (dictionary ? 2 : 1);
|
|
40
|
+
while (cursor < source.length) {
|
|
41
|
+
const character = source[cursor];
|
|
42
|
+
const nested = skipCompositeConstruct(source, cursor, depth);
|
|
43
|
+
if (nested !== void 0) {
|
|
44
|
+
cursor = nested;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (dictionary && character === ">" && source[cursor + 1] === ">") return cursor + 2;
|
|
48
|
+
if (!dictionary && character === "]") return cursor + 1;
|
|
49
|
+
cursor++;
|
|
50
|
+
}
|
|
51
|
+
return refusal(`truncated ${dictionary ? "dictionary" : "array"}`, dictionary ? "a closing >>" : "a closing ]");
|
|
52
|
+
}
|
|
53
|
+
function skipCompositeConstruct(source, cursor, depth) {
|
|
54
|
+
const character = source[cursor];
|
|
55
|
+
if (whitespace(character)) return cursor + 1;
|
|
56
|
+
if (character === "%") return skipComment(source, cursor);
|
|
57
|
+
if (character === "(") return skipLiteral(source, cursor);
|
|
58
|
+
if (character === "<") return source[cursor + 1] === "<" ? skipComposite(source, cursor, true, depth + 1) : skipHex(source, cursor);
|
|
59
|
+
if (character === "[") return skipComposite(source, cursor, false, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
function readToken(source, cursor) {
|
|
62
|
+
const start = cursor;
|
|
63
|
+
if (source[cursor] === "/") {
|
|
64
|
+
cursor++;
|
|
65
|
+
while (cursor < source.length && !whitespace(source[cursor]) && !delimiter(source[cursor])) cursor++;
|
|
66
|
+
return {
|
|
67
|
+
start,
|
|
68
|
+
end: cursor,
|
|
69
|
+
raw: source.slice(start, cursor),
|
|
70
|
+
kind: "name"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (delimiter(source[cursor])) return void 0;
|
|
74
|
+
while (cursor < source.length && !whitespace(source[cursor]) && !delimiter(source[cursor])) cursor++;
|
|
75
|
+
const raw = source.slice(start, cursor);
|
|
76
|
+
return {
|
|
77
|
+
start,
|
|
78
|
+
end: cursor,
|
|
79
|
+
raw,
|
|
80
|
+
kind: numberPattern.test(raw) ? "number" : "operator"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function inlineHeaderToken(source, at) {
|
|
84
|
+
let cursor = at;
|
|
85
|
+
while (whitespace(source[cursor])) cursor++;
|
|
86
|
+
if (source[cursor] === "[") {
|
|
87
|
+
const end = skipComposite(source, cursor, false);
|
|
88
|
+
return {
|
|
89
|
+
end,
|
|
90
|
+
raw: source.slice(cursor, end)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const token = readToken(source, cursor);
|
|
94
|
+
return token ? {
|
|
95
|
+
end: token.end,
|
|
96
|
+
raw: token.raw
|
|
97
|
+
} : {
|
|
98
|
+
end: cursor + 1,
|
|
99
|
+
raw: ""
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function inlineHeader(source, at) {
|
|
103
|
+
let cursor = at;
|
|
104
|
+
let name = "";
|
|
105
|
+
const values = /* @__PURE__ */ new Map();
|
|
106
|
+
while (cursor < source.length) {
|
|
107
|
+
const token = inlineHeaderToken(source, cursor);
|
|
108
|
+
cursor = token.end;
|
|
109
|
+
if (token.raw === "ID") return {
|
|
110
|
+
colorSpace: values.get("/CS") ?? values.get("/ColorSpace"),
|
|
111
|
+
id: cursor,
|
|
112
|
+
imageMask: values.get("/IM") === "true" || values.get("/ImageMask") === "true"
|
|
113
|
+
};
|
|
114
|
+
if (!token.raw) continue;
|
|
115
|
+
if (!name) name = token.raw;
|
|
116
|
+
else {
|
|
117
|
+
values.set(name, token.raw);
|
|
118
|
+
name = "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return refusal("truncated BI operator", "an ID and EI pair");
|
|
122
|
+
}
|
|
123
|
+
function inlineImageEnd(source, at) {
|
|
124
|
+
const { colorSpace, id, imageMask } = inlineHeader(source, at);
|
|
125
|
+
const match = /[\0\t\n\f\r ]EI(?=[\0\t\n\f\r ()<>[\]{}/%]|$)/g;
|
|
126
|
+
match.lastIndex = id;
|
|
127
|
+
const end = match.exec(source);
|
|
128
|
+
if (!end) return refusal("truncated BI operator", "an ID and EI pair");
|
|
129
|
+
const name = colorSpace?.startsWith("/") ? colorSpace.slice(1) : void 0;
|
|
130
|
+
const policy = inlineImageAction(name, imageMask);
|
|
131
|
+
if (policy.action === "refuse") refusal(`BI operator with color space ${policy.label}`, policy.supported);
|
|
132
|
+
return end.index + end[0].length;
|
|
133
|
+
}
|
|
134
|
+
function skippedConstruct(source, cursor) {
|
|
135
|
+
const character = source[cursor];
|
|
136
|
+
if (whitespace(character)) return cursor + 1;
|
|
137
|
+
if (character === "%") return skipComment(source, cursor);
|
|
138
|
+
if (character === "(") return skipLiteral(source, cursor);
|
|
139
|
+
if (character === "<") return source[cursor + 1] === "<" ? skipComposite(source, cursor, true) : skipHex(source, cursor);
|
|
140
|
+
if (character === "[") return skipComposite(source, cursor, false);
|
|
141
|
+
}
|
|
142
|
+
function numeric(operands, count, operator) {
|
|
143
|
+
if (operands.length !== count || operands.some((token) => token.kind !== "number")) refusal(`${operator} operator operands`, `${count} numeric operands`);
|
|
144
|
+
return operands.map((token) => Number(token.raw));
|
|
145
|
+
}
|
|
146
|
+
function replacement(token, operands, values) {
|
|
147
|
+
const operator = token.raw === "rg" || token.raw === "g" ? "k" : "K";
|
|
148
|
+
return {
|
|
149
|
+
start: operands[0]?.start ?? token.start,
|
|
150
|
+
end: token.end,
|
|
151
|
+
text: `${values.map(formatPdfNumber).join(" ")} ${operator}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function replaceRgb(token, operands, convert, counts) {
|
|
155
|
+
const [red = 0, green = 0, blue = 0] = numeric(operands, 3, token.raw);
|
|
156
|
+
counts[token.raw === "rg" ? "fills" : "strokes"]++;
|
|
157
|
+
return replacement(token, operands, convert([
|
|
158
|
+
red,
|
|
159
|
+
green,
|
|
160
|
+
blue
|
|
161
|
+
]));
|
|
162
|
+
}
|
|
163
|
+
function replaceGray(token, operands, convert, counts) {
|
|
164
|
+
const [gray = 0] = numeric(operands, 1, token.raw);
|
|
165
|
+
counts[token.raw === "g" ? "fills" : "strokes"]++;
|
|
166
|
+
return replacement(token, operands, convert([
|
|
167
|
+
gray,
|
|
168
|
+
gray,
|
|
169
|
+
gray
|
|
170
|
+
]));
|
|
171
|
+
}
|
|
172
|
+
function validateSpace(token, operands) {
|
|
173
|
+
if (operands.length !== 1 || operands[0]?.kind !== "name") refusal(`${token.raw} operator operands`, "one color space name");
|
|
174
|
+
const name = operands[0].raw.slice(1);
|
|
175
|
+
const policy = contentOperatorAction(token.raw, false, name);
|
|
176
|
+
if (policy.action === "refuse") refusal(`${token.raw} operator selecting ${colorSpaceLabel({ name })}`, policy.supported);
|
|
177
|
+
}
|
|
178
|
+
function enforceOperatorPolicy(token, operands, policy) {
|
|
179
|
+
if (policy.action !== "refuse") return;
|
|
180
|
+
const operator = token.raw;
|
|
181
|
+
if (operator === "cs" || operator === "CS") {
|
|
182
|
+
validateSpace(token, operands);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
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
|
+
}
|
|
188
|
+
function colorOperator(token, operands, convert, counts, inspect) {
|
|
189
|
+
const operator = token.raw;
|
|
190
|
+
if (!isContentColorOperator(operator)) return void 0;
|
|
191
|
+
const patternName = operands.length === 1 && operands[0]?.kind === "name";
|
|
192
|
+
enforceOperatorPolicy(token, operands, contentOperatorAction(operator, inspect, void 0, patternName));
|
|
193
|
+
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);
|
|
196
|
+
else if (operator === "k" || operator === "K") {
|
|
197
|
+
numeric(operands, 4, operator);
|
|
198
|
+
counts[operator === "k" ? "fills" : "strokes"]++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function scan(source, convert, inspect, visit, operatorVisit) {
|
|
202
|
+
const counts = {
|
|
203
|
+
fills: 0,
|
|
204
|
+
strokes: 0
|
|
205
|
+
};
|
|
206
|
+
let operands = [];
|
|
207
|
+
let cursor = 0;
|
|
208
|
+
while (cursor < source.length) {
|
|
209
|
+
const skipped = skippedConstruct(source, cursor);
|
|
210
|
+
if (skipped !== void 0) {
|
|
211
|
+
cursor = skipped;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const token = readToken(source, cursor);
|
|
215
|
+
if (!token) {
|
|
216
|
+
cursor++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
cursor = token.end;
|
|
220
|
+
if (token.raw === "BI") {
|
|
221
|
+
cursor = inlineImageEnd(source, cursor);
|
|
222
|
+
operands = [];
|
|
223
|
+
} else if (token.kind === "operator") {
|
|
224
|
+
operatorVisit?.(token, operands);
|
|
225
|
+
const found = colorOperator(token, operands, convert, counts, inspect);
|
|
226
|
+
if (found) visit?.(found);
|
|
227
|
+
operands = [];
|
|
228
|
+
} else {
|
|
229
|
+
if (operands.length >= 32) refusal(`more than 32 operands before an operator`, `at most 32 operands`);
|
|
230
|
+
operands.push(token);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return counts;
|
|
234
|
+
}
|
|
235
|
+
function inspectContent(bytes) {
|
|
236
|
+
return scan(decoder.decode(bytes), () => refusal("RGB color operator after conversion", "CMYK operators"), true);
|
|
237
|
+
}
|
|
238
|
+
function rewriteContent(bytes, convert) {
|
|
239
|
+
const source = decoder.decode(bytes);
|
|
240
|
+
let outputLength = bytes.length;
|
|
241
|
+
let changed = false;
|
|
242
|
+
const counts = scan(source, convert, false, (found) => {
|
|
243
|
+
changed = true;
|
|
244
|
+
outputLength += encoder.encode(found.text).length - (found.end - found.start);
|
|
245
|
+
});
|
|
246
|
+
if (!changed) return {
|
|
247
|
+
bytes,
|
|
248
|
+
counts
|
|
249
|
+
};
|
|
250
|
+
const output = new Uint8Array(outputLength);
|
|
251
|
+
let inputAt = 0;
|
|
252
|
+
let outputAt = 0;
|
|
253
|
+
scan(source, convert, false, (found) => {
|
|
254
|
+
const untouched = bytes.subarray(inputAt, found.start);
|
|
255
|
+
output.set(untouched, outputAt);
|
|
256
|
+
outputAt += untouched.length;
|
|
257
|
+
const inserted = encoder.encode(found.text);
|
|
258
|
+
output.set(inserted, outputAt);
|
|
259
|
+
outputAt += inserted.length;
|
|
260
|
+
inputAt = found.end;
|
|
261
|
+
});
|
|
262
|
+
output.set(bytes.subarray(inputAt), outputAt);
|
|
263
|
+
return {
|
|
264
|
+
bytes: output,
|
|
265
|
+
counts
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
export { inspectContent, rewriteContent };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { RenderRefusedError } from "../errors.js";
|
|
2
|
+
import { colorSpaceKind, colorSpaceLabel, contentFilter } from "./policy.js";
|
|
3
|
+
import { PDFArray, PDFDict, PDFName, PDFNumber, PDFRawStream, PDFRef, PDFStream } from "pdf-lib";
|
|
4
|
+
import { inflateSync } from "node:zlib";
|
|
5
|
+
//#region src/press/pdf-objects.ts
|
|
6
|
+
const MAX_CONTENT_BYTES = 33554432;
|
|
7
|
+
const MAX_DOCUMENT_CONTENT_BYTES = 134217728;
|
|
8
|
+
const nameOf = (object) => object instanceof PDFName ? object.toString().slice(1) : void 0;
|
|
9
|
+
function dictionaryOf(object) {
|
|
10
|
+
if (object instanceof PDFDict) return object;
|
|
11
|
+
if (object instanceof PDFStream) return object.dict;
|
|
12
|
+
}
|
|
13
|
+
function numberOf(dictionary, key) {
|
|
14
|
+
return dictionary.lookupMaybe(PDFName.of(key), PDFNumber)?.asNumber();
|
|
15
|
+
}
|
|
16
|
+
function arrayNumbers(dictionary, key, count) {
|
|
17
|
+
const array = dictionary.lookupMaybe(PDFName.of(key), PDFArray);
|
|
18
|
+
if (!array || array.size() !== count) throw new Error(`PDF ${key} has unsupported length ${array?.size() ?? "missing"}; supported: ${count} numbers`);
|
|
19
|
+
return Array.from({ length: count }, (_, index) => {
|
|
20
|
+
const value = array.lookupMaybe(index, PDFNumber)?.asNumber();
|
|
21
|
+
if (value === void 0) throw new Error(`PDF ${key} has unsupported non-number at index ${index}; supported: ${count} numbers`);
|
|
22
|
+
return value;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function replaceArray(dictionary, key, values) {
|
|
26
|
+
const array = dictionary.context.obj(values);
|
|
27
|
+
dictionary.set(PDFName.of(key), array);
|
|
28
|
+
}
|
|
29
|
+
function rawStreamOf(object) {
|
|
30
|
+
return object instanceof PDFRawStream ? object : void 0;
|
|
31
|
+
}
|
|
32
|
+
const resolved = (document, object) => object instanceof PDFRef ? document.context.lookup(object) : object;
|
|
33
|
+
function colorSpaceValue(document, object) {
|
|
34
|
+
const value = resolved(document, object);
|
|
35
|
+
const name = nameOf(value);
|
|
36
|
+
if (name) return { name };
|
|
37
|
+
if (!(value instanceof PDFArray)) return void 0;
|
|
38
|
+
const family = nameOf(resolved(document, value.get(0)));
|
|
39
|
+
if (family === "ICCBased") return {
|
|
40
|
+
family,
|
|
41
|
+
channels: rawStreamOf(resolved(document, value.get(1)))?.dict.lookupMaybe(PDFName.of("N"), PDFNumber)?.asNumber()
|
|
42
|
+
};
|
|
43
|
+
if (family === "Indexed") return {
|
|
44
|
+
family,
|
|
45
|
+
base: colorSpaceValue(document, value.get(1))
|
|
46
|
+
};
|
|
47
|
+
return { family };
|
|
48
|
+
}
|
|
49
|
+
function resolvedColorSpace(document, object) {
|
|
50
|
+
const value = colorSpaceValue(document, object);
|
|
51
|
+
return {
|
|
52
|
+
kind: colorSpaceKind(value),
|
|
53
|
+
label: object ? colorSpaceLabel(value) : "missing"
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function filterNames(filter) {
|
|
57
|
+
if (!filter) return void 0;
|
|
58
|
+
const direct = nameOf(filter);
|
|
59
|
+
if (direct) return [direct];
|
|
60
|
+
if (!(filter instanceof PDFArray)) return [];
|
|
61
|
+
const names = [];
|
|
62
|
+
for (let index = 0; index < filter.size(); index++) {
|
|
63
|
+
const name = nameOf(filter.get(index));
|
|
64
|
+
if (!name) return [];
|
|
65
|
+
names.push(name);
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
function decodeContent(stream, budget) {
|
|
70
|
+
const filter = stream.dict.get(PDFName.of("Filter"));
|
|
71
|
+
const decision = contentFilter(filterNames(filter));
|
|
72
|
+
if (decision.action === "refuse") throw new RenderRefusedError(`The PDF has unsupported content stream filter other; supported: ${decision.supported}.`);
|
|
73
|
+
let decoded;
|
|
74
|
+
try {
|
|
75
|
+
decoded = decision.encoding === "compressed" ? inflateSync(stream.contents, { maxOutputLength: MAX_CONTENT_BYTES }) : stream.contents;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code === "ERR_BUFFER_TOO_LARGE") throw new RenderRefusedError(`The PDF has unsupported decoded content stream longer than ${MAX_CONTENT_BYTES} bytes; supported: at most ${MAX_CONTENT_BYTES} bytes.`);
|
|
78
|
+
throw new RenderRefusedError(`The PDF has unsupported content stream decode failure; supported: a valid FlateDecode or unfiltered stream.`);
|
|
79
|
+
}
|
|
80
|
+
if (decoded.byteLength > 33554432) throw new RenderRefusedError(`The PDF has unsupported decoded content stream length ${decoded.byteLength}; supported: at most ${MAX_CONTENT_BYTES} bytes.`);
|
|
81
|
+
if (budget) {
|
|
82
|
+
budget.decodedBytes += decoded.byteLength;
|
|
83
|
+
if (budget.decodedBytes > 134217728) throw new RenderRefusedError(`The PDF has unsupported total decoded content length ${budget.decodedBytes}; supported: at most ${MAX_DOCUMENT_CONTENT_BYTES} bytes.`);
|
|
84
|
+
}
|
|
85
|
+
return decoded;
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { MAX_CONTENT_BYTES, MAX_DOCUMENT_CONTENT_BYTES, arrayNumbers, decodeContent, dictionaryOf, nameOf, numberOf, rawStreamOf, replaceArray, resolvedColorSpace };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//#region src/press/policy.ts
|
|
2
|
+
function colorSpaceKind(value) {
|
|
3
|
+
if (value?.name === "DeviceRGB" || value?.name === "CalRGB") return "rgb";
|
|
4
|
+
if (value?.name === "DeviceGray") return "gray";
|
|
5
|
+
if (value?.name === "DeviceCMYK") return "cmyk";
|
|
6
|
+
if (value?.family === "ICCBased") return value.channels === 3 ? "rgb" : value.channels === 1 ? "gray" : value.channels === 4 ? "cmyk" : "other";
|
|
7
|
+
if (value?.family === "Indexed") return colorSpaceKind(value.base);
|
|
8
|
+
return "other";
|
|
9
|
+
}
|
|
10
|
+
function colorSpaceLabel(value) {
|
|
11
|
+
const label = value?.name ?? value?.family;
|
|
12
|
+
if (!label) return "missing";
|
|
13
|
+
return [
|
|
14
|
+
"DeviceRGB",
|
|
15
|
+
"DeviceGray",
|
|
16
|
+
"DeviceCMYK",
|
|
17
|
+
"CalRGB",
|
|
18
|
+
"ICCBased",
|
|
19
|
+
"Indexed",
|
|
20
|
+
"Pattern"
|
|
21
|
+
].includes(label) ? label : "other";
|
|
22
|
+
}
|
|
23
|
+
const decision = (action, supported) => ({
|
|
24
|
+
action,
|
|
25
|
+
supported
|
|
26
|
+
});
|
|
27
|
+
function contentFilter(names) {
|
|
28
|
+
const supported = `FlateDecode or no filter`;
|
|
29
|
+
if (!names) return {
|
|
30
|
+
...decision("keep", supported),
|
|
31
|
+
encoding: "none"
|
|
32
|
+
};
|
|
33
|
+
if (names.length === 1 && names[0] === "FlateDecode") return {
|
|
34
|
+
...decision("keep", supported),
|
|
35
|
+
encoding: "compressed"
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
...decision("refuse", supported),
|
|
39
|
+
encoding: "unsupported"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function groupAction(kind, label, maskSource) {
|
|
43
|
+
const supported = "DeviceCMYK, DeviceGray, DeviceRGB on a mask source, or no declared space";
|
|
44
|
+
if (kind === "gray" || kind === "cmyk" || maskSource && (label === "DeviceRGB" || label === "missing")) return decision("keep", supported);
|
|
45
|
+
if (label === "missing" || label === "DeviceRGB") return decision("convert", supported);
|
|
46
|
+
return decision("refuse", supported);
|
|
47
|
+
}
|
|
48
|
+
function shadingAction(kind, type) {
|
|
49
|
+
const supported = "ShadingType 2 or 3 in an RGB or DeviceCMYK color space";
|
|
50
|
+
if (type !== 2 && type !== 3) return decision("refuse", supported);
|
|
51
|
+
if (kind === "rgb") return decision("convert", supported);
|
|
52
|
+
if (kind === "cmyk") return decision("keep", supported);
|
|
53
|
+
return decision("refuse", supported);
|
|
54
|
+
}
|
|
55
|
+
function shadingFunctionAction(type, dictionary, functions) {
|
|
56
|
+
if (!dictionary) return {
|
|
57
|
+
...decision("refuse", "FunctionType 2 or 3"),
|
|
58
|
+
found: "shading function other"
|
|
59
|
+
};
|
|
60
|
+
if (type !== 2 && type !== 3) return {
|
|
61
|
+
...decision("refuse", "FunctionType 2 or 3"),
|
|
62
|
+
found: `shading FunctionType ${type ?? "missing"}`
|
|
63
|
+
};
|
|
64
|
+
if (type === 3 && !functions) return {
|
|
65
|
+
...decision("refuse", "a Functions array"),
|
|
66
|
+
found: "FunctionType 3 Functions missing"
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
...decision("keep", "FunctionType 2 or 3"),
|
|
70
|
+
found: `shading FunctionType ${type}`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const inlineNames = {
|
|
74
|
+
G: { name: "DeviceGray" },
|
|
75
|
+
DeviceGray: { name: "DeviceGray" },
|
|
76
|
+
RGB: { name: "DeviceRGB" },
|
|
77
|
+
DeviceRGB: { name: "DeviceRGB" },
|
|
78
|
+
CMYK: { name: "DeviceCMYK" },
|
|
79
|
+
DeviceCMYK: { name: "DeviceCMYK" },
|
|
80
|
+
I: { family: "Indexed" },
|
|
81
|
+
Indexed: { family: "Indexed" },
|
|
82
|
+
CalRGB: { name: "CalRGB" }
|
|
83
|
+
};
|
|
84
|
+
function inlineImageAction(name, imageMask) {
|
|
85
|
+
const value = name ? inlineNames[name] ?? { name } : void 0;
|
|
86
|
+
const kind = colorSpaceKind(value);
|
|
87
|
+
return {
|
|
88
|
+
...decision(imageMask || kind === "gray" || kind === "cmyk" ? "keep" : "refuse", "an image mask, DeviceGray, or DeviceCMYK"),
|
|
89
|
+
kind,
|
|
90
|
+
label: colorSpaceLabel(value)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function contentColorSpaceAction(name) {
|
|
94
|
+
return decision(name === "Pattern" || name === "DeviceCMYK" ? "keep" : "refuse", "Pattern or DeviceCMYK");
|
|
95
|
+
}
|
|
96
|
+
const colorOperators = [
|
|
97
|
+
"rg",
|
|
98
|
+
"RG",
|
|
99
|
+
"g",
|
|
100
|
+
"G",
|
|
101
|
+
"k",
|
|
102
|
+
"K",
|
|
103
|
+
"sc",
|
|
104
|
+
"SC",
|
|
105
|
+
"scn",
|
|
106
|
+
"SCN",
|
|
107
|
+
"cs",
|
|
108
|
+
"CS"
|
|
109
|
+
];
|
|
110
|
+
function isContentColorOperator(operator) {
|
|
111
|
+
return colorOperators.includes(operator);
|
|
112
|
+
}
|
|
113
|
+
function contentOperatorAction(operator, inspect, selectedSpace, patternName = false) {
|
|
114
|
+
if (operator === "rg" || operator === "RG" || operator === "g" || operator === "G") return decision(inspect ? "refuse" : "convert", inspect ? "CMYK operators" : "RGB or DeviceGray color operands");
|
|
115
|
+
if (operator === "k" || operator === "K") return decision("keep", "four CMYK operands");
|
|
116
|
+
if (operator === "cs" || operator === "CS") return contentColorSpaceAction(selectedSpace);
|
|
117
|
+
return decision((operator === "scn" || operator === "SCN") && patternName ? "keep" : "refuse", "a pattern name with scn or SCN");
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
export { colorSpaceKind, colorSpaceLabel, contentColorSpaceAction, contentFilter, contentOperatorAction, groupAction, inlineImageAction, isContentColorOperator, shadingAction, shadingFunctionAction };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { RenderRefusedError } from "../errors.js";
|
|
2
|
+
import { groupAction, shadingAction } from "./policy.js";
|
|
3
|
+
import { inspectContent } from "./content.js";
|
|
4
|
+
import { decodeContent, nameOf, numberOf, resolvedColorSpace } from "./pdf-objects.js";
|
|
5
|
+
import { walkPressGraph } from "./visitor.js";
|
|
6
|
+
import { PDFDict, PDFDocument, PDFName, PDFRef } from "pdf-lib";
|
|
7
|
+
//#region src/press/report.ts
|
|
8
|
+
const key = (name) => PDFName.of(name);
|
|
9
|
+
const refuse = (found, supported) => {
|
|
10
|
+
throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
|
|
11
|
+
};
|
|
12
|
+
const resolved = (state, object) => object instanceof PDFRef ? state.document.context.lookup(object) : object;
|
|
13
|
+
const requiredDimension = (value, name) => value ?? refuse(`RGB image ${name} missing`, `a ${name} number`);
|
|
14
|
+
function inspectImage(state, image) {
|
|
15
|
+
if (state.images.has(image)) return;
|
|
16
|
+
state.images.add(image);
|
|
17
|
+
const space = resolvedColorSpace(state.document, resolved(state, image.dict.get(key("ColorSpace"))));
|
|
18
|
+
if (space.kind !== "rgb") return;
|
|
19
|
+
const width = requiredDimension(numberOf(image.dict, "Width"), "Width");
|
|
20
|
+
const height = requiredDimension(numberOf(image.dict, "Height"), "Height");
|
|
21
|
+
state.report.images.push({
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
colorSpace: space.label
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function inspectFunction(state, fn, policy) {
|
|
28
|
+
if (policy.action === "refuse") refuse(policy.found, policy.supported);
|
|
29
|
+
if (fn && numberOf(fn, "FunctionType") === 2) state.report.gradientStops += 2;
|
|
30
|
+
}
|
|
31
|
+
function inspectShading(state, shading) {
|
|
32
|
+
if (state.shadings.has(shading)) return false;
|
|
33
|
+
state.shadings.add(shading);
|
|
34
|
+
const type = numberOf(shading, "ShadingType");
|
|
35
|
+
const space = resolvedColorSpace(state.document, resolved(state, shading.get(key("ColorSpace"))));
|
|
36
|
+
const policy = shadingAction(space.kind, type);
|
|
37
|
+
if (policy.action === "refuse" || policy.action === "convert") refuse(`ShadingType ${type ?? "missing"} color space ${space.label}`, policy.supported);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function inspectGroup(state, owner, maskSource) {
|
|
41
|
+
const group = owner.lookupMaybe(key("Group"), PDFDict);
|
|
42
|
+
if (!group || nameOf(group.get(key("S"))) !== "Transparency" || state.groups.has(group)) return;
|
|
43
|
+
state.groups.add(group);
|
|
44
|
+
const space = resolvedColorSpace(state.document, resolved(state, group.get(key("CS"))));
|
|
45
|
+
const policy = groupAction(space.kind, space.label, maskSource);
|
|
46
|
+
if (policy.action !== "keep") refuse(`transparency group color space ${space.label}`, policy.supported);
|
|
47
|
+
if (space.kind === "cmyk") state.report.groups++;
|
|
48
|
+
}
|
|
49
|
+
async function inspectPressPdf(bytes) {
|
|
50
|
+
const document = await PDFDocument.load(bytes, { updateMetadata: false });
|
|
51
|
+
const state = {
|
|
52
|
+
budget: { decodedBytes: 0 },
|
|
53
|
+
document,
|
|
54
|
+
groups: /* @__PURE__ */ new WeakSet(),
|
|
55
|
+
images: /* @__PURE__ */ new WeakSet(),
|
|
56
|
+
report: {
|
|
57
|
+
images: [],
|
|
58
|
+
fills: 0,
|
|
59
|
+
strokes: 0,
|
|
60
|
+
gradientStops: 0,
|
|
61
|
+
groups: 0
|
|
62
|
+
},
|
|
63
|
+
shadings: /* @__PURE__ */ new WeakSet()
|
|
64
|
+
};
|
|
65
|
+
walkPressGraph(document, {
|
|
66
|
+
content: (_object, stream) => {
|
|
67
|
+
const counts = inspectContent(decodeContent(stream, state.budget));
|
|
68
|
+
state.report.fills += counts.fills;
|
|
69
|
+
state.report.strokes += counts.strokes;
|
|
70
|
+
},
|
|
71
|
+
function: (fn, decision) => inspectFunction(state, fn, decision),
|
|
72
|
+
group: (owner, _stream, maskSource) => inspectGroup(state, owner, maskSource),
|
|
73
|
+
image: (image) => inspectImage(state, image),
|
|
74
|
+
shading: (shading) => inspectShading(state, shading)
|
|
75
|
+
});
|
|
76
|
+
return state.report;
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { inspectPressPdf };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/press/types.d.ts
|
|
2
|
+
export interface PressImage {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
colorSpace: 'DeviceRGB' | 'CalRGB' | 'ICCBased' | 'Indexed';
|
|
6
|
+
}
|
|
7
|
+
export interface PressReport {
|
|
8
|
+
/** Authored raster images left in RGB, read back from the finished PDF. */
|
|
9
|
+
images: PressImage[];
|
|
10
|
+
/** CMYK painting and group counts read back from the finished PDF. */
|
|
11
|
+
fills: number;
|
|
12
|
+
strokes: number;
|
|
13
|
+
gradientStops: number;
|
|
14
|
+
groups: number;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|