@segnavia/render 0.0.0-stage → 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/LICENSE +21 -0
- 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 +65 -0
- package/dist/index.js +322 -0
- 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 +32 -4
- package/README.md +0 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { RenderRefusedError } from "../errors.js";
|
|
2
|
+
import { readProfile } from "../color/profile.js";
|
|
3
|
+
import { cachedPressColor } from "./color-policy.js";
|
|
4
|
+
import { groupAction, shadingAction } from "./policy.js";
|
|
5
|
+
import { rewriteContent } from "./content.js";
|
|
6
|
+
import { arrayNumbers, decodeContent, nameOf, numberOf, replaceArray, resolvedColorSpace } from "./pdf-objects.js";
|
|
7
|
+
import { walkPressGraph } from "./visitor.js";
|
|
8
|
+
import { inspectPressPdf } from "./report.js";
|
|
9
|
+
import { PDFDict, PDFDocument, PDFName, PDFRef, PDFString } from "pdf-lib";
|
|
10
|
+
import { deflateSync } from "node:zlib";
|
|
11
|
+
//#region src/press/adapter.ts
|
|
12
|
+
const key = (name) => PDFName.of(name);
|
|
13
|
+
const refuse = (found, supported) => {
|
|
14
|
+
throw new RenderRefusedError(`The PDF has unsupported ${found}; supported: ${supported}.`);
|
|
15
|
+
};
|
|
16
|
+
const resolved = (state, object) => object instanceof PDFRef ? state.document.context.lookup(object) : object;
|
|
17
|
+
function convertFunction(state, fn, policy) {
|
|
18
|
+
if (policy.action === "refuse") refuse(policy.found, policy.supported);
|
|
19
|
+
if (!fn) return;
|
|
20
|
+
if (numberOf(fn, "FunctionType") === 2) {
|
|
21
|
+
for (const name of ["C0", "C1"]) replaceArray(fn, name, state.convert(arrayNumbers(fn, name, 3)));
|
|
22
|
+
if (fn.has(key("Range"))) replaceArray(fn, "Range", [
|
|
23
|
+
0,
|
|
24
|
+
1,
|
|
25
|
+
0,
|
|
26
|
+
1,
|
|
27
|
+
0,
|
|
28
|
+
1,
|
|
29
|
+
0,
|
|
30
|
+
1
|
|
31
|
+
]);
|
|
32
|
+
state.totals.gradientStops += 2;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (fn.has(key("Range"))) replaceArray(fn, "Range", [
|
|
36
|
+
0,
|
|
37
|
+
1,
|
|
38
|
+
0,
|
|
39
|
+
1,
|
|
40
|
+
0,
|
|
41
|
+
1,
|
|
42
|
+
0,
|
|
43
|
+
1
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
function convertShading(state, shading) {
|
|
47
|
+
if (state.seenShadings.has(shading)) return false;
|
|
48
|
+
state.seenShadings.add(shading);
|
|
49
|
+
const type = numberOf(shading, "ShadingType");
|
|
50
|
+
const space = resolvedColorSpace(state.document, resolved(state, shading.get(key("ColorSpace"))));
|
|
51
|
+
const policy = shadingAction(space.kind, type);
|
|
52
|
+
if (policy.action === "refuse") refuse(`ShadingType ${type ?? "missing"} color space ${space.label}`, policy.supported);
|
|
53
|
+
if (policy.action === "keep") return false;
|
|
54
|
+
if (shading.has(key("Background"))) replaceArray(shading, "Background", state.convert(arrayNumbers(shading, "Background", 3)));
|
|
55
|
+
shading.set(key("ColorSpace"), key("DeviceCMYK"));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
function convertGroup(state, owner, maskSource) {
|
|
59
|
+
const group = owner.lookupMaybe(key("Group"), PDFDict);
|
|
60
|
+
if (!group || nameOf(group.get(key("S"))) !== "Transparency" || state.seenGroups.has(group)) return;
|
|
61
|
+
state.seenGroups.add(group);
|
|
62
|
+
const space = resolvedColorSpace(state.document, resolved(state, group.get(key("CS"))));
|
|
63
|
+
const policy = groupAction(space.kind, space.label, maskSource);
|
|
64
|
+
if (policy.action === "refuse") refuse(`transparency group color space ${space.label}`, policy.supported);
|
|
65
|
+
if (policy.action === "convert") {
|
|
66
|
+
group.set(key("CS"), key("DeviceCMYK"));
|
|
67
|
+
state.totals.groups++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function streamDictionary(stream) {
|
|
71
|
+
const dictionary = { Filter: key("FlateDecode") };
|
|
72
|
+
for (const [name, value] of stream.dict.entries()) {
|
|
73
|
+
const label = name.toString().slice(1);
|
|
74
|
+
if (![
|
|
75
|
+
"Filter",
|
|
76
|
+
"DecodeParms",
|
|
77
|
+
"Length"
|
|
78
|
+
].includes(label)) dictionary[label] = value;
|
|
79
|
+
}
|
|
80
|
+
return dictionary;
|
|
81
|
+
}
|
|
82
|
+
function convertContent(state, object, stream, setDirect) {
|
|
83
|
+
const rewritten = rewriteContent(decodeContent(stream, state.budget), state.convert);
|
|
84
|
+
state.totals.fills += rewritten.counts.fills;
|
|
85
|
+
state.totals.strokes += rewritten.counts.strokes;
|
|
86
|
+
const replacement = state.document.context.stream(deflateSync(rewritten.bytes), streamDictionary(stream));
|
|
87
|
+
const reference = object instanceof PDFRef ? object : state.document.context.getObjectRef(object);
|
|
88
|
+
if (reference) state.document.context.assign(reference, replacement);
|
|
89
|
+
else setDirect(replacement);
|
|
90
|
+
}
|
|
91
|
+
function newState(document, profile) {
|
|
92
|
+
return {
|
|
93
|
+
budget: { decodedBytes: 0 },
|
|
94
|
+
convert: cachedPressColor(profile),
|
|
95
|
+
document,
|
|
96
|
+
seenGroups: /* @__PURE__ */ new WeakSet(),
|
|
97
|
+
seenShadings: /* @__PURE__ */ new WeakSet(),
|
|
98
|
+
totals: {
|
|
99
|
+
fills: 0,
|
|
100
|
+
strokes: 0,
|
|
101
|
+
gradientStops: 0,
|
|
102
|
+
groups: 0
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function rewritePressDocument(document, profile) {
|
|
107
|
+
const state = newState(document, profile);
|
|
108
|
+
walkPressGraph(document, {
|
|
109
|
+
content: (object, stream, replace) => convertContent(state, object, stream, replace),
|
|
110
|
+
function: (fn, decision) => convertFunction(state, fn, decision),
|
|
111
|
+
group: (owner, _stream, maskSource) => convertGroup(state, owner, maskSource),
|
|
112
|
+
image: () => void 0,
|
|
113
|
+
shading: (shading) => convertShading(state, shading)
|
|
114
|
+
});
|
|
115
|
+
return state.totals;
|
|
116
|
+
}
|
|
117
|
+
function addOutputIntent(document, profile) {
|
|
118
|
+
const profileRef = document.context.register(document.context.stream(profile, { N: 4 }));
|
|
119
|
+
const intentRef = document.context.register(document.context.obj({
|
|
120
|
+
Type: "OutputIntent",
|
|
121
|
+
S: "GTS_PDFX",
|
|
122
|
+
OutputConditionIdentifier: PDFString.of("Custom"),
|
|
123
|
+
DestOutputProfile: profileRef
|
|
124
|
+
}));
|
|
125
|
+
document.catalog.set(key("OutputIntents"), document.context.obj([intentRef]));
|
|
126
|
+
}
|
|
127
|
+
async function rewritePressPdf(bytes, profileBytes) {
|
|
128
|
+
try {
|
|
129
|
+
const profile = readProfile(profileBytes);
|
|
130
|
+
if (profile.colorSpace !== "CMYK") refuse(`press profile color space ${profile.colorSpace}`, "CMYK");
|
|
131
|
+
const document = await PDFDocument.load(bytes, { updateMetadata: false });
|
|
132
|
+
rewritePressDocument(document, profile);
|
|
133
|
+
addOutputIntent(document, profileBytes);
|
|
134
|
+
const output = await document.save({ useObjectStreams: false });
|
|
135
|
+
return {
|
|
136
|
+
bytes: output,
|
|
137
|
+
report: await inspectPressPdf(output)
|
|
138
|
+
};
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof RenderRefusedError) throw error;
|
|
141
|
+
throw new RenderRefusedError("The PDF has unsupported malformed structure; supported: a valid PDF produced by the renderer.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
export { rewritePressDocument, rewritePressPdf };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { toPress } from "../color/engine.js";
|
|
2
|
+
//#region src/press/color-policy.ts
|
|
3
|
+
const NEUTRAL_TOLERANCE = .001;
|
|
4
|
+
function pressColor(profile, rgb) {
|
|
5
|
+
const [red, green, blue] = rgb;
|
|
6
|
+
if (Math.max(red, green, blue) - Math.min(red, green, blue) <= NEUTRAL_TOLERANCE) return [
|
|
7
|
+
0,
|
|
8
|
+
0,
|
|
9
|
+
0,
|
|
10
|
+
1 - red
|
|
11
|
+
];
|
|
12
|
+
return toPress(profile, rgb);
|
|
13
|
+
}
|
|
14
|
+
function formatPdfNumber(value) {
|
|
15
|
+
const rounded = Math.round(value * 1e6) / 1e6;
|
|
16
|
+
if (Object.is(rounded, -0)) return "0";
|
|
17
|
+
return rounded.toFixed(6).replace(/(?:\.0+|(?<=\.[0-9]*?)0+)$/, "");
|
|
18
|
+
}
|
|
19
|
+
function cachedPressColor(profile) {
|
|
20
|
+
const cache = /* @__PURE__ */ new Map();
|
|
21
|
+
return (rgb) => {
|
|
22
|
+
const key = rgb.join(",");
|
|
23
|
+
const cached = cache.get(key);
|
|
24
|
+
if (cached) return cached;
|
|
25
|
+
const converted = pressColor(profile, rgb);
|
|
26
|
+
cache.set(key, converted);
|
|
27
|
+
return converted;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { cachedPressColor, formatPdfNumber, pressColor };
|
|
@@ -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 };
|