@embedpdf/engine-core 3.0.0-next.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 +192 -0
- package/README.md +24 -0
- package/dist/AbortError-ByBflvEl.cjs +38 -0
- package/dist/AbortError-ByBflvEl.cjs.map +1 -0
- package/dist/AbortError-CjSasIlL.js +27 -0
- package/dist/AbortError-CjSasIlL.js.map +1 -0
- package/dist/AnnotationListSnapshot-W0RCsyDE.d.cts +5043 -0
- package/dist/AnnotationListSnapshot-W0RCsyDE.d.ts +5043 -0
- package/dist/DocumentSecurityService-B-tULKNY.d.ts +332 -0
- package/dist/DocumentSecurityService-Df79fgpe.d.cts +332 -0
- package/dist/Engine-B7Oe7f5i.d.ts +1013 -0
- package/dist/Engine-D9loFZc1.d.cts +1013 -0
- package/dist/builders-BvikWRuO.cjs +1684 -0
- package/dist/builders-BvikWRuO.cjs.map +1 -0
- package/dist/builders-C548U47M.js +1367 -0
- package/dist/builders-C548U47M.js.map +1 -0
- package/dist/conformance.cjs +5414 -0
- package/dist/conformance.cjs.map +1 -0
- package/dist/conformance.d.cts +530 -0
- package/dist/conformance.d.ts +530 -0
- package/dist/conformance.js +5394 -0
- package/dist/conformance.js.map +1 -0
- package/dist/fold-CB4fAOOy.js +175 -0
- package/dist/fold-CB4fAOOy.js.map +1 -0
- package/dist/fold-IaMph3CO.cjs +216 -0
- package/dist/fold-IaMph3CO.cjs.map +1 -0
- package/dist/index.cjs +82 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/resolver-DAZWVoJL.cjs +434 -0
- package/dist/resolver-DAZWVoJL.cjs.map +1 -0
- package/dist/resolver-DiPMTfcy.js +363 -0
- package/dist/resolver-DiPMTfcy.js.map +1 -0
- package/dist/runtime.cjs +453 -0
- package/dist/runtime.cjs.map +1 -0
- package/dist/runtime.d.cts +1002 -0
- package/dist/runtime.d.ts +1002 -0
- package/dist/runtime.js +361 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schemas-9OpSSeM6.js +2493 -0
- package/dist/schemas-9OpSSeM6.js.map +1 -0
- package/dist/schemas-B_1VyFsm.cjs +3608 -0
- package/dist/schemas-B_1VyFsm.cjs.map +1 -0
- package/dist/subtype-BvAzLCGC.js +196 -0
- package/dist/subtype-BvAzLCGC.js.map +1 -0
- package/dist/subtype-qejjVr6h.cjs +231 -0
- package/dist/subtype-qejjVr6h.cjs.map +1 -0
- package/dist/wire.cjs +1516 -0
- package/dist/wire.cjs.map +1 -0
- package/dist/wire.d.cts +1546 -0
- package/dist/wire.d.ts +1546 -0
- package/dist/wire.js +1280 -0
- package/dist/wire.js.map +1 -0
- package/package.json +74 -0
|
@@ -0,0 +1,1367 @@
|
|
|
1
|
+
import { o as EngineErrorCode } from "./subtype-BvAzLCGC.js";
|
|
2
|
+
import { i as EngineError, n as foldText, r as toOriginalRange } from "./fold-CB4fAOOy.js";
|
|
3
|
+
//#region src/geometry/convert.ts
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a `PdfRect` to the y-up invariant (`left <= right`, `bottom <= top`),
|
|
6
|
+
* regardless of how the PDF ordered the corners (a `/Rect` array or `FS_RECTF`
|
|
7
|
+
* may carry them in any order). THE enforcement point for the "PdfRect is always
|
|
8
|
+
* normalized" rule: every producer that reads a raw box from PDFium passes it
|
|
9
|
+
* through here, so the invariant holds once, centrally — downstream `width =
|
|
10
|
+
* right - left` is never negative.
|
|
11
|
+
*/
|
|
12
|
+
function normalizePdfRect(r) {
|
|
13
|
+
return {
|
|
14
|
+
left: Math.min(r.left, r.right),
|
|
15
|
+
right: Math.max(r.left, r.right),
|
|
16
|
+
bottom: Math.min(r.top, r.bottom),
|
|
17
|
+
top: Math.max(r.top, r.bottom)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function pdfRectWidth(r) {
|
|
21
|
+
return r.right - r.left;
|
|
22
|
+
}
|
|
23
|
+
function pdfRectHeight(r) {
|
|
24
|
+
return r.top - r.bottom;
|
|
25
|
+
}
|
|
26
|
+
function pdfRectSize(r) {
|
|
27
|
+
return {
|
|
28
|
+
width: r.right - r.left,
|
|
29
|
+
height: r.top - r.bottom
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Convert edges -> origin+size, staying y-up (origin at bottom-left). */
|
|
33
|
+
function pdfRectToOriginSize(r) {
|
|
34
|
+
return {
|
|
35
|
+
x: r.left,
|
|
36
|
+
y: r.bottom,
|
|
37
|
+
width: r.right - r.left,
|
|
38
|
+
height: r.top - r.bottom
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Convert y-up origin+size back to edges. */
|
|
42
|
+
function pdfRectFromOriginSize(o) {
|
|
43
|
+
return {
|
|
44
|
+
left: o.x,
|
|
45
|
+
bottom: o.y,
|
|
46
|
+
right: o.x + o.width,
|
|
47
|
+
top: o.y + o.height
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Enclosing axis-aligned box of a quad, in PDF user space. Orientation
|
|
52
|
+
* agnostic (correct for rotated/skewed quads) — this is what highlight
|
|
53
|
+
* rendering and hit-testing want.
|
|
54
|
+
*/
|
|
55
|
+
function pdfQuadBounds(q) {
|
|
56
|
+
const xs = [
|
|
57
|
+
q.p1.x,
|
|
58
|
+
q.p2.x,
|
|
59
|
+
q.p3.x,
|
|
60
|
+
q.p4.x
|
|
61
|
+
];
|
|
62
|
+
const ys = [
|
|
63
|
+
q.p1.y,
|
|
64
|
+
q.p2.y,
|
|
65
|
+
q.p3.y,
|
|
66
|
+
q.p4.y
|
|
67
|
+
];
|
|
68
|
+
return {
|
|
69
|
+
left: Math.min(...xs),
|
|
70
|
+
right: Math.max(...xs),
|
|
71
|
+
bottom: Math.min(...ys),
|
|
72
|
+
top: Math.max(...ys)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Axis-aligned named-corner interpretation of a quad.
|
|
77
|
+
*
|
|
78
|
+
* VALID ONLY for upright (non-rotated, non-skewed) quads: it is derived from
|
|
79
|
+
* the enclosing bounds, so for rotated/skewed quads it returns the bounding
|
|
80
|
+
* box corners, not the true geometric corners. For arbitrary quads use the
|
|
81
|
+
* positional `p1..p4` or `pdfQuadBounds` directly.
|
|
82
|
+
*/
|
|
83
|
+
function pdfQuadCorners(q) {
|
|
84
|
+
const b = pdfQuadBounds(q);
|
|
85
|
+
return {
|
|
86
|
+
topLeft: {
|
|
87
|
+
x: b.left,
|
|
88
|
+
y: b.top
|
|
89
|
+
},
|
|
90
|
+
topRight: {
|
|
91
|
+
x: b.right,
|
|
92
|
+
y: b.top
|
|
93
|
+
},
|
|
94
|
+
bottomLeft: {
|
|
95
|
+
x: b.left,
|
|
96
|
+
y: b.bottom
|
|
97
|
+
},
|
|
98
|
+
bottomRight: {
|
|
99
|
+
x: b.right,
|
|
100
|
+
y: b.bottom
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build a quad from named corners, in PDFium `FS_QUADPOINTSF` slot order
|
|
106
|
+
* (`p1 = topLeft`, `p2 = topRight`, `p3 = bottomLeft`, `p4 = bottomRight`).
|
|
107
|
+
* The inverse of `pdfQuadCorners` for upright quads.
|
|
108
|
+
*/
|
|
109
|
+
function pdfQuadFromCorners(c) {
|
|
110
|
+
return {
|
|
111
|
+
p1: c.topLeft,
|
|
112
|
+
p2: c.topRight,
|
|
113
|
+
p3: c.bottomLeft,
|
|
114
|
+
p4: c.bottomRight
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/dto/PageRender.ts
|
|
119
|
+
function createPageImageHandle(result, blobSource) {
|
|
120
|
+
return {
|
|
121
|
+
...result,
|
|
122
|
+
async objectUrl(signal) {
|
|
123
|
+
if (typeof Blob === "undefined" || typeof URL === "undefined" || !URL.createObjectURL) throw new EngineError(EngineErrorCode.RuntimeUnavailable, "Object URLs are not available in this environment");
|
|
124
|
+
const blob = await blobSource.blob(signal);
|
|
125
|
+
const url = URL.createObjectURL(blob);
|
|
126
|
+
return {
|
|
127
|
+
url,
|
|
128
|
+
revoke: () => URL.revokeObjectURL(url)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/dto/PdfSaveMode.ts
|
|
135
|
+
const DEFAULT_PDF_SAVE_MODE = "incremental";
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/identity/PageObjectNumber.ts
|
|
138
|
+
/**
|
|
139
|
+
* Type guard for the rare malformed-PDF case where a page is a direct
|
|
140
|
+
* object. The engine treats `0` as malformed and throws `EngineError(NotFound)`
|
|
141
|
+
* before any page-level operation.
|
|
142
|
+
*/
|
|
143
|
+
function isValidPageObjectNumber(value) {
|
|
144
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/identity/AnnotationStableId.ts
|
|
148
|
+
/**
|
|
149
|
+
* URL-safe encoding of a stable id, used by the cloud HTTP surface as the
|
|
150
|
+
* `:annotKey` route parameter. Decoded by the server back into an
|
|
151
|
+
* `AnnotationStableId` via `decodeStableIdKey`.
|
|
152
|
+
*
|
|
153
|
+
* Format:
|
|
154
|
+
* `{ kind: 'objectNumber', value: 42 }` -> `'obj:42'`
|
|
155
|
+
* `{ kind: 'nm', value: 'foo bar' }` -> `'nm:foo bar'`
|
|
156
|
+
*
|
|
157
|
+
* The caller is responsible for `encodeURIComponent`-ing the result before
|
|
158
|
+
* splicing it into a URL path; `wirePaths.layerAnnotationByKey` already does
|
|
159
|
+
* that. /NM values are opaque strings and may contain anything; the
|
|
160
|
+
* `nm:` prefix lets the decoder distinguish them from numeric ids
|
|
161
|
+
* unambiguously.
|
|
162
|
+
*/
|
|
163
|
+
function encodeStableIdKey(id) {
|
|
164
|
+
if (id.kind === "objectNumber") {
|
|
165
|
+
if (!Number.isInteger(id.value) || id.value <= 0) throw new RangeError(`encodeStableIdKey: objectNumber must be a positive integer, got ${id.value}`);
|
|
166
|
+
return `obj:${id.value}`;
|
|
167
|
+
}
|
|
168
|
+
return `nm:${id.value}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Inverse of `encodeStableIdKey`. Returns `null` for malformed input so
|
|
172
|
+
* the server can answer 400 InvalidArg with a useful message instead of
|
|
173
|
+
* throwing.
|
|
174
|
+
*
|
|
175
|
+
* The input is the already-`decodeURIComponent`-ed segment from the route
|
|
176
|
+
* path. Empty `/NM` values are rejected: `nm:` with no suffix is not a
|
|
177
|
+
* valid identity.
|
|
178
|
+
*/
|
|
179
|
+
function decodeStableIdKey(key) {
|
|
180
|
+
if (key.startsWith("obj:")) {
|
|
181
|
+
const rest = key.slice(4);
|
|
182
|
+
const n = Number.parseInt(rest, 10);
|
|
183
|
+
if (!Number.isInteger(n) || n <= 0 || String(n) !== rest) return null;
|
|
184
|
+
return {
|
|
185
|
+
kind: "objectNumber",
|
|
186
|
+
value: n
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (key.startsWith("nm:")) {
|
|
190
|
+
const value = key.slice(3);
|
|
191
|
+
if (value.length === 0) return null;
|
|
192
|
+
return {
|
|
193
|
+
kind: "nm",
|
|
194
|
+
value
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/revision/RevisionToken.ts
|
|
201
|
+
function revisionTokensEqual(a, b) {
|
|
202
|
+
return a.docSessionId === b.docSessionId && a.pageObjectNumber === b.pageObjectNumber && a.generation === b.generation;
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/revision/WeakAnnotationState.ts
|
|
206
|
+
const UNKNOWN_WEAK_ANNOTATION_STATE = { kind: "unknown" };
|
|
207
|
+
function knownWeakAnnotationState(hasAnyWeakAnnotations) {
|
|
208
|
+
return {
|
|
209
|
+
kind: "known",
|
|
210
|
+
hasAnyWeakAnnotations
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/annotation/primitives.ts
|
|
215
|
+
const NO_ANNOTATION_FLAGS = {
|
|
216
|
+
invisible: false,
|
|
217
|
+
hidden: false,
|
|
218
|
+
print: false,
|
|
219
|
+
noZoom: false,
|
|
220
|
+
noRotate: false,
|
|
221
|
+
noView: false,
|
|
222
|
+
readOnly: false,
|
|
223
|
+
locked: false,
|
|
224
|
+
toggleNoView: false,
|
|
225
|
+
lockedContents: false
|
|
226
|
+
};
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/resource/BinarySource.ts
|
|
229
|
+
function isBlob(value) {
|
|
230
|
+
return typeof Blob !== "undefined" && value instanceof Blob;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Detach a Uint8Array view into a standalone ArrayBuffer without copying
|
|
234
|
+
* when the view already spans its whole buffer.
|
|
235
|
+
*/
|
|
236
|
+
function toStandaloneArrayBuffer(view) {
|
|
237
|
+
if (view.byteOffset === 0 && view.buffer instanceof ArrayBuffer && view.byteLength === view.buffer.byteLength) return view.buffer;
|
|
238
|
+
return view.slice().buffer;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Resolve any accepted `BinarySource` form into a `WireResource`.
|
|
242
|
+
* Async because Blob bytes can only be read asynchronously.
|
|
243
|
+
*/
|
|
244
|
+
async function resolveBinarySource(source) {
|
|
245
|
+
if (source instanceof Uint8Array) return { bytes: toStandaloneArrayBuffer(source) };
|
|
246
|
+
if (isBlob(source)) {
|
|
247
|
+
const blobName = blobFileName(source);
|
|
248
|
+
return {
|
|
249
|
+
bytes: await source.arrayBuffer(),
|
|
250
|
+
mimeType: source.type || void 0,
|
|
251
|
+
...blobName !== void 0 ? { name: blobName } : {}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const { data, mimeType, name } = source;
|
|
255
|
+
const inner = data instanceof Uint8Array ? {
|
|
256
|
+
bytes: toStandaloneArrayBuffer(data),
|
|
257
|
+
mimeType: void 0,
|
|
258
|
+
name: void 0
|
|
259
|
+
} : {
|
|
260
|
+
bytes: await data.arrayBuffer(),
|
|
261
|
+
mimeType: data.type || void 0,
|
|
262
|
+
name: blobFileName(data)
|
|
263
|
+
};
|
|
264
|
+
const resolvedName = name ?? inner.name;
|
|
265
|
+
return {
|
|
266
|
+
bytes: inner.bytes,
|
|
267
|
+
...mimeType ?? inner.mimeType ? { mimeType: mimeType ?? inner.mimeType } : {},
|
|
268
|
+
...resolvedName !== void 0 ? { name: resolvedName } : {}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** A browser `File` is a Blob with a `name` — pick it up so attachment
|
|
272
|
+
* drafts can pass a `File` directly without repeating the file name. */
|
|
273
|
+
function blobFileName(blob) {
|
|
274
|
+
const name = blob.name;
|
|
275
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/resource/binaryMetadata.ts
|
|
279
|
+
/**
|
|
280
|
+
* Detect format (and intrinsic dimensions for rasters) of a binary buffer.
|
|
281
|
+
*
|
|
282
|
+
* @returns metadata, or `null` for unsupported/corrupt data
|
|
283
|
+
*/
|
|
284
|
+
function sniffBinaryMetadata(buffer) {
|
|
285
|
+
if (buffer.byteLength < 4) return null;
|
|
286
|
+
const bytes = new Uint8Array(buffer);
|
|
287
|
+
if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return parsePng(buffer);
|
|
288
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return parseJpeg(buffer);
|
|
289
|
+
if (bytes[0] === 37 && bytes[1] === 80 && bytes[2] === 68 && bytes[3] === 70) return { mimeType: "application/pdf" };
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
function parsePng(buffer) {
|
|
293
|
+
if (buffer.byteLength < 24) return null;
|
|
294
|
+
const view = new DataView(buffer);
|
|
295
|
+
const width = view.getUint32(16);
|
|
296
|
+
const height = view.getUint32(20);
|
|
297
|
+
if (width === 0 || height === 0) return null;
|
|
298
|
+
return {
|
|
299
|
+
mimeType: "image/png",
|
|
300
|
+
width,
|
|
301
|
+
height
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function parseJpeg(buffer) {
|
|
305
|
+
const bytes = new Uint8Array(buffer);
|
|
306
|
+
let offset = 2;
|
|
307
|
+
while (offset + 4 < bytes.byteLength) {
|
|
308
|
+
if (bytes[offset] !== 255) return null;
|
|
309
|
+
const marker = bytes[offset + 1];
|
|
310
|
+
if (marker >= 192 && marker <= 195) {
|
|
311
|
+
if (offset + 9 > bytes.byteLength) return null;
|
|
312
|
+
const view = new DataView(buffer, offset + 5);
|
|
313
|
+
const height = view.getUint16(0);
|
|
314
|
+
const width = view.getUint16(2);
|
|
315
|
+
if (width === 0 || height === 0) return null;
|
|
316
|
+
return {
|
|
317
|
+
mimeType: "image/jpeg",
|
|
318
|
+
width,
|
|
319
|
+
height
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (marker === 216 || marker === 217) offset += 2;
|
|
323
|
+
else if (marker === 255) offset += 1;
|
|
324
|
+
else {
|
|
325
|
+
const segLen = bytes[offset + 2] << 8 | bytes[offset + 3];
|
|
326
|
+
offset += 2 + segLen;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/annotation/kinds/file-attachment/normalize.ts
|
|
333
|
+
/**
|
|
334
|
+
* Resolve an attachment `file` into its wire halves: metadata into the
|
|
335
|
+
* JSON body, bytes into the resource map. Unlike stamps there is NO
|
|
336
|
+
* format allowlist — attaching arbitrary files is the point — so the
|
|
337
|
+
* declared mime type wins (attachment formats cannot be reliably sniffed;
|
|
338
|
+
* the writer falls back to `application/octet-stream` when absent).
|
|
339
|
+
*
|
|
340
|
+
* Shared by the file-attachment annotation draft (below) and the
|
|
341
|
+
* document-level `attachments.create` mutation — one vocabulary, one
|
|
342
|
+
* normalizer for both homes a file can enter a PDF through.
|
|
343
|
+
*/
|
|
344
|
+
async function normalizeAttachmentFileSource(file, key) {
|
|
345
|
+
const resolved = await resolveBinarySource(file.data);
|
|
346
|
+
const name = file.name ?? resolved.name;
|
|
347
|
+
if (!name) throw new EngineError(EngineErrorCode.InvalidArg, "attachment file requires a name — pass { data, name } or a File whose name is set");
|
|
348
|
+
const mimeType = file.mimeType ?? resolved.mimeType;
|
|
349
|
+
return {
|
|
350
|
+
wireFile: {
|
|
351
|
+
resource: key,
|
|
352
|
+
name,
|
|
353
|
+
...mimeType !== void 0 ? { mimeType } : {},
|
|
354
|
+
...file.description !== void 0 ? { description: file.description } : {}
|
|
355
|
+
},
|
|
356
|
+
resource: {
|
|
357
|
+
bytes: resolved.bytes,
|
|
358
|
+
...mimeType !== void 0 ? { mimeType } : {},
|
|
359
|
+
name
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function normalizeFileAttachmentDraft(draft, allocateKey) {
|
|
364
|
+
const { file, ...rest } = draft;
|
|
365
|
+
const key = allocateKey();
|
|
366
|
+
const { wireFile, resource } = await normalizeAttachmentFileSource(file, key);
|
|
367
|
+
return {
|
|
368
|
+
wire: {
|
|
369
|
+
...rest,
|
|
370
|
+
file: wireFile
|
|
371
|
+
},
|
|
372
|
+
resources: { [key]: resource }
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/annotation/kinds/stamp/normalize.ts
|
|
377
|
+
/**
|
|
378
|
+
* Resolve + validate a stamp `source`. Sniffing happens here — before any
|
|
379
|
+
* transport — so callers get a fast `InvalidArg` instead of a worker/HTTP
|
|
380
|
+
* round-trip. The sniffed mime type overwrites whatever was declared.
|
|
381
|
+
*/
|
|
382
|
+
async function resolveStampSource(source) {
|
|
383
|
+
const resolved = await resolveBinarySource(source);
|
|
384
|
+
const meta = sniffBinaryMetadata(resolved.bytes);
|
|
385
|
+
if (!meta) throw new EngineError(EngineErrorCode.InvalidArg, "stamp source must be PNG, JPEG, or single-page PDF bytes (format is detected from the data, not the declared mime type)");
|
|
386
|
+
return {
|
|
387
|
+
...resolved,
|
|
388
|
+
mimeType: meta.mimeType
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
async function normalizeStampDraft(draft, allocateKey) {
|
|
392
|
+
const { source, ...rest } = draft;
|
|
393
|
+
const resolved = await resolveStampSource(source);
|
|
394
|
+
const key = allocateKey();
|
|
395
|
+
return {
|
|
396
|
+
wire: {
|
|
397
|
+
...rest,
|
|
398
|
+
source: { resource: key }
|
|
399
|
+
},
|
|
400
|
+
resources: { [key]: resolved }
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function normalizeStampPatch(patch, allocateKey) {
|
|
404
|
+
if (patch.source === void 0) {
|
|
405
|
+
const { source: _source, ...rest } = patch;
|
|
406
|
+
return {
|
|
407
|
+
wire: rest,
|
|
408
|
+
resources: {}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const { source, ...rest } = patch;
|
|
412
|
+
const resolved = await resolveStampSource(source);
|
|
413
|
+
const key = allocateKey();
|
|
414
|
+
return {
|
|
415
|
+
wire: {
|
|
416
|
+
...rest,
|
|
417
|
+
source: { resource: key }
|
|
418
|
+
},
|
|
419
|
+
resources: { [key]: resolved }
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region src/annotation/normalize.ts
|
|
424
|
+
function createResourceKeyAllocator() {
|
|
425
|
+
let next = 0;
|
|
426
|
+
return () => `r${next++}`;
|
|
427
|
+
}
|
|
428
|
+
async function normalizeAnnotationDraft(draft) {
|
|
429
|
+
switch (draft.subtype) {
|
|
430
|
+
case "stamp": return normalizeStampDraft(draft, createResourceKeyAllocator());
|
|
431
|
+
case "file-attachment": return normalizeFileAttachmentDraft(draft, createResourceKeyAllocator());
|
|
432
|
+
default: return {
|
|
433
|
+
wire: draft,
|
|
434
|
+
resources: {}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function normalizeAnnotationPatch(patch) {
|
|
439
|
+
switch (patch.subtype) {
|
|
440
|
+
case "stamp": return normalizeStampPatch(patch, createResourceKeyAllocator());
|
|
441
|
+
default: return {
|
|
442
|
+
wire: patch,
|
|
443
|
+
resources: {}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/annotation/relationships.ts
|
|
449
|
+
/**
|
|
450
|
+
* Classify one annotation by its relationship edge alone. Mirrors the
|
|
451
|
+
* spec's "practical parser rule": no `/IRT` is top-level; `/RT /Group` is
|
|
452
|
+
* a grouped subordinate; anything else with an `/IRT` is a reply (the
|
|
453
|
+
* engine has already normalized a missing `/RT` to `'reply'`).
|
|
454
|
+
*/
|
|
455
|
+
function classifyRelation(a) {
|
|
456
|
+
if (!a.inReplyTo) return "top-level";
|
|
457
|
+
return a.replyType === "group" ? "grouped-subordinate" : "reply";
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Stable string key for an {@link AnnotationRef}, used to match a child's
|
|
461
|
+
* `inReplyTo` against a candidate parent's `ref`. Both sides are produced
|
|
462
|
+
* by the engine with the same identity precedence (objectNumber, then nm,
|
|
463
|
+
* then index), so equal keys mean "same annotation".
|
|
464
|
+
*/
|
|
465
|
+
function refKey(ref) {
|
|
466
|
+
switch (ref.kind) {
|
|
467
|
+
case "objectNumber": return `obj:${ref.pageObjectNumber}:${ref.annotObjectNumber}`;
|
|
468
|
+
case "nm": return `nm:${ref.pageObjectNumber}:${ref.nm}`;
|
|
469
|
+
case "index": return `idx:${ref.pageObjectNumber}:${ref.index}`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Compose a flat annotation list (one page, or a whole document via
|
|
474
|
+
* `listRawAll()` flattened) into {@link AnnotationThread}s in primary
|
|
475
|
+
* order.
|
|
476
|
+
*
|
|
477
|
+
* Rules (ISO 32000 §12.5.6.2 + the spec's UI rule):
|
|
478
|
+
* - Annotations with no `/IRT` become thread primaries, in input order.
|
|
479
|
+
* - A `/RT /Group` child is attached to its primary's `groupedParts`.
|
|
480
|
+
* - A `/RT /R` (or default) child is attached to its primary's `replies`.
|
|
481
|
+
* - A child whose parent is not in the input set is treated as an
|
|
482
|
+
* orphan primary (defensive — e.g. a partial/filtered page slice).
|
|
483
|
+
*
|
|
484
|
+
* Limitations (v1, matches the legacy stack): threading is one level deep
|
|
485
|
+
* — a reply-to-a-reply is keyed to its direct parent, so it only appears
|
|
486
|
+
* nested if that direct parent is itself a primary. Callers that want a
|
|
487
|
+
* sidebar of "most annotations, not widgets/popups" should pre-filter the
|
|
488
|
+
* input; this helper is intentionally unopinionated about eligibility.
|
|
489
|
+
*/
|
|
490
|
+
function buildThreads(annotations) {
|
|
491
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
492
|
+
for (const a of annotations) {
|
|
493
|
+
byKey.set(refKey(a.ref), a);
|
|
494
|
+
if (a.nm && a.nm.length > 0) byKey.set(`nm:${a.ref.pageObjectNumber}:${a.nm}`, a);
|
|
495
|
+
}
|
|
496
|
+
const threads = [];
|
|
497
|
+
const threadByPrimaryKey = /* @__PURE__ */ new Map();
|
|
498
|
+
const primaryThread = (primary) => {
|
|
499
|
+
const key = refKey(primary.ref);
|
|
500
|
+
let thread = threadByPrimaryKey.get(key);
|
|
501
|
+
if (!thread) {
|
|
502
|
+
thread = {
|
|
503
|
+
primary,
|
|
504
|
+
groupedParts: [],
|
|
505
|
+
replies: []
|
|
506
|
+
};
|
|
507
|
+
threadByPrimaryKey.set(key, thread);
|
|
508
|
+
threads.push(thread);
|
|
509
|
+
}
|
|
510
|
+
return thread;
|
|
511
|
+
};
|
|
512
|
+
for (const a of annotations) if (!a.inReplyTo) primaryThread(a);
|
|
513
|
+
for (const a of annotations) {
|
|
514
|
+
if (!a.inReplyTo) continue;
|
|
515
|
+
const parent = byKey.get(refKey(a.inReplyTo));
|
|
516
|
+
if (!parent || parent.inReplyTo) {
|
|
517
|
+
primaryThread(a);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const thread = primaryThread(parent);
|
|
521
|
+
if (a.replyType === "group") thread.groupedParts.push(a);
|
|
522
|
+
else thread.replies.push(a);
|
|
523
|
+
}
|
|
524
|
+
return threads;
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/mutation/AnnotationMutationImpactPolicy.ts
|
|
528
|
+
function changesAnnotationList(_kind) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
function shiftsExistingAnnotationIndices(kind) {
|
|
532
|
+
return kind === "delete" || kind === "move";
|
|
533
|
+
}
|
|
534
|
+
function invalidatesWeakIndexRefs(kind, weakStateBefore) {
|
|
535
|
+
if (!shiftsExistingAnnotationIndices(kind)) return false;
|
|
536
|
+
if (weakStateBefore.kind !== "known") throw new Error(`invalidatesWeakIndexRefs requires a known WeakAnnotationState for ${kind}; got 'unknown'. Caller must scan the page before computing mutation impact.`);
|
|
537
|
+
return weakStateBefore.hasAnyWeakAnnotations;
|
|
538
|
+
}
|
|
539
|
+
//#endregion
|
|
540
|
+
//#region src/annotation/appearance.ts
|
|
541
|
+
/**
|
|
542
|
+
* Coordinate tolerance in PDF user-space points. PDF numbers round-trip
|
|
543
|
+
* through f32 (~7 significant digits) and client-side space conversions, so
|
|
544
|
+
* exact float equality would misclassify no-op writes; 1e-3 pt is far below
|
|
545
|
+
* anything visible while far above accumulated f32 drift at page magnitudes.
|
|
546
|
+
*/
|
|
547
|
+
const EPSILON = .001;
|
|
548
|
+
/**
|
|
549
|
+
* Keys that never affect `/AP` on any kind: the discriminator, behavioral
|
|
550
|
+
* flags, reply relationships, and grouping.
|
|
551
|
+
*/
|
|
552
|
+
const INERT_KEYS = /* @__PURE__ */ new Set([
|
|
553
|
+
"subtype",
|
|
554
|
+
"flags",
|
|
555
|
+
"inReplyTo",
|
|
556
|
+
"replyType",
|
|
557
|
+
"groupId"
|
|
558
|
+
]);
|
|
559
|
+
/**
|
|
560
|
+
* Kinds whose `/AP` paints `/Contents`. Everywhere else `contents` is popup
|
|
561
|
+
* note text — editing a comment must never re-bake (or destroy) the shape's
|
|
562
|
+
* appearance.
|
|
563
|
+
*/
|
|
564
|
+
const CONTENTS_PAINTED = /* @__PURE__ */ new Set(["free-text", "redact"]);
|
|
565
|
+
/**
|
|
566
|
+
* The vertex family's `/EMBD_Metadata/Rotation` is an advisory scalar (the
|
|
567
|
+
* vertices are already rotated); it never drives the AP generator, so patches
|
|
568
|
+
* touching it are appearance-inert for these kinds.
|
|
569
|
+
*/
|
|
570
|
+
const ADVISORY_ROTATION = /* @__PURE__ */ new Set([
|
|
571
|
+
"line",
|
|
572
|
+
"polyline",
|
|
573
|
+
"polygon",
|
|
574
|
+
"ink"
|
|
575
|
+
]);
|
|
576
|
+
/**
|
|
577
|
+
* The box kinds carry the `/EMBD_Metadata` transform pair (`rotation` +
|
|
578
|
+
* `unrotatedRect`), tri-state on writes: omitted fields are PRESERVED, `null`
|
|
579
|
+
* (or `0` for rotation) clears. Translation verification therefore compares
|
|
580
|
+
* the after-state — `patch.rotation ?? current.rotation` — not the patch keys.
|
|
581
|
+
*/
|
|
582
|
+
const BOX_TRANSFORM = /* @__PURE__ */ new Set([
|
|
583
|
+
"square",
|
|
584
|
+
"circle",
|
|
585
|
+
"free-text",
|
|
586
|
+
"stamp"
|
|
587
|
+
]);
|
|
588
|
+
/**
|
|
589
|
+
* Per-kind absolute-geometry fields (PDF user space) that a rigid translation
|
|
590
|
+
* shifts together. A kind absent from this table never takes the translation
|
|
591
|
+
* route. `rect` is required in every entry: a translation always moves `/Rect`.
|
|
592
|
+
*/
|
|
593
|
+
const TRANSLATABLE_GEOMETRY = {
|
|
594
|
+
square: ["rect", "unrotatedRect"],
|
|
595
|
+
circle: ["rect", "unrotatedRect"],
|
|
596
|
+
"free-text": [
|
|
597
|
+
"rect",
|
|
598
|
+
"unrotatedRect",
|
|
599
|
+
"calloutLine"
|
|
600
|
+
],
|
|
601
|
+
line: ["rect", "linePoints"],
|
|
602
|
+
polygon: ["rect", "vertices"],
|
|
603
|
+
polyline: ["rect", "vertices"],
|
|
604
|
+
ink: ["rect", "inkList"],
|
|
605
|
+
highlight: ["rect", "quadPoints"],
|
|
606
|
+
underline: ["rect", "quadPoints"],
|
|
607
|
+
squiggly: ["rect", "quadPoints"],
|
|
608
|
+
strikeout: ["rect", "quadPoints"],
|
|
609
|
+
redact: ["rect", "quadPoints"],
|
|
610
|
+
caret: ["rect"],
|
|
611
|
+
text: ["rect"],
|
|
612
|
+
stamp: ["rect", "unrotatedRect"],
|
|
613
|
+
"file-attachment": ["rect"],
|
|
614
|
+
link: ["rect"]
|
|
615
|
+
};
|
|
616
|
+
const numEq = (a, b) => Math.abs(a - b) <= EPSILON;
|
|
617
|
+
/** Degrees normalized to [0,360); absent reads as 0 (no rotation). */
|
|
618
|
+
const normDeg = (v) => {
|
|
619
|
+
return typeof v === "number" ? (v % 360 + 360) % 360 : 0;
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* Semantic equality between a patch value and the current DTO value.
|
|
623
|
+
* Numbers compare within {@link EPSILON}; `null` and `undefined` both mean
|
|
624
|
+
* "entry absent" (the tri-state clear of an absent entry is a no-op); arrays
|
|
625
|
+
* and objects compare structurally.
|
|
626
|
+
*/
|
|
627
|
+
function semanticEqual(a, b) {
|
|
628
|
+
if (a == null || b == null) return a == null && b == null;
|
|
629
|
+
if (typeof a === "number" && typeof b === "number") return numEq(a, b);
|
|
630
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
631
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
632
|
+
return a.every((v, i) => semanticEqual(v, b[i]));
|
|
633
|
+
}
|
|
634
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
635
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
636
|
+
for (const k of keys) if (!semanticEqual(a[k], b[k])) return false;
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
return a === b;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Is `next` exactly `cur` shifted by `(dx, dy)`? Walks the value structurally:
|
|
643
|
+
* numeric leaves under x-axis keys (`left`/`right`/`x`) must shift by `dx`,
|
|
644
|
+
* y-axis keys (`bottom`/`top`/`y`) by `dy`, and any other numeric leaf must be
|
|
645
|
+
* unchanged. Arrays walk elementwise (same length required).
|
|
646
|
+
*/
|
|
647
|
+
function shiftedBy(cur, next, dx, dy) {
|
|
648
|
+
if (cur == null || next == null) return cur == null && next == null;
|
|
649
|
+
if (Array.isArray(cur) || Array.isArray(next)) {
|
|
650
|
+
if (!Array.isArray(cur) || !Array.isArray(next) || cur.length !== next.length) return false;
|
|
651
|
+
return cur.every((v, i) => shiftedBy(v, next[i], dx, dy));
|
|
652
|
+
}
|
|
653
|
+
if (typeof cur === "object" && typeof next === "object") {
|
|
654
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(cur), ...Object.keys(next)]);
|
|
655
|
+
for (const k of keys) {
|
|
656
|
+
const c = cur[k];
|
|
657
|
+
const n = next[k];
|
|
658
|
+
if (typeof c === "number" && typeof n === "number") {
|
|
659
|
+
if (!numEq(c + (k === "left" || k === "right" || k === "x" ? dx : k === "bottom" || k === "top" || k === "y" ? dy : 0), n)) return false;
|
|
660
|
+
} else if (!shiftedBy(c, n, dx, dy)) return false;
|
|
661
|
+
}
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
return cur === next;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Verify the touched geometry is one rigid translation of the current state.
|
|
668
|
+
* Requires `patch.rect` (a translation always moves `/Rect`) with width and
|
|
669
|
+
* height preserved; every other geometry field present on the annotation must
|
|
670
|
+
* ride along shifted by the same delta — a rect move that leaves `vertices`
|
|
671
|
+
* behind is NOT a translation (the dictionary would desync from the pixels).
|
|
672
|
+
* For box kinds the `/EMBD_Metadata` transform group is checked as an
|
|
673
|
+
* after-state: writes are tri-state (omitted preserves, null/0 clears), so an
|
|
674
|
+
* omitted rotation keeps the current one — but a preserved-yet-unshifted
|
|
675
|
+
* `unrotatedRect` still fails the congruence check via the field loop.
|
|
676
|
+
*/
|
|
677
|
+
function isRigidTranslation(cur, pat, subtype, geometryKeys) {
|
|
678
|
+
const curRect = cur.rect;
|
|
679
|
+
const patRect = pat.rect;
|
|
680
|
+
if (!curRect || !patRect) return false;
|
|
681
|
+
const dx = patRect.left - curRect.left;
|
|
682
|
+
const dy = patRect.bottom - curRect.bottom;
|
|
683
|
+
if (!numEq(patRect.right - patRect.left, curRect.right - curRect.left)) return false;
|
|
684
|
+
if (!numEq(patRect.top - patRect.bottom, curRect.top - curRect.bottom)) return false;
|
|
685
|
+
if (BOX_TRANSFORM.has(subtype)) {
|
|
686
|
+
const rotAfter = pat.rotation === void 0 ? cur.rotation : pat.rotation;
|
|
687
|
+
if (!numEq(normDeg(cur.rotation), normDeg(rotAfter))) return false;
|
|
688
|
+
}
|
|
689
|
+
for (const key of geometryKeys) {
|
|
690
|
+
if (key === "rect") continue;
|
|
691
|
+
const c = cur[key];
|
|
692
|
+
const p = pat[key];
|
|
693
|
+
if (c == null && p == null) continue;
|
|
694
|
+
if (c == null || p == null) return false;
|
|
695
|
+
if (!shiftedBy(c, p, dx, dy)) return false;
|
|
696
|
+
}
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Classify an update patch against the annotation's current DTO.
|
|
701
|
+
*
|
|
702
|
+
* 1. Value-diff: drop keys whose value semantically equals the current one,
|
|
703
|
+
* plus the always-inert metadata keys (and per-kind inert keys: `contents`
|
|
704
|
+
* where it isn't painted, advisory `rotation` on the vertex family).
|
|
705
|
+
* Nothing left → `'inert'`.
|
|
706
|
+
* 2. If every remaining key is translatable geometry for this kind AND the
|
|
707
|
+
* values are one rigid translation → `'translation'`.
|
|
708
|
+
* 3. Anything else — style, text, unknown keys, unknown kinds → `'regenerate'`.
|
|
709
|
+
*/
|
|
710
|
+
function appearanceImpactOf(current, patch) {
|
|
711
|
+
if (patch.subtype !== current.subtype) return "regenerate";
|
|
712
|
+
const cur = current;
|
|
713
|
+
const pat = patch;
|
|
714
|
+
const subtype = patch.subtype;
|
|
715
|
+
const touched = [];
|
|
716
|
+
for (const [key, value] of Object.entries(pat)) {
|
|
717
|
+
if (value === void 0 || INERT_KEYS.has(key)) continue;
|
|
718
|
+
if (key === "contents" && !CONTENTS_PAINTED.has(subtype)) continue;
|
|
719
|
+
if (key === "rotation" && ADVISORY_ROTATION.has(subtype)) continue;
|
|
720
|
+
if (!semanticEqual(value, cur[key])) touched.push(key);
|
|
721
|
+
}
|
|
722
|
+
if (touched.length === 0) return "inert";
|
|
723
|
+
const geometryKeys = TRANSLATABLE_GEOMETRY[subtype];
|
|
724
|
+
if (!geometryKeys) return "regenerate";
|
|
725
|
+
if (!touched.every((k) => geometryKeys.includes(k))) return "regenerate";
|
|
726
|
+
return isRigidTranslation(cur, pat, subtype, geometryKeys) ? "translation" : "regenerate";
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/identity/FormFieldRef.ts
|
|
730
|
+
/**
|
|
731
|
+
* URL-safe encoding of a `FormFieldRef`, used by the cloud HTTP surface as
|
|
732
|
+
* the `:fieldKey` route parameter. Decoded by the server back into a
|
|
733
|
+
* `FormFieldRef` via `decodeFieldRefKey`. Mirrors the annotation plane's
|
|
734
|
+
* `encodeStableIdKey` (`obj:42` / `nm:…`) so the two member-key syntaxes
|
|
735
|
+
* read the same on the wire:
|
|
736
|
+
*
|
|
737
|
+
* `{ kind: 'objectNumber', fieldObjectNumber: 12 }` -> `'obj:12'`
|
|
738
|
+
* `{ kind: 'fqn', name: 'billing.name' }` -> `'fqn:billing.name'`
|
|
739
|
+
*
|
|
740
|
+
* The caller is responsible for `encodeURIComponent`-ing the result before
|
|
741
|
+
* splicing it into a URL path; the `wirePaths.layerFormField*` builders
|
|
742
|
+
* already do that. FQNs are opaque strings and may contain anything; the
|
|
743
|
+
* `fqn:` prefix keeps them unambiguous from numeric ids.
|
|
744
|
+
*/
|
|
745
|
+
function encodeFieldRefKey(ref) {
|
|
746
|
+
if (ref.kind === "objectNumber") {
|
|
747
|
+
if (!Number.isInteger(ref.fieldObjectNumber) || ref.fieldObjectNumber <= 0) throw new RangeError(`encodeFieldRefKey: fieldObjectNumber must be a positive integer, got ${ref.fieldObjectNumber}`);
|
|
748
|
+
return `obj:${ref.fieldObjectNumber}`;
|
|
749
|
+
}
|
|
750
|
+
return `fqn:${ref.name}`;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Inverse of `encodeFieldRefKey`. Returns `null` for malformed input so the
|
|
754
|
+
* server can answer 400 InvalidArg with a useful message instead of
|
|
755
|
+
* throwing. The input is the already-`decodeURIComponent`-ed segment from
|
|
756
|
+
* the route path. Empty FQNs are rejected: `fqn:` with no suffix is not a
|
|
757
|
+
* valid identity.
|
|
758
|
+
*/
|
|
759
|
+
function decodeFieldRefKey(key) {
|
|
760
|
+
if (key.startsWith("obj:")) {
|
|
761
|
+
const rest = key.slice(4);
|
|
762
|
+
const n = Number.parseInt(rest, 10);
|
|
763
|
+
if (!Number.isInteger(n) || n <= 0 || String(n) !== rest) return null;
|
|
764
|
+
return {
|
|
765
|
+
kind: "objectNumber",
|
|
766
|
+
fieldObjectNumber: n
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (key.startsWith("fqn:")) {
|
|
770
|
+
const name = key.slice(4);
|
|
771
|
+
if (name.length === 0) return null;
|
|
772
|
+
return {
|
|
773
|
+
kind: "fqn",
|
|
774
|
+
name
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/search/literal.ts
|
|
781
|
+
/**
|
|
782
|
+
* The fold flavor a literal query needs. Haystack and needle MUST be
|
|
783
|
+
* folded with the same options; corpus caches key their pre-folded text
|
|
784
|
+
* on this (the default `{}` fold is the persistable one — non-default
|
|
785
|
+
* queries fold the original page text at query time).
|
|
786
|
+
*/
|
|
787
|
+
function foldOptionsFor(query) {
|
|
788
|
+
return {
|
|
789
|
+
keepCase: !!query.matchCase,
|
|
790
|
+
keepMarks: !!query.matchDiacritics
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
const WORD_UNIT = /[\p{L}\p{N}_]/u;
|
|
794
|
+
const MARK = /\p{M}/u;
|
|
795
|
+
/** Whether the code point ENDING at `index` (exclusive) is a word char. */
|
|
796
|
+
function wordBefore(text, index) {
|
|
797
|
+
if (index <= 0) return false;
|
|
798
|
+
const unit = text.charCodeAt(index - 1);
|
|
799
|
+
const start = unit >= 56320 && unit <= 57343 && index >= 2 ? index - 2 : index - 1;
|
|
800
|
+
return WORD_UNIT.test(String.fromCodePoint(text.codePointAt(start)));
|
|
801
|
+
}
|
|
802
|
+
/** Whether the code point starting at `index` is a word char. */
|
|
803
|
+
function wordAt(text, index) {
|
|
804
|
+
if (index >= text.length) return false;
|
|
805
|
+
return WORD_UNIT.test(String.fromCodePoint(text.codePointAt(index)));
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* All non-overlapping literal matches, in original code-unit space.
|
|
809
|
+
* `haystack` must have been folded with `foldOptionsFor(query)`.
|
|
810
|
+
*/
|
|
811
|
+
function matchLiteral(haystack, query) {
|
|
812
|
+
const needle = foldText(query.text, foldOptionsFor(query)).folded;
|
|
813
|
+
if (needle.trim().length === 0) return [];
|
|
814
|
+
const out = [];
|
|
815
|
+
let from = 0;
|
|
816
|
+
while (from <= haystack.folded.length - needle.length) {
|
|
817
|
+
const at = haystack.folded.indexOf(needle, from);
|
|
818
|
+
if (at < 0) break;
|
|
819
|
+
if (query.wholeWord && (wordBefore(haystack.folded, at) || wordAt(haystack.folded, at + needle.length))) {
|
|
820
|
+
from = at + 1;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (query.matchDiacritics && at + needle.length < haystack.folded.length) {
|
|
824
|
+
const after = haystack.folded.codePointAt(at + needle.length);
|
|
825
|
+
if (MARK.test(String.fromCodePoint(after))) {
|
|
826
|
+
from = at + 1;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
out.push(toOriginalRange(haystack, at, needle.length));
|
|
831
|
+
from = at + needle.length;
|
|
832
|
+
}
|
|
833
|
+
return out;
|
|
834
|
+
}
|
|
835
|
+
//#endregion
|
|
836
|
+
//#region src/search/regex.ts
|
|
837
|
+
/**
|
|
838
|
+
* The portable search-regex dialect: JavaScript `u`-mode syntax MINUS the
|
|
839
|
+
* features RE2 refuses (backreferences, lookaround). The local engine
|
|
840
|
+
* executes patterns with JS `RegExp`; the server executes the SAME
|
|
841
|
+
* pattern with RE2, whose linear-time guarantee is the ReDoS defense.
|
|
842
|
+
* Restricting both sides to the common subset keeps one query string
|
|
843
|
+
* valid — and equally powerful — everywhere.
|
|
844
|
+
*/
|
|
845
|
+
const SEARCH_REGEX_MAX_LENGTH = 512;
|
|
846
|
+
/**
|
|
847
|
+
* Validate a pattern against the dialect. Cheap and dependency-free —
|
|
848
|
+
* viewers call it on keystroke for early feedback; engines re-validate
|
|
849
|
+
* and reject bad patterns with `InvalidArg`.
|
|
850
|
+
*/
|
|
851
|
+
function validateSearchRegex(pattern) {
|
|
852
|
+
if (pattern.length === 0) return {
|
|
853
|
+
ok: false,
|
|
854
|
+
issue: "empty",
|
|
855
|
+
message: "Pattern is empty."
|
|
856
|
+
};
|
|
857
|
+
if (pattern.length > 512) return {
|
|
858
|
+
ok: false,
|
|
859
|
+
issue: "too-long",
|
|
860
|
+
message: `Pattern exceeds 512 characters.`
|
|
861
|
+
};
|
|
862
|
+
let inClass = false;
|
|
863
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
864
|
+
const ch = pattern[i];
|
|
865
|
+
if (ch === "\\") {
|
|
866
|
+
const next = pattern[i + 1] ?? "";
|
|
867
|
+
if (!inClass && next >= "1" && next <= "9") return {
|
|
868
|
+
ok: false,
|
|
869
|
+
issue: "backreference",
|
|
870
|
+
message: `Backreference \\${next} is not supported.`
|
|
871
|
+
};
|
|
872
|
+
if (!inClass && next === "k" && pattern[i + 2] === "<") return {
|
|
873
|
+
ok: false,
|
|
874
|
+
issue: "backreference",
|
|
875
|
+
message: "Named backreferences (\\k<…>) are not supported."
|
|
876
|
+
};
|
|
877
|
+
i++;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (inClass) {
|
|
881
|
+
if (ch === "]") inClass = false;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (ch === "[") {
|
|
885
|
+
inClass = true;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (ch === "(" && pattern[i + 1] === "?") {
|
|
889
|
+
const tag = pattern[i + 2] ?? "";
|
|
890
|
+
if (tag === "=" || tag === "!") return {
|
|
891
|
+
ok: false,
|
|
892
|
+
issue: "lookaround",
|
|
893
|
+
message: "Lookahead ((?=…), (?!…)) is not supported."
|
|
894
|
+
};
|
|
895
|
+
if (tag === "<" && (pattern[i + 3] === "=" || pattern[i + 3] === "!")) return {
|
|
896
|
+
ok: false,
|
|
897
|
+
issue: "lookaround",
|
|
898
|
+
message: "Lookbehind ((?<=…), (?<!…)) is not supported."
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
new RegExp(pattern, "mu");
|
|
904
|
+
} catch (error) {
|
|
905
|
+
return {
|
|
906
|
+
ok: false,
|
|
907
|
+
issue: "syntax",
|
|
908
|
+
message: error.message
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
return { ok: true };
|
|
912
|
+
}
|
|
913
|
+
function validateSearchQuery(query) {
|
|
914
|
+
if (!query.regex) return { ok: true };
|
|
915
|
+
if (query.matchDiacritics) return {
|
|
916
|
+
ok: false,
|
|
917
|
+
issue: "diacritics-with-regex",
|
|
918
|
+
message: "Diacritic-sensitive matching is not available for regex patterns (regex runs on the raw text plane)."
|
|
919
|
+
};
|
|
920
|
+
return validateSearchRegex(query.text);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* All matches of a dialect-valid pattern over the RAW page text (regex
|
|
924
|
+
* does not run against folded text — case handling is the `i` flag, and
|
|
925
|
+
* diacritic folding never applies). `wholeWord` is a boundary POST-FILTER
|
|
926
|
+
* using the same Unicode word test as the literal path — never `\b`,
|
|
927
|
+
* whose ASCII word set would give the toggle different semantics in regex
|
|
928
|
+
* mode ("caf" whole-word matching inside "café"). Filtering after the
|
|
929
|
+
* match also survives the move to RE2 server-side. Zero-length matches
|
|
930
|
+
* are skipped: search UI cannot highlight nothing, and skipping them is
|
|
931
|
+
* also the infinite-loop guard for patterns like `a*`.
|
|
932
|
+
*/
|
|
933
|
+
function matchRegex(text, query) {
|
|
934
|
+
const valid = validateSearchQuery({
|
|
935
|
+
...query,
|
|
936
|
+
regex: true
|
|
937
|
+
});
|
|
938
|
+
if (!valid.ok) throw new Error(`Invalid search query (${valid.issue}): ${valid.message}`);
|
|
939
|
+
const re = new RegExp(query.text, query.matchCase ? "gmu" : "gimu");
|
|
940
|
+
const out = [];
|
|
941
|
+
let m;
|
|
942
|
+
while ((m = re.exec(text)) !== null) {
|
|
943
|
+
if (m[0].length === 0) {
|
|
944
|
+
const unit = text.charCodeAt(re.lastIndex);
|
|
945
|
+
re.lastIndex += unit >= 55296 && unit <= 56319 ? 2 : 1;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (query.wholeWord && (wordBefore(text, m.index) || wordAt(text, m.index + m[0].length))) continue;
|
|
949
|
+
out.push({
|
|
950
|
+
start: m.index,
|
|
951
|
+
length: m[0].length
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
//#endregion
|
|
957
|
+
//#region src/search/matcher.ts
|
|
958
|
+
/**
|
|
959
|
+
* The whole match stage in one pure call: page text in, original-space
|
|
960
|
+
* match ranges out. Engines with a pre-folded corpus for the default fold
|
|
961
|
+
* skip this and call `matchLiteral` on the cached `FoldedText` directly;
|
|
962
|
+
* everything else (non-default fold options, regex, tests) comes through
|
|
963
|
+
* here.
|
|
964
|
+
*/
|
|
965
|
+
function matchPageText(text, query) {
|
|
966
|
+
if (query.regex) return matchRegex(text, query);
|
|
967
|
+
return matchLiteral(foldText(text, foldOptionsFor(query)), query);
|
|
968
|
+
}
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/search/snippet.ts
|
|
971
|
+
/** Default context on each side of a match, in code units. */
|
|
972
|
+
const SEARCH_SNIPPET_CONTEXT = 48;
|
|
973
|
+
const WHITESPACE = /\s/;
|
|
974
|
+
const WHITESPACE_ALL = /\s/g;
|
|
975
|
+
const isLowSurrogate = (text, index) => {
|
|
976
|
+
const unit = text.charCodeAt(index);
|
|
977
|
+
return unit >= 56320 && unit <= 57343;
|
|
978
|
+
};
|
|
979
|
+
/**
|
|
980
|
+
* The `'full'`-mode excerpt around one match: up to `context` code units
|
|
981
|
+
* of page text on each side, trimmed to a whitespace boundary when one
|
|
982
|
+
* exists inside the window (so snippets start and end on whole words),
|
|
983
|
+
* never splitting a surrogate pair, whitespace flattened 1:1.
|
|
984
|
+
*/
|
|
985
|
+
function buildSnippet(text, range, context = 48) {
|
|
986
|
+
const matchEnd = range.start + range.length;
|
|
987
|
+
let start = Math.max(0, range.start - context);
|
|
988
|
+
if (start > 0) {
|
|
989
|
+
for (let i = start; i < range.start; i++) if (WHITESPACE.test(text[i])) {
|
|
990
|
+
start = i + 1;
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
if (isLowSurrogate(text, start)) start++;
|
|
994
|
+
}
|
|
995
|
+
let end = Math.min(text.length, matchEnd + context);
|
|
996
|
+
if (end < text.length) {
|
|
997
|
+
for (let i = end - 1; i >= matchEnd; i--) if (WHITESPACE.test(text[i])) {
|
|
998
|
+
end = i;
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
if (isLowSurrogate(text, end)) end--;
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
text: text.slice(start, end).replace(WHITESPACE_ALL, " "),
|
|
1005
|
+
matchStart: range.start - start,
|
|
1006
|
+
matchLength: range.length
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/search/rects.ts
|
|
1011
|
+
/**
|
|
1012
|
+
* Match-range → merged line rects, in PDF user space (y-up).
|
|
1013
|
+
*
|
|
1014
|
+
* This is the same line-merge text selection uses (adapted from Chromium's
|
|
1015
|
+
* pdf/pdfium/pdfium_range.cc `MergeAdjacentRects`, BSD-licensed, Copyright
|
|
1016
|
+
* 2010 The Chromium Authors), operating directly on the engine's
|
|
1017
|
+
* `PageGeometrySnapshot`: glyph loose boxes → sub-runs (split on big
|
|
1018
|
+
* intra-run gaps) → one rect per visual line. Search highlights therefore
|
|
1019
|
+
* look exactly like selections — never a rect per glyph (the v2 bug).
|
|
1020
|
+
*
|
|
1021
|
+
* It lives in engine-core because BOTH sides need it: the server computes
|
|
1022
|
+
* rects here for `'rects'`-mode responses (geometry never leaves the trust
|
|
1023
|
+
* boundary), and the local engine computes them in the worker.
|
|
1024
|
+
*/
|
|
1025
|
+
const FLAG_EMPTY = 2;
|
|
1026
|
+
const CHAR_DISTANCE_FACTOR = 2.5;
|
|
1027
|
+
const FONT_SIZE_RATIO_THRESHOLD = 1.5;
|
|
1028
|
+
const VERTICAL_OVERLAP_THRESHOLD = .8;
|
|
1029
|
+
/**
|
|
1030
|
+
* Merged line rects for the text-page char range
|
|
1031
|
+
* `[charStart, charStart + charCount)`. Ranges index the same space as
|
|
1032
|
+
* `PageGeometryRun.charStart`; chars outside the snapshot are ignored.
|
|
1033
|
+
*/
|
|
1034
|
+
function searchRectsForRange(snapshot, charStart, charCount) {
|
|
1035
|
+
if (charCount <= 0) return [];
|
|
1036
|
+
const lo = charStart;
|
|
1037
|
+
const hi = charStart + charCount - 1;
|
|
1038
|
+
const subRuns = [];
|
|
1039
|
+
for (const run of snapshot.runs) {
|
|
1040
|
+
const runEnd = run.charStart + run.glyphs.length - 1;
|
|
1041
|
+
if (runEnd < lo || run.charStart > hi) continue;
|
|
1042
|
+
const s = Math.max(lo, run.charStart);
|
|
1043
|
+
const e = Math.min(hi, runEnd);
|
|
1044
|
+
let left = Infinity;
|
|
1045
|
+
let right = -Infinity;
|
|
1046
|
+
let bottom = Infinity;
|
|
1047
|
+
let top = -Infinity;
|
|
1048
|
+
let count = 0;
|
|
1049
|
+
let widthSum = 0;
|
|
1050
|
+
let prevRight = -Infinity;
|
|
1051
|
+
const flush = () => {
|
|
1052
|
+
if (count > 0 && right > left && top > bottom) subRuns.push({
|
|
1053
|
+
rect: {
|
|
1054
|
+
left,
|
|
1055
|
+
bottom,
|
|
1056
|
+
right,
|
|
1057
|
+
top
|
|
1058
|
+
},
|
|
1059
|
+
charCount: count,
|
|
1060
|
+
fontSize: run.fontSize
|
|
1061
|
+
});
|
|
1062
|
+
left = Infinity;
|
|
1063
|
+
right = -Infinity;
|
|
1064
|
+
bottom = Infinity;
|
|
1065
|
+
top = -Infinity;
|
|
1066
|
+
count = 0;
|
|
1067
|
+
widthSum = 0;
|
|
1068
|
+
prevRight = -Infinity;
|
|
1069
|
+
};
|
|
1070
|
+
for (let ci = s; ci <= e; ci++) {
|
|
1071
|
+
const g = run.glyphs[ci - run.charStart];
|
|
1072
|
+
if ((g.flags & FLAG_EMPTY) !== 0) continue;
|
|
1073
|
+
const b = g.looseBox;
|
|
1074
|
+
if (count > 0 && prevRight > -Infinity) {
|
|
1075
|
+
const avg = widthSum / count;
|
|
1076
|
+
if (avg > 0 && Math.abs(b.left - prevRight) > CHAR_DISTANCE_FACTOR * avg) flush();
|
|
1077
|
+
}
|
|
1078
|
+
left = Math.min(left, b.left);
|
|
1079
|
+
right = Math.max(right, b.right);
|
|
1080
|
+
bottom = Math.min(bottom, b.bottom);
|
|
1081
|
+
top = Math.max(top, b.top);
|
|
1082
|
+
count++;
|
|
1083
|
+
widthSum += b.right - b.left;
|
|
1084
|
+
prevRight = b.right;
|
|
1085
|
+
}
|
|
1086
|
+
flush();
|
|
1087
|
+
}
|
|
1088
|
+
return mergeAdjacentRects(subRuns);
|
|
1089
|
+
}
|
|
1090
|
+
function mergeAdjacentRects(runs) {
|
|
1091
|
+
const out = [];
|
|
1092
|
+
let prev = null;
|
|
1093
|
+
let cur = null;
|
|
1094
|
+
for (const run of runs) {
|
|
1095
|
+
if (prev && cur && shouldMerge(prev, run)) cur = union(cur, run.rect);
|
|
1096
|
+
else {
|
|
1097
|
+
if (cur) out.push(cur);
|
|
1098
|
+
cur = run.rect;
|
|
1099
|
+
}
|
|
1100
|
+
prev = run;
|
|
1101
|
+
}
|
|
1102
|
+
if (cur) out.push(cur);
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1105
|
+
function shouldMerge(a, b) {
|
|
1106
|
+
if (a.fontSize != null && b.fontSize != null && a.fontSize > 0 && b.fontSize > 0) {
|
|
1107
|
+
if (Math.max(a.fontSize, b.fontSize) / Math.min(a.fontSize, b.fontSize) > FONT_SIZE_RATIO_THRESHOLD) return false;
|
|
1108
|
+
}
|
|
1109
|
+
if (verticalOverlap(a.rect, b.rect) < VERTICAL_OVERLAP_THRESHOLD) return false;
|
|
1110
|
+
const aw = (a.rect.right - a.rect.left) / a.charCount;
|
|
1111
|
+
const bw = (b.rect.right - b.rect.left) / b.charCount;
|
|
1112
|
+
const aL = a.rect.left - aw;
|
|
1113
|
+
const aR = a.rect.right + aw;
|
|
1114
|
+
const bL = b.rect.left - bw;
|
|
1115
|
+
return aL < b.rect.right + bw && aR > bL;
|
|
1116
|
+
}
|
|
1117
|
+
function union(a, b) {
|
|
1118
|
+
return {
|
|
1119
|
+
left: Math.min(a.left, b.left),
|
|
1120
|
+
bottom: Math.min(a.bottom, b.bottom),
|
|
1121
|
+
right: Math.max(a.right, b.right),
|
|
1122
|
+
top: Math.max(a.top, b.top)
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
function verticalOverlap(a, b) {
|
|
1126
|
+
const ah = a.top - a.bottom;
|
|
1127
|
+
const bh = b.top - b.bottom;
|
|
1128
|
+
if (ah <= 0 || bh <= 0) return 0;
|
|
1129
|
+
const u = Math.max(a.top, b.top) - Math.min(a.bottom, b.bottom);
|
|
1130
|
+
if (u === ah || u === bh) return 1;
|
|
1131
|
+
return Math.max(0, Math.min(a.top, b.top) - Math.max(a.bottom, b.bottom)) / u;
|
|
1132
|
+
}
|
|
1133
|
+
//#endregion
|
|
1134
|
+
//#region src/search/epoch.ts
|
|
1135
|
+
/**
|
|
1136
|
+
* The search CONTENT EPOCH: a deterministic fingerprint of everything a
|
|
1137
|
+
* search result depends on — page order/structure (`layoutVersion`) and
|
|
1138
|
+
* each page's text content (`contentVersion`), nothing else. Annotation
|
|
1139
|
+
* and metadata churn deliberately do NOT move it, so cached search
|
|
1140
|
+
* responses in a collaborative document survive comment storms.
|
|
1141
|
+
*
|
|
1142
|
+
* Both sides compute it from the manifest they already hold: the client
|
|
1143
|
+
* to mint versioned search URLs, the server to validate them (mismatch →
|
|
1144
|
+
* NotFound, the standard stale-versioned-read signal). FNV-1a 64-bit —
|
|
1145
|
+
* dependency-free and stable; not cryptographic, and doesn't need to be:
|
|
1146
|
+
* an adversary can only vary his OWN authorized query results.
|
|
1147
|
+
*/
|
|
1148
|
+
function searchContentEpoch(manifest) {
|
|
1149
|
+
let input = `${manifest.layoutVersion}`;
|
|
1150
|
+
for (const page of manifest.pages) input += `|${page.state.pageObjectNumber}:${page.cache.contentVersion}`;
|
|
1151
|
+
return fnv1a64(input);
|
|
1152
|
+
}
|
|
1153
|
+
const FNV_OFFSET = 14695981039346656037n;
|
|
1154
|
+
const FNV_PRIME = 1099511628211n;
|
|
1155
|
+
const MASK_64 = 18446744073709551615n;
|
|
1156
|
+
function fnv1a64(input) {
|
|
1157
|
+
let hash = FNV_OFFSET;
|
|
1158
|
+
for (let i = 0; i < input.length; i++) {
|
|
1159
|
+
hash ^= BigInt(input.charCodeAt(i));
|
|
1160
|
+
hash = hash * FNV_PRIME & MASK_64;
|
|
1161
|
+
}
|
|
1162
|
+
return hash.toString(16).padStart(16, "0");
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Canonicalize a query for CACHE KEYING: default-fold literal queries are
|
|
1166
|
+
* pre-folded ("Café", "café" and "CAFE" are the same search, so they
|
|
1167
|
+
* should be the same cache entry). Semantics are unchanged — the fold is
|
|
1168
|
+
* idempotent and exactly what the matcher applies to the needle anyway.
|
|
1169
|
+
* Case- or diacritic-sensitive literals and regex patterns pass through
|
|
1170
|
+
* untouched (their raw form IS the query).
|
|
1171
|
+
*/
|
|
1172
|
+
function canonicalSearchQuery(query) {
|
|
1173
|
+
if (query.regex || query.matchCase || query.matchDiacritics) return query;
|
|
1174
|
+
const canonical = { text: foldText(query.text).folded };
|
|
1175
|
+
if (query.wholeWord) canonical.wholeWord = true;
|
|
1176
|
+
return canonical;
|
|
1177
|
+
}
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/auth/scope/pdf-bits.ts
|
|
1180
|
+
/**
|
|
1181
|
+
* ISO 32000 user-access permission bit positions, expressed as integer
|
|
1182
|
+
* masks. Bit numbers in the spec are 1-indexed, so bit 3 = `1 << 2`.
|
|
1183
|
+
*
|
|
1184
|
+
* Bits 1, 2, 7, 8, and 13+ are reserved and not surfaced here.
|
|
1185
|
+
*/
|
|
1186
|
+
const PDF_BITS = {
|
|
1187
|
+
/** Bit 3 — print the document. */
|
|
1188
|
+
PRINT: 4,
|
|
1189
|
+
/** Bit 4 — modify content streams. */
|
|
1190
|
+
MODIFY: 8,
|
|
1191
|
+
/** Bit 5 — copy/extract text and graphics. */
|
|
1192
|
+
COPY: 16,
|
|
1193
|
+
/** Bit 6 — annotate + fill forms (+ modify form fields with bit 4). */
|
|
1194
|
+
ANNOTATE_FILL: 32,
|
|
1195
|
+
/** Bit 9 — fill existing forms (even if bit 6 is clear). */
|
|
1196
|
+
FILL_FORMS: 256,
|
|
1197
|
+
/** Bit 10 — accessibility extraction (deprecated in PDF 2.0). */
|
|
1198
|
+
ACCESSIBILITY: 512,
|
|
1199
|
+
/** Bit 11 — assemble document (insert/rotate/delete pages, outline). */
|
|
1200
|
+
ASSEMBLE: 1024,
|
|
1201
|
+
/** Bit 12 — high-quality print (refines bit 3). */
|
|
1202
|
+
PRINT_HIGH: 2048
|
|
1203
|
+
};
|
|
1204
|
+
/**
|
|
1205
|
+
* Decode a raw PDFium permission integer into a typed boolean view.
|
|
1206
|
+
*
|
|
1207
|
+
* `null` input (the documents row column is nullable until probed)
|
|
1208
|
+
* yields all-false bits — i.e., a maximally restrictive interpretation.
|
|
1209
|
+
* That keeps `pdf.permissions` expansion safe on unprobed documents.
|
|
1210
|
+
*/
|
|
1211
|
+
function decodePdfBits(raw) {
|
|
1212
|
+
if (raw === null) return {
|
|
1213
|
+
bit3: false,
|
|
1214
|
+
bit4: false,
|
|
1215
|
+
bit5: false,
|
|
1216
|
+
bit6: false,
|
|
1217
|
+
bit9: false,
|
|
1218
|
+
bit10: false,
|
|
1219
|
+
bit11: false,
|
|
1220
|
+
bit12: false
|
|
1221
|
+
};
|
|
1222
|
+
return {
|
|
1223
|
+
bit3: (raw & PDF_BITS.PRINT) !== 0,
|
|
1224
|
+
bit4: (raw & PDF_BITS.MODIFY) !== 0,
|
|
1225
|
+
bit5: (raw & PDF_BITS.COPY) !== 0,
|
|
1226
|
+
bit6: (raw & PDF_BITS.ANNOTATE_FILL) !== 0,
|
|
1227
|
+
bit9: (raw & PDF_BITS.FILL_FORMS) !== 0,
|
|
1228
|
+
bit10: (raw & PDF_BITS.ACCESSIBILITY) !== 0,
|
|
1229
|
+
bit11: (raw & PDF_BITS.ASSEMBLE) !== 0,
|
|
1230
|
+
bit12: (raw & PDF_BITS.PRINT_HIGH) !== 0
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
//#region src/auth/scope/builders.ts
|
|
1235
|
+
/**
|
|
1236
|
+
* Typed capability constants for customer-side JWT minting.
|
|
1237
|
+
*
|
|
1238
|
+
* The shape mirrors the dotted capability name so it reads naturally:
|
|
1239
|
+
* caps.doc.text.copy() // "doc.text.copy"
|
|
1240
|
+
* caps.doc.download() // "doc.download"
|
|
1241
|
+
* caps.doc.download.flattened() // "doc.download.flattened"
|
|
1242
|
+
* caps.doc.print.high() // "doc.print.high"
|
|
1243
|
+
*
|
|
1244
|
+
* Each leaf is a function returning the literal string (as `const`) so
|
|
1245
|
+
* TypeScript catches typos at the call site. Wrappers like `download`
|
|
1246
|
+
* and `print` are `Object.assign`'d to be callable AND carry child
|
|
1247
|
+
* properties for the refinement capabilities.
|
|
1248
|
+
*/
|
|
1249
|
+
const caps = { doc: {
|
|
1250
|
+
open: () => "doc.open",
|
|
1251
|
+
render: () => "doc.render",
|
|
1252
|
+
text: {
|
|
1253
|
+
select: () => "doc.text.select",
|
|
1254
|
+
copy: () => "doc.text.copy",
|
|
1255
|
+
search: () => "doc.text.search"
|
|
1256
|
+
},
|
|
1257
|
+
content: { copy: () => "doc.content.copy" },
|
|
1258
|
+
download: Object.assign(() => "doc.download", { flattened: () => "doc.download.flattened" }),
|
|
1259
|
+
print: Object.assign(() => "doc.print", { high: () => "doc.print.high" }),
|
|
1260
|
+
pages: {
|
|
1261
|
+
modify: () => "doc.pages.modify",
|
|
1262
|
+
assemble: () => "doc.pages.assemble"
|
|
1263
|
+
},
|
|
1264
|
+
forms: {
|
|
1265
|
+
read: () => "doc.forms.read",
|
|
1266
|
+
fill: () => "doc.forms.fill",
|
|
1267
|
+
modify: () => "doc.forms.modify"
|
|
1268
|
+
},
|
|
1269
|
+
annotate: {
|
|
1270
|
+
read: () => "doc.annotate.read",
|
|
1271
|
+
modify: () => "doc.annotate.modify"
|
|
1272
|
+
},
|
|
1273
|
+
metadata: { modify: () => "doc.metadata.modify" },
|
|
1274
|
+
attachments: { modify: () => "doc.attachments.modify" },
|
|
1275
|
+
redact: () => "doc.redact"
|
|
1276
|
+
} };
|
|
1277
|
+
/**
|
|
1278
|
+
* Builder factory for collab scope filters. Each entity:action pair
|
|
1279
|
+
* gets a sub-object with the four filter constructors.
|
|
1280
|
+
*
|
|
1281
|
+
* collab.annotations.create.self() → "annotations:create:self"
|
|
1282
|
+
* collab.annotations.create.group("legal") → "annotations:create:group=legal"
|
|
1283
|
+
* collab.annotations.update.group("4") → "annotations:update:group=4"
|
|
1284
|
+
* collab.annotations.delete.createdBy("u-7") → "annotations:delete:createdBy=u-7"
|
|
1285
|
+
* collab.annotations.setGroup.group("legal") → "annotations:set-group:group=legal"
|
|
1286
|
+
* collab.annotations.all.all() → "annotations:*:all" (action wildcard)
|
|
1287
|
+
*
|
|
1288
|
+
* On create, the filter is evaluated against the caller's own identity
|
|
1289
|
+
* (no impersonation), so `:self` and `:all` trivially pass; `:group=X`
|
|
1290
|
+
* is the meaningful filter — it constrains creation to callers whose
|
|
1291
|
+
* default group is X.
|
|
1292
|
+
*/
|
|
1293
|
+
const collab = { annotations: {
|
|
1294
|
+
create: makeFilterBuilder("annotations", "create"),
|
|
1295
|
+
update: makeFilterBuilder("annotations", "update"),
|
|
1296
|
+
delete: makeFilterBuilder("annotations", "delete"),
|
|
1297
|
+
setGroup: makeSetGroupBuilder(),
|
|
1298
|
+
/** Action wildcard — matches create, update, delete, AND set-group with the given filter. */
|
|
1299
|
+
all: makeFilterBuilder("annotations", "*")
|
|
1300
|
+
} };
|
|
1301
|
+
function makeFilterBuilder(entity, action) {
|
|
1302
|
+
return {
|
|
1303
|
+
all: () => `${entity}:${action}:all`,
|
|
1304
|
+
self: () => `${entity}:${action}:self`,
|
|
1305
|
+
createdBy: (userId) => `${entity}:${action}:createdBy=${userId}`,
|
|
1306
|
+
group: (groupId) => `${entity}:${action}:group=${groupId}`
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function makeSetGroupBuilder() {
|
|
1310
|
+
return {
|
|
1311
|
+
all: () => "annotations:set-group:all",
|
|
1312
|
+
group: (groupId) => `annotations:set-group:group=${groupId}`
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Builder for the `pdf.permissions` virtual scope. Returns the literal
|
|
1317
|
+
* string; provided as a helper for parity with caps/collab in JWT
|
|
1318
|
+
* construction code.
|
|
1319
|
+
*/
|
|
1320
|
+
const pdfPermissions = () => "pdf.permissions";
|
|
1321
|
+
/**
|
|
1322
|
+
* Materialise `pdf.permissions` into the concrete capability list it
|
|
1323
|
+
* would expand to under the given PDF bit configuration.
|
|
1324
|
+
*
|
|
1325
|
+
* Useful when the customer wants to start from PDF defaults and then
|
|
1326
|
+
* subtract specific capabilities — easier to build the JWT scope array
|
|
1327
|
+
* by filtering an explicit list than by composing virtual + denials.
|
|
1328
|
+
*
|
|
1329
|
+
* const defaults = materializePdfPermissions(pdfBits);
|
|
1330
|
+
* const scope = [
|
|
1331
|
+
* ...defaults.filter(s => s !== 'doc.text.copy'),
|
|
1332
|
+
* 'doc.download',
|
|
1333
|
+
* ];
|
|
1334
|
+
*
|
|
1335
|
+
* IMPORTANT: this MUST stay in sync with `addPdfPermissions` inside
|
|
1336
|
+
* resolver.ts. A test pins them together.
|
|
1337
|
+
*/
|
|
1338
|
+
function materializePdfPermissions(b) {
|
|
1339
|
+
const out = /* @__PURE__ */ new Set();
|
|
1340
|
+
out.add("doc.open");
|
|
1341
|
+
out.add("doc.render");
|
|
1342
|
+
out.add("doc.annotate.read");
|
|
1343
|
+
out.add("doc.forms.read");
|
|
1344
|
+
if (b.bit5) {
|
|
1345
|
+
out.add("doc.text.select");
|
|
1346
|
+
out.add("doc.text.copy");
|
|
1347
|
+
out.add("doc.text.search");
|
|
1348
|
+
out.add("doc.content.copy");
|
|
1349
|
+
}
|
|
1350
|
+
if (b.bit3) out.add("doc.print");
|
|
1351
|
+
if (b.bit12 && b.bit3) out.add("doc.print.high");
|
|
1352
|
+
if (b.bit4) {
|
|
1353
|
+
out.add("doc.pages.modify");
|
|
1354
|
+
out.add("doc.redact");
|
|
1355
|
+
out.add("doc.metadata.modify");
|
|
1356
|
+
out.add("doc.attachments.modify");
|
|
1357
|
+
}
|
|
1358
|
+
if (b.bit11) out.add("doc.pages.assemble");
|
|
1359
|
+
if (b.bit6) out.add("doc.annotate.modify");
|
|
1360
|
+
if (b.bit6 || b.bit9) out.add("doc.forms.fill");
|
|
1361
|
+
if (b.bit6 && b.bit4) out.add("doc.forms.modify");
|
|
1362
|
+
return [...out];
|
|
1363
|
+
}
|
|
1364
|
+
//#endregion
|
|
1365
|
+
export { pdfRectWidth as $, refKey as A, decodeStableIdKey as B, appearanceImpactOf as C, shiftsExistingAnnotationIndices as D, invalidatesWeakIndexRefs as E, resolveBinarySource as F, normalizePdfRect as G, isValidPageObjectNumber as H, NO_ANNOTATION_FLAGS as I, pdfQuadFromCorners as J, pdfQuadBounds as K, UNKNOWN_WEAK_ANNOTATION_STATE as L, normalizeAnnotationPatch as M, normalizeAttachmentFileSource as N, buildThreads as O, sniffBinaryMetadata as P, pdfRectToOriginSize as Q, knownWeakAnnotationState as R, encodeFieldRefKey as S, changesAnnotationList as T, DEFAULT_PDF_SAVE_MODE as U, encodeStableIdKey as V, createPageImageHandle as W, pdfRectHeight as X, pdfRectFromOriginSize as Y, pdfRectSize as Z, foldOptionsFor as _, PDF_BITS as a, wordBefore as b, searchContentEpoch as c, buildSnippet as d, matchPageText as f, validateSearchRegex as g, validateSearchQuery as h, pdfPermissions as i, normalizeAnnotationDraft as j, classifyRelation as k, searchRectsForRange as l, matchRegex as m, collab as n, decodePdfBits as o, SEARCH_REGEX_MAX_LENGTH as p, pdfQuadCorners as q, materializePdfPermissions as r, canonicalSearchQuery as s, caps as t, SEARCH_SNIPPET_CONTEXT as u, matchLiteral as v, semanticEqual as w, decodeFieldRefKey as x, wordAt as y, revisionTokensEqual as z };
|
|
1366
|
+
|
|
1367
|
+
//# sourceMappingURL=builders-C548U47M.js.map
|