@inkbar/core 0.3.0 → 0.5.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/README.md +57 -7
- package/dist/dom.d.mts +848 -43
- package/dist/dom.d.mts.map +1 -1
- package/dist/dom.mjs +5520 -769
- package/dist/dom.mjs.map +1 -1
- package/dist/index.d.mts +99 -17
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/index.mjs.map +1 -1
- package/dist/signatures-BwaXi7C8.d.mts +2284 -0
- package/dist/signatures-BwaXi7C8.d.mts.map +1 -0
- package/dist/signatures-CMapTKrC.mjs +4712 -0
- package/dist/signatures-CMapTKrC.mjs.map +1 -0
- package/package.json +5 -6
- package/dist/document-BftlPZKU.d.mts +0 -1455
- package/dist/document-BftlPZKU.d.mts.map +0 -1
- package/dist/threads-2USvyGpX.mjs +0 -1901
- package/dist/threads-2USvyGpX.mjs.map +0 -1
- package/schema.json +0 -650
|
@@ -0,0 +1,4712 @@
|
|
|
1
|
+
import { Circle, Color, FreeText, FreeTextStyle, Fringe, GLOSS_FORMAT, GLOSS_FORMAT as GLOSS_FORMAT$1, GLOSS_MEDIA_TYPE, GLOSS_SCHEMA_ID, GLOSS_VERSION, GLOSS_VERSION as GLOSS_VERSION$1, Highlight, IMAGE_DATA_URL, INTENTS, Ink, LINE_ENDINGS, Line, LineEnding, NOTE_ICONS, Note, NoteIcon, PDF_NAME, PDF_NAME as PDF_NAME$1, Point, Polygon, Polyline, Rect, Redact, Square, Stamp, commonFields } from "gloss-format";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { v7 } from "uuid";
|
|
4
|
+
//#region src/model/errors.ts
|
|
5
|
+
var InkError = class extends Error {
|
|
6
|
+
name = "InkError";
|
|
7
|
+
code;
|
|
8
|
+
details;
|
|
9
|
+
constructor(code, message, details = {}, cause) {
|
|
10
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function isInkError(value) {
|
|
16
|
+
return value instanceof InkError || typeof value === "object" && value !== null && value.name === "InkError";
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/model/events.ts
|
|
20
|
+
var Emitter = class {
|
|
21
|
+
listeners = /* @__PURE__ */ new Map();
|
|
22
|
+
on(event, fn) {
|
|
23
|
+
let set = this.listeners.get(event);
|
|
24
|
+
if (!set) {
|
|
25
|
+
set = /* @__PURE__ */ new Set();
|
|
26
|
+
this.listeners.set(event, set);
|
|
27
|
+
}
|
|
28
|
+
set.add(fn);
|
|
29
|
+
return () => {
|
|
30
|
+
set?.delete(fn);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
emit(event, payload) {
|
|
34
|
+
const set = this.listeners.get(event);
|
|
35
|
+
if (!set) return;
|
|
36
|
+
for (const fn of [...set]) fn(payload);
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
this.listeners.clear();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/model/geometry.ts
|
|
44
|
+
function approxEqual(a, b, eps = .01) {
|
|
45
|
+
return Math.abs(a - b) <= eps;
|
|
46
|
+
}
|
|
47
|
+
function rectFromPoints(a, b) {
|
|
48
|
+
return {
|
|
49
|
+
x: Math.min(a.x, b.x),
|
|
50
|
+
y: Math.min(a.y, b.y),
|
|
51
|
+
width: Math.abs(a.x - b.x),
|
|
52
|
+
height: Math.abs(a.y - b.y)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function rectFromQuad(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
|
+
const x = Math.min(...xs);
|
|
69
|
+
const y = Math.min(...ys);
|
|
70
|
+
return {
|
|
71
|
+
x,
|
|
72
|
+
y,
|
|
73
|
+
width: Math.max(...xs) - x,
|
|
74
|
+
height: Math.max(...ys) - y
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function quadFromRect(r) {
|
|
78
|
+
const top = r.y + r.height;
|
|
79
|
+
const right = r.x + r.width;
|
|
80
|
+
return {
|
|
81
|
+
p1: {
|
|
82
|
+
x: r.x,
|
|
83
|
+
y: top
|
|
84
|
+
},
|
|
85
|
+
p2: {
|
|
86
|
+
x: right,
|
|
87
|
+
y: top
|
|
88
|
+
},
|
|
89
|
+
p3: {
|
|
90
|
+
x: r.x,
|
|
91
|
+
y: r.y
|
|
92
|
+
},
|
|
93
|
+
p4: {
|
|
94
|
+
x: right,
|
|
95
|
+
y: r.y
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function unionRects(rects) {
|
|
100
|
+
if (rects.length === 0) throw new RangeError("unionRects needs at least one rect");
|
|
101
|
+
let x1 = Infinity;
|
|
102
|
+
let y1 = Infinity;
|
|
103
|
+
let x2 = -Infinity;
|
|
104
|
+
let y2 = -Infinity;
|
|
105
|
+
for (const r of rects) {
|
|
106
|
+
x1 = Math.min(x1, r.x);
|
|
107
|
+
y1 = Math.min(y1, r.y);
|
|
108
|
+
x2 = Math.max(x2, r.x + r.width);
|
|
109
|
+
y2 = Math.max(y2, r.y + r.height);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
x: x1,
|
|
113
|
+
y: y1,
|
|
114
|
+
width: x2 - x1,
|
|
115
|
+
height: y2 - y1
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function rectsIntersect(a, b) {
|
|
119
|
+
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
120
|
+
}
|
|
121
|
+
function rectContainsPoint(r, p) {
|
|
122
|
+
return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
|
|
123
|
+
}
|
|
124
|
+
function translateRect(r, dx, dy) {
|
|
125
|
+
return {
|
|
126
|
+
x: r.x + dx,
|
|
127
|
+
y: r.y + dy,
|
|
128
|
+
width: r.width,
|
|
129
|
+
height: r.height
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function translateQuad(q, dx, dy) {
|
|
133
|
+
const t = (p) => ({
|
|
134
|
+
x: p.x + dx,
|
|
135
|
+
y: p.y + dy
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
p1: t(q.p1),
|
|
139
|
+
p2: t(q.p2),
|
|
140
|
+
p3: t(q.p3),
|
|
141
|
+
p4: t(q.p4)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function applyMatrix(m, p) {
|
|
145
|
+
return {
|
|
146
|
+
x: m[0] * p.x + m[2] * p.y + m[4],
|
|
147
|
+
y: m[1] * p.x + m[3] * p.y + m[5]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function invertMatrix(m) {
|
|
151
|
+
const det = m[0] * m[3] - m[1] * m[2];
|
|
152
|
+
if (det === 0) throw new RangeError("matrix is not invertible");
|
|
153
|
+
return [
|
|
154
|
+
m[3] / det,
|
|
155
|
+
-m[1] / det,
|
|
156
|
+
-m[2] / det,
|
|
157
|
+
m[0] / det,
|
|
158
|
+
(m[2] * m[5] - m[3] * m[4]) / det,
|
|
159
|
+
(m[1] * m[4] - m[0] * m[5]) / det
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
/** Returns the matrix that applies `a` first, then `b`. */
|
|
163
|
+
function multiplyMatrix(a, b) {
|
|
164
|
+
return [
|
|
165
|
+
a[0] * b[0] + a[1] * b[2],
|
|
166
|
+
a[0] * b[1] + a[1] * b[3],
|
|
167
|
+
a[2] * b[0] + a[3] * b[2],
|
|
168
|
+
a[2] * b[1] + a[3] * b[3],
|
|
169
|
+
a[4] * b[0] + a[5] * b[2] + b[4],
|
|
170
|
+
a[4] * b[1] + a[5] * b[3] + b[5]
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
function pointsFromFlat(flat) {
|
|
174
|
+
const out = [];
|
|
175
|
+
for (let i = 0; i + 1 < flat.length; i += 2) out.push({
|
|
176
|
+
x: flat[i],
|
|
177
|
+
y: flat[i + 1]
|
|
178
|
+
});
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/** Eight numbers per quad, in PDF.js's normalised order. Trailing partial quads are dropped. */
|
|
182
|
+
function quadsFromFlat(flat) {
|
|
183
|
+
const pts = pointsFromFlat(flat);
|
|
184
|
+
const out = [];
|
|
185
|
+
for (let i = 0; i + 3 < pts.length; i += 4) out.push({
|
|
186
|
+
p1: pts[i],
|
|
187
|
+
p2: pts[i + 1],
|
|
188
|
+
p3: pts[i + 2],
|
|
189
|
+
p4: pts[i + 3]
|
|
190
|
+
});
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/model/readable.ts
|
|
195
|
+
function writable(initial) {
|
|
196
|
+
let value = initial;
|
|
197
|
+
const subs = /* @__PURE__ */ new Set();
|
|
198
|
+
const set = (next) => {
|
|
199
|
+
if (Object.is(next, value)) return;
|
|
200
|
+
value = next;
|
|
201
|
+
for (const fn of [...subs]) fn(value);
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
get: () => value,
|
|
205
|
+
set,
|
|
206
|
+
update(fn) {
|
|
207
|
+
set(fn(value));
|
|
208
|
+
},
|
|
209
|
+
subscribe(fn) {
|
|
210
|
+
subs.add(fn);
|
|
211
|
+
return () => {
|
|
212
|
+
subs.delete(fn);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A readable computed from `source`. It subscribes to the source only while it has
|
|
219
|
+
* subscribers of its own, so an unobserved derived value holds no reference to the source.
|
|
220
|
+
*/
|
|
221
|
+
function derived(source, fn) {
|
|
222
|
+
const subs = /* @__PURE__ */ new Set();
|
|
223
|
+
let cached;
|
|
224
|
+
let stopSource;
|
|
225
|
+
const onSource = (v) => {
|
|
226
|
+
const next = fn(v);
|
|
227
|
+
if (Object.is(next, cached)) return;
|
|
228
|
+
cached = next;
|
|
229
|
+
for (const sub of [...subs]) sub(next);
|
|
230
|
+
};
|
|
231
|
+
return {
|
|
232
|
+
get: () => stopSource ? cached : fn(source.get()),
|
|
233
|
+
subscribe(sub) {
|
|
234
|
+
if (!stopSource) {
|
|
235
|
+
cached = fn(source.get());
|
|
236
|
+
stopSource = source.subscribe(onSource);
|
|
237
|
+
}
|
|
238
|
+
subs.add(sub);
|
|
239
|
+
return () => {
|
|
240
|
+
if (!subs.delete(sub) || subs.size > 0) return;
|
|
241
|
+
stopSource?.();
|
|
242
|
+
stopSource = void 0;
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function readonly(w) {
|
|
248
|
+
return {
|
|
249
|
+
get: () => w.get(),
|
|
250
|
+
subscribe: (fn) => w.subscribe(fn)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/model/selection.ts
|
|
255
|
+
const union = (a, b) => {
|
|
256
|
+
const x = Math.min(a.x, b.x);
|
|
257
|
+
const y = Math.min(a.y, b.y);
|
|
258
|
+
return {
|
|
259
|
+
x,
|
|
260
|
+
y,
|
|
261
|
+
width: Math.max(a.x + a.width, b.x + b.width) - x,
|
|
262
|
+
height: Math.max(a.y + a.height, b.y + b.height) - y
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
const centreY = (r) => r.y + r.height / 2;
|
|
266
|
+
function median(values) {
|
|
267
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
268
|
+
const mid = sorted.length >> 1;
|
|
269
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Merges glyph boxes that share a line into one quad per run, ordered top to bottom (by
|
|
273
|
+
* vertical centre) then left to right. Boxes whose vertical centres are within `tolerance`
|
|
274
|
+
* points count as the same line; boxes with no area are dropped. Within a line, a horizontal
|
|
275
|
+
* gap wider than `gap` points (default 1.5 × the line's median box height) starts a new run,
|
|
276
|
+
* so text selected across a column gutter yields one quad per column. Pure user-space maths.
|
|
277
|
+
*/
|
|
278
|
+
function mergeLineRects(rects, tolerance = 2, gap) {
|
|
279
|
+
const lines = [];
|
|
280
|
+
for (const r of rects) {
|
|
281
|
+
if (!(r.width > 0 && r.height > 0)) continue;
|
|
282
|
+
const line = lines.find((l) => Math.abs(centreY(l.bounds) - centreY(r)) <= tolerance);
|
|
283
|
+
if (!line) {
|
|
284
|
+
lines.push({
|
|
285
|
+
bounds: { ...r },
|
|
286
|
+
boxes: [r]
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
line.bounds = union(line.bounds, r);
|
|
291
|
+
line.boxes.push(r);
|
|
292
|
+
}
|
|
293
|
+
const runs = [];
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
const maxGap = gap ?? 1.5 * median(line.boxes.map((b) => b.height));
|
|
296
|
+
const boxes = [...line.boxes].sort((a, b) => a.x - b.x);
|
|
297
|
+
let run = { ...boxes[0] };
|
|
298
|
+
for (const b of boxes.slice(1)) if (b.x - (run.x + run.width) > maxGap) {
|
|
299
|
+
runs.push(run);
|
|
300
|
+
run = { ...b };
|
|
301
|
+
} else run = union(run, b);
|
|
302
|
+
runs.push(run);
|
|
303
|
+
}
|
|
304
|
+
runs.sort((a, b) => centreY(b) - centreY(a) || a.x - b.x);
|
|
305
|
+
return runs.map(quadFromRect);
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/pdfjs/load.ts
|
|
309
|
+
const REQUIRED = [
|
|
310
|
+
"worker",
|
|
311
|
+
"cMaps",
|
|
312
|
+
"standardFonts",
|
|
313
|
+
"wasm"
|
|
314
|
+
];
|
|
315
|
+
function assertAssets(value) {
|
|
316
|
+
const record = typeof value === "object" && value !== null ? value : {};
|
|
317
|
+
const missing = REQUIRED.filter((k) => typeof record[k] !== "string" || record[k] === "");
|
|
318
|
+
if (value === void 0 || value === null || missing.length > 0) throw new InkError("AssetConfigMissing", "openDocument needs an `assets` option naming the PDF.js worker, cMaps, standardFonts and wasm locations. Vite users: add the @inkbar/core/vite plugin and pass `assets` from \"virtual:inkbar-assets\".", { missing: value == null ? [...REQUIRED] : missing });
|
|
319
|
+
}
|
|
320
|
+
let modulePromise;
|
|
321
|
+
let configuredWorker;
|
|
322
|
+
/**
|
|
323
|
+
* Loads PDF.js once. `#pdfjs` resolves to the legacy build in Node and the
|
|
324
|
+
* modern build everywhere else (see package.json "imports").
|
|
325
|
+
*/
|
|
326
|
+
async function loadPdfjs(assets) {
|
|
327
|
+
assertAssets(assets);
|
|
328
|
+
modulePromise ??= import("#pdfjs").catch((cause) => {
|
|
329
|
+
modulePromise = void 0;
|
|
330
|
+
throw new InkError("AssetLoadFailed", "PDF.js could not be imported", { url: assets.worker }, cause);
|
|
331
|
+
});
|
|
332
|
+
const pdfjs = await modulePromise;
|
|
333
|
+
if (configuredWorker === void 0) {
|
|
334
|
+
pdfjs.GlobalWorkerOptions.workerSrc = assets.worker;
|
|
335
|
+
configuredWorker = assets.worker;
|
|
336
|
+
} else if (configuredWorker !== assets.worker) throw new InkError("AssetConfigMissing", "PDF.js worker URL cannot change after the first document is opened", {
|
|
337
|
+
configured: configuredWorker,
|
|
338
|
+
requested: assets.worker
|
|
339
|
+
});
|
|
340
|
+
return pdfjs;
|
|
341
|
+
}
|
|
342
|
+
async function openProxy(pdfjs, bytes, options) {
|
|
343
|
+
const { assets, password, verbosity } = options;
|
|
344
|
+
const task = pdfjs.getDocument({
|
|
345
|
+
data: bytes,
|
|
346
|
+
...password !== void 0 ? { password } : {},
|
|
347
|
+
...verbosity !== void 0 ? { verbosity } : {},
|
|
348
|
+
cMapUrl: assets.cMaps,
|
|
349
|
+
cMapPacked: true,
|
|
350
|
+
standardFontDataUrl: assets.standardFonts,
|
|
351
|
+
wasmUrl: assets.wasm,
|
|
352
|
+
...assets.icc !== void 0 ? { iccUrl: assets.icc } : {}
|
|
353
|
+
});
|
|
354
|
+
try {
|
|
355
|
+
return {
|
|
356
|
+
task,
|
|
357
|
+
proxy: await task.promise
|
|
358
|
+
};
|
|
359
|
+
} catch (cause) {
|
|
360
|
+
try {
|
|
361
|
+
await task.destroy();
|
|
362
|
+
} catch {}
|
|
363
|
+
throw mapLoadError(cause, {
|
|
364
|
+
assets,
|
|
365
|
+
passwordSupplied: password !== void 0
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** `PasswordResponses` values from PDF.js (`NEED_PASSWORD = 1`, `INCORRECT_PASSWORD = 2`). */
|
|
370
|
+
const INCORRECT_PASSWORD = 2;
|
|
371
|
+
/**
|
|
372
|
+
* PDF.js raises these from `task.promise` when the worker script cannot be
|
|
373
|
+
* imported (in browsers after its real Worker fails and the fake-worker
|
|
374
|
+
* fallback also fails to import `workerSrc`).
|
|
375
|
+
*/
|
|
376
|
+
const WORKER_FAILURE = /Setting up fake worker failed|No "GlobalWorkerOptions\.workerSrc" specified/i;
|
|
377
|
+
/**
|
|
378
|
+
* Only the worker is mapped here. CMap, standard-font and wasm failures are
|
|
379
|
+
* PDF.js warnings with fallbacks that never reject the open promise, so they
|
|
380
|
+
* cannot be detected at open time.
|
|
381
|
+
*
|
|
382
|
+
* PDF.js 6 exports only some exception classes (`PasswordException`,
|
|
383
|
+
* `InvalidPDFException`, `ResponseException`, `AbortException`), and errors
|
|
384
|
+
* that cross the worker boundary are rebuilt by name, so match on `name`.
|
|
385
|
+
* Inputs here are always in-memory bytes, so there is no fetch to fail:
|
|
386
|
+
* every non-password failure means the bytes are not a usable PDF.
|
|
387
|
+
*/
|
|
388
|
+
function mapLoadError(cause, context) {
|
|
389
|
+
const { assets, passwordSupplied = false } = context;
|
|
390
|
+
const name = cause?.name;
|
|
391
|
+
const rawMessage = cause?.message;
|
|
392
|
+
const message = typeof rawMessage === "string" && rawMessage !== "" ? rawMessage : "Unknown PDF.js error";
|
|
393
|
+
if (WORKER_FAILURE.test(message)) return new InkError("AssetLoadFailed", "The PDF.js worker could not be loaded. Check the `assets.worker` URL.", { url: assets.worker }, cause);
|
|
394
|
+
if (name === "PasswordException") {
|
|
395
|
+
const wrong = cause.code === INCORRECT_PASSWORD;
|
|
396
|
+
return new InkError("PasswordRequired", wrong ? "The password is incorrect" : "This document is encrypted and needs a password", {
|
|
397
|
+
passwordSupplied,
|
|
398
|
+
incorrect: wrong
|
|
399
|
+
}, cause);
|
|
400
|
+
}
|
|
401
|
+
return new InkError("InvalidPdf", message, {
|
|
402
|
+
pdfjsErrorName: typeof name === "string" ? name : void 0,
|
|
403
|
+
pdfjsMessage: message
|
|
404
|
+
}, cause);
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/model/annotations/defaults.ts
|
|
408
|
+
const COLORS = {
|
|
409
|
+
highlight: "#F2C230",
|
|
410
|
+
underline: "#2B5A9E",
|
|
411
|
+
strikeout: "#C0392B",
|
|
412
|
+
squiggly: "#1F7A4D",
|
|
413
|
+
text: "#F2C230",
|
|
414
|
+
freetext: "#151820",
|
|
415
|
+
square: "#C0392B",
|
|
416
|
+
circle: "#C0392B",
|
|
417
|
+
line: "#151820",
|
|
418
|
+
polyline: "#151820",
|
|
419
|
+
polygon: "#151820",
|
|
420
|
+
ink: "#151820",
|
|
421
|
+
stamp: "#151820",
|
|
422
|
+
loupe: "#151820",
|
|
423
|
+
mask: "#000000",
|
|
424
|
+
redact: "#C0392B"
|
|
425
|
+
};
|
|
426
|
+
const staticDefaults = {
|
|
427
|
+
color: (type) => COLORS[type],
|
|
428
|
+
fill: () => "#000000",
|
|
429
|
+
opacity: (type) => type === "highlight" ? .4 : 1
|
|
430
|
+
};
|
|
431
|
+
const VARIABLES = {
|
|
432
|
+
highlight: "--ink-highlight",
|
|
433
|
+
underline: "--ink-underline",
|
|
434
|
+
strikeout: "--ink-strikeout",
|
|
435
|
+
squiggly: "--ink-squiggly",
|
|
436
|
+
text: "--ink-note",
|
|
437
|
+
freetext: "--ink-free-text",
|
|
438
|
+
square: "--ink-shape",
|
|
439
|
+
circle: "--ink-shape",
|
|
440
|
+
line: "--ink-shape",
|
|
441
|
+
polyline: "--ink-shape",
|
|
442
|
+
polygon: "--ink-shape",
|
|
443
|
+
ink: "--ink-ink",
|
|
444
|
+
stamp: "--ink-ink",
|
|
445
|
+
loupe: "--ink-shape",
|
|
446
|
+
mask: "--ink-mask",
|
|
447
|
+
redact: "--ink-redact"
|
|
448
|
+
};
|
|
449
|
+
/** Resolves default colours from CSS custom properties on `el`, falling back to `staticDefaults`. */
|
|
450
|
+
function cssVariableDefaults(el) {
|
|
451
|
+
const read = (name) => {
|
|
452
|
+
const view = el.ownerDocument.defaultView;
|
|
453
|
+
if (!view) return void 0;
|
|
454
|
+
return normalizeColor(view.getComputedStyle(el).getPropertyValue(name));
|
|
455
|
+
};
|
|
456
|
+
return {
|
|
457
|
+
color: (type) => read(VARIABLES[type]) ?? staticDefaults.color(type),
|
|
458
|
+
fill: () => read("--ink-redact-fill") ?? staticDefaults.fill(),
|
|
459
|
+
opacity: (type) => staticDefaults.opacity(type)
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/** Accepts `#rgb`, `#rrggbb`, `rgb(r, g, b)` and `rgb(r g b)`; returns `#RRGGBB` or undefined. */
|
|
463
|
+
function normalizeColor(value) {
|
|
464
|
+
const v = value.trim();
|
|
465
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(v);
|
|
466
|
+
if (short) {
|
|
467
|
+
const [, r = "", g = "", b = ""] = short;
|
|
468
|
+
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
|
469
|
+
}
|
|
470
|
+
if (/^#[0-9a-f]{6}$/i.test(v)) return v.toUpperCase();
|
|
471
|
+
const rgb = /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/i.exec(v);
|
|
472
|
+
if (rgb) return `#${[
|
|
473
|
+
rgb[1],
|
|
474
|
+
rgb[2],
|
|
475
|
+
rgb[3]
|
|
476
|
+
].map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/model/pdf-date.ts
|
|
480
|
+
/** Parses a PDF date string (`D:YYYYMMDDHHmmSSOHH'mm'`) into ISO 8601. Returns `fallback` on anything unparseable. */
|
|
481
|
+
function parsePdfDate(value, fallback) {
|
|
482
|
+
if (!value) return fallback;
|
|
483
|
+
const m = /^(?:D:)?(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?(Z|[+-]\d{2}(?:'\d{2}')?)?/.exec(value.trim());
|
|
484
|
+
if (!m) return fallback;
|
|
485
|
+
const [, y, mo = "01", d = "01", h = "00", mi = "00", s = "00", zone] = m;
|
|
486
|
+
let offsetMinutes = 0;
|
|
487
|
+
if (zone && zone !== "Z") {
|
|
488
|
+
const sign = zone.startsWith("-") ? -1 : 1;
|
|
489
|
+
const zh = Number(zone.slice(1, 3));
|
|
490
|
+
const zm = Number(zone.slice(4, 6) || 0);
|
|
491
|
+
offsetMinutes = sign * (zh * 60 + zm);
|
|
492
|
+
}
|
|
493
|
+
const utc = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) - offsetMinutes * 6e4;
|
|
494
|
+
if (Number.isNaN(utc)) return fallback;
|
|
495
|
+
return new Date(utc).toISOString();
|
|
496
|
+
}
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region src/model/annotations/pdf-names.ts
|
|
499
|
+
/**
|
|
500
|
+
* A set of PDF names Inkbar knows that stays open: any other valid PDF name loads, is kept and
|
|
501
|
+
* is written back unchanged. The output type keeps autocomplete for the known values.
|
|
502
|
+
*/
|
|
503
|
+
function pdfNameSet(known) {
|
|
504
|
+
return z.union([z.enum(known), z.string().regex(PDF_NAME, "expected a PDF name")]).describe(`One of ${known.join(", ")}. Any other PDF name is allowed and kept unchanged.`);
|
|
505
|
+
}
|
|
506
|
+
/** True when `v` is one of the values in `list`. */
|
|
507
|
+
function isKnown(list, v) {
|
|
508
|
+
return list.includes(v);
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/model/annotations/callout.ts
|
|
512
|
+
/**
|
|
513
|
+
* The insets from a free text's `rect` to its text box, in points (PDF `/RD`, XFDF `fringe`,
|
|
514
|
+
* both in this order).
|
|
515
|
+
*/
|
|
516
|
+
const FringeSchema = z.object(Fringe.shape);
|
|
517
|
+
/** An ending's length, and its full width, as a multiple of the stroke width. */
|
|
518
|
+
const ENDING_SCALE = 3;
|
|
519
|
+
/**
|
|
520
|
+
* A free text's text box: `rect` inset by `fringe`, or `rect` itself without one. Insets wider
|
|
521
|
+
* or taller than `rect` leave a box of width or height 0 at the inset origin.
|
|
522
|
+
*/
|
|
523
|
+
function freeTextBox(a) {
|
|
524
|
+
const { rect, fringe } = a;
|
|
525
|
+
if (!fringe) return rect;
|
|
526
|
+
return {
|
|
527
|
+
x: rect.x + fringe.left,
|
|
528
|
+
y: rect.y + fringe.bottom,
|
|
529
|
+
width: Math.max(0, rect.width - fringe.left - fringe.right),
|
|
530
|
+
height: Math.max(0, rect.height - fringe.top - fringe.bottom)
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* True for a free-text callout drawn the standard way: a box with a leader line through
|
|
535
|
+
* `callout`. A callout with `cornerRadius` is Inkbar's speech bubble instead.
|
|
536
|
+
*/
|
|
537
|
+
function isStandardCallout(a) {
|
|
538
|
+
return a.type === "freetext" && a.intent === "FreeTextCallout" && a.callout !== void 0 && a.callout.length >= 2 && a.cornerRadius === void 0;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The leader line's stroke: the border colour, else the text colour, and the border width, with
|
|
542
|
+
* 1 pt when the box has no border.
|
|
543
|
+
*/
|
|
544
|
+
function calloutStroke(a) {
|
|
545
|
+
return {
|
|
546
|
+
color: a.borderColor ?? a.defaultStyle.color,
|
|
547
|
+
width: a.width > 0 ? a.width : 1
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The `rect` and `fringe` that hold the text box `box` and a leader line through `callout`
|
|
552
|
+
* stroked `width` points wide: every point is padded by half the stroke, and by the ending's
|
|
553
|
+
* size as well when `head` is one Inkbar draws.
|
|
554
|
+
*/
|
|
555
|
+
function withCalloutBounds(box, callout, width, head) {
|
|
556
|
+
const drawn = head !== void 0 && head !== "None" && isKnown(LINE_ENDINGS, head);
|
|
557
|
+
const pad = width / 2 + (drawn ? ENDING_SCALE * Math.max(width, 1) : 0);
|
|
558
|
+
const xs = [
|
|
559
|
+
box.x,
|
|
560
|
+
box.x + box.width,
|
|
561
|
+
...callout.map((p) => p.x)
|
|
562
|
+
];
|
|
563
|
+
const ys = [
|
|
564
|
+
box.y,
|
|
565
|
+
box.y + box.height,
|
|
566
|
+
...callout.map((p) => p.y)
|
|
567
|
+
];
|
|
568
|
+
const left = Math.min(...xs) - pad;
|
|
569
|
+
const bottom = Math.min(...ys) - pad;
|
|
570
|
+
const right = Math.max(...xs) + pad;
|
|
571
|
+
const top = Math.max(...ys) + pad;
|
|
572
|
+
return {
|
|
573
|
+
rect: {
|
|
574
|
+
x: left,
|
|
575
|
+
y: bottom,
|
|
576
|
+
width: right - left,
|
|
577
|
+
height: top - bottom
|
|
578
|
+
},
|
|
579
|
+
fringe: {
|
|
580
|
+
left: box.x - left,
|
|
581
|
+
top: top - (box.y + box.height),
|
|
582
|
+
right: right - (box.x + box.width),
|
|
583
|
+
bottom: box.y - bottom
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/model/annotations/types.ts
|
|
589
|
+
const PointSchema = z.object(Point.shape);
|
|
590
|
+
const RectSchema = z.object(Rect.shape);
|
|
591
|
+
const QuadSchema = z.object({
|
|
592
|
+
p1: PointSchema,
|
|
593
|
+
p2: PointSchema,
|
|
594
|
+
p3: PointSchema,
|
|
595
|
+
p4: PointSchema
|
|
596
|
+
});
|
|
597
|
+
/** What Inkbar adds to every Gloss type: the fields it always fills in, and its viewer state. */
|
|
598
|
+
const Base = {
|
|
599
|
+
/** Bounding box in PDF user space. */
|
|
600
|
+
rect: RectSchema,
|
|
601
|
+
/** Resolved #RRGGBB. Written into the PDF later, so it is always concrete. */
|
|
602
|
+
color: Color,
|
|
603
|
+
opacity: commonFields.opacity.removeDefault(),
|
|
604
|
+
createdAt: commonFields.createdAt.unwrap(),
|
|
605
|
+
modifiedAt: commonFields.modifiedAt.unwrap(),
|
|
606
|
+
/** Created in this session, or imported from the file. Viewer state, never exported. */
|
|
607
|
+
source: z.enum(["session", "pdf"]),
|
|
608
|
+
/** Embedded annotations are locked until the engine can rewrite the file. Viewer state, never exported. */
|
|
609
|
+
locked: z.boolean().optional()
|
|
610
|
+
};
|
|
611
|
+
const Quads = z.array(QuadSchema).min(1);
|
|
612
|
+
const TextMarkupSchema = z.object({
|
|
613
|
+
...Highlight.shape,
|
|
614
|
+
...Base,
|
|
615
|
+
type: z.enum([
|
|
616
|
+
"highlight",
|
|
617
|
+
"underline",
|
|
618
|
+
"strikeout",
|
|
619
|
+
"squiggly"
|
|
620
|
+
]),
|
|
621
|
+
quads: Quads
|
|
622
|
+
});
|
|
623
|
+
/** A note's icon (XFDF `icon`): a PDF name, so a name Inkbar does not know is kept. */
|
|
624
|
+
const NoteIconSchema = NoteIcon;
|
|
625
|
+
const NoteSchema = z.object({
|
|
626
|
+
...Note.shape,
|
|
627
|
+
...Base
|
|
628
|
+
});
|
|
629
|
+
/** A line ending (XFDF `head`/`tail`): a PDF name, so a name Inkbar does not know is kept. */
|
|
630
|
+
const LineEndingSchema = LineEnding;
|
|
631
|
+
/**
|
|
632
|
+
* A key a type refuses outright rather than dropping like other unknown keys: one an older
|
|
633
|
+
* shape used (so its value is not silently lost), or one that means nothing on the type (so a
|
|
634
|
+
* later version is free to give it a meaning).
|
|
635
|
+
*/
|
|
636
|
+
const Refused = z.never().optional();
|
|
637
|
+
/**
|
|
638
|
+
* A free-text box's text style, keyed by the CSS property names XFDF writes in its
|
|
639
|
+
* `defaultstyle` string. The older `align` is refused; other unknown keys are dropped.
|
|
640
|
+
*/
|
|
641
|
+
const FreeTextStyleSchema = z.object({
|
|
642
|
+
...FreeTextStyle.shape,
|
|
643
|
+
/** The text colour. */
|
|
644
|
+
color: Color,
|
|
645
|
+
align: Refused
|
|
646
|
+
});
|
|
647
|
+
/**
|
|
648
|
+
* Free text, as XFDF has it: `color` is the box's fill and the text style lives in
|
|
649
|
+
* `defaultStyle`. A box in the older shape (top-level `fontFamily`, `fontSize`, `align`) is
|
|
650
|
+
* refused rather than silently losing its style; other unknown keys are dropped.
|
|
651
|
+
*/
|
|
652
|
+
const FreeTextSchema = z.object({
|
|
653
|
+
...FreeText.shape,
|
|
654
|
+
...Base,
|
|
655
|
+
/** The box's fill (XFDF `color`). Absent means no fill. */
|
|
656
|
+
color: FreeText.shape.color,
|
|
657
|
+
/** The box's border colour. Absent means the text colour. */
|
|
658
|
+
borderColor: Color.optional(),
|
|
659
|
+
defaultStyle: FreeTextStyleSchema,
|
|
660
|
+
fringe: FringeSchema.optional(),
|
|
661
|
+
callout: z.array(PointSchema).min(2).max(3).optional(),
|
|
662
|
+
fontFamily: Refused,
|
|
663
|
+
fontSize: Refused,
|
|
664
|
+
align: Refused
|
|
665
|
+
});
|
|
666
|
+
const SquareSchema = z.object({
|
|
667
|
+
...Square.shape,
|
|
668
|
+
...Base
|
|
669
|
+
});
|
|
670
|
+
const CircleSchema = z.object({
|
|
671
|
+
...Circle.shape,
|
|
672
|
+
...Base,
|
|
673
|
+
cornerRadius: Refused
|
|
674
|
+
});
|
|
675
|
+
const ShapeSchema = z.discriminatedUnion("type", [SquareSchema, CircleSchema]);
|
|
676
|
+
const LineSchema = z.object({
|
|
677
|
+
...Line.shape,
|
|
678
|
+
...Base,
|
|
679
|
+
start: PointSchema,
|
|
680
|
+
end: PointSchema
|
|
681
|
+
});
|
|
682
|
+
const Vertices = z.array(PointSchema).min(2);
|
|
683
|
+
const PolylineSchema = z.object({
|
|
684
|
+
...Polyline.shape,
|
|
685
|
+
...Base,
|
|
686
|
+
vertices: Vertices
|
|
687
|
+
});
|
|
688
|
+
const PolygonSchema = z.object({
|
|
689
|
+
...Polygon.shape,
|
|
690
|
+
...Base,
|
|
691
|
+
vertices: Vertices,
|
|
692
|
+
head: Refused,
|
|
693
|
+
tail: Refused
|
|
694
|
+
});
|
|
695
|
+
const PolySchema = z.discriminatedUnion("type", [PolylineSchema, PolygonSchema]);
|
|
696
|
+
const InkShape = z.object({
|
|
697
|
+
...Ink.shape,
|
|
698
|
+
...Base,
|
|
699
|
+
inklist: z.array(z.array(PointSchema).min(1)).min(1),
|
|
700
|
+
/** Pen pressure from 0 to 1, one value per point of each stroke. Absent means even pressure. */
|
|
701
|
+
pressure: z.array(z.array(z.number().min(0).max(1))).optional()
|
|
702
|
+
});
|
|
703
|
+
/** Pressure must have one value per point of each stroke. */
|
|
704
|
+
function checkPressure(a, ctx) {
|
|
705
|
+
if (!a.pressure) return;
|
|
706
|
+
if (!(a.pressure.length === a.inklist.length && a.pressure.every((p, i) => p.length === a.inklist[i]?.length))) ctx.addIssue({
|
|
707
|
+
code: "custom",
|
|
708
|
+
path: ["pressure"],
|
|
709
|
+
message: "pressure must have the same shape as inklist"
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const InkSchema = InkShape.superRefine(checkPressure);
|
|
713
|
+
/**
|
|
714
|
+
* An image on the page (XFDF `stamp`), drawn to fill `rect`. Image and typed signatures are
|
|
715
|
+
* stamps with `subject: 'Signature'`.
|
|
716
|
+
*/
|
|
717
|
+
const StampSchema = z.object({
|
|
718
|
+
...Stamp.shape,
|
|
719
|
+
...Base
|
|
720
|
+
});
|
|
721
|
+
/**
|
|
722
|
+
* A loupe (Inkbar's own type, a circle with `custom.inkbar.type: 'loupe'` in a file): a circle
|
|
723
|
+
* inscribed in `rect` that shows the page content under its own centre, magnified. `color` and
|
|
724
|
+
* `width` draw its rim.
|
|
725
|
+
*/
|
|
726
|
+
const LoupeSchema = z.object({
|
|
727
|
+
type: z.literal("loupe"),
|
|
728
|
+
...commonFields,
|
|
729
|
+
/** How many times larger the content under the centre is drawn. Above 1. */
|
|
730
|
+
magnification: z.number().gt(1).default(2),
|
|
731
|
+
/** The rim's width in points. */
|
|
732
|
+
width: z.number().nonnegative().default(2),
|
|
733
|
+
...Base
|
|
734
|
+
});
|
|
735
|
+
/**
|
|
736
|
+
* A mask (Inkbar's own type, a circle or square with `custom.inkbar.type: 'mask'` in a file):
|
|
737
|
+
* dims everything on the page outside `shape` drawn in `rect`, an ellipse inscribed in it or the
|
|
738
|
+
* rectangle itself. `color` is the dimming colour and `dim` how strongly it covers, from 0 to 1.
|
|
739
|
+
* One per page: adding a second replaces the first.
|
|
740
|
+
*/
|
|
741
|
+
const MaskSchema = z.object({
|
|
742
|
+
type: z.literal("mask"),
|
|
743
|
+
...commonFields,
|
|
744
|
+
shape: z.enum(["rect", "ellipse"]),
|
|
745
|
+
dim: z.number().min(0).max(1).default(.5),
|
|
746
|
+
...Base
|
|
747
|
+
});
|
|
748
|
+
const RedactSchema = z.object({
|
|
749
|
+
...Redact.shape,
|
|
750
|
+
...Base,
|
|
751
|
+
/** Present when the mark came from text; absent for an area mark. */
|
|
752
|
+
quads: Quads.optional(),
|
|
753
|
+
/** Colour the redaction will be filled with when applied. */
|
|
754
|
+
interiorColor: Color
|
|
755
|
+
});
|
|
756
|
+
const AnnotationSchema = z.discriminatedUnion("type", [
|
|
757
|
+
TextMarkupSchema,
|
|
758
|
+
NoteSchema,
|
|
759
|
+
FreeTextSchema,
|
|
760
|
+
SquareSchema,
|
|
761
|
+
CircleSchema,
|
|
762
|
+
LineSchema,
|
|
763
|
+
PolylineSchema,
|
|
764
|
+
PolygonSchema,
|
|
765
|
+
InkSchema,
|
|
766
|
+
StampSchema,
|
|
767
|
+
LoupeSchema,
|
|
768
|
+
MaskSchema,
|
|
769
|
+
RedactSchema
|
|
770
|
+
]);
|
|
771
|
+
/** The viewer's own state on an annotation, left out of exports. */
|
|
772
|
+
const VIEWER_STATE = {
|
|
773
|
+
source: true,
|
|
774
|
+
locked: true
|
|
775
|
+
};
|
|
776
|
+
/** One exported annotation: the stored annotation without its viewer state. */
|
|
777
|
+
const ExportedAnnotationSchema = z.discriminatedUnion("type", [
|
|
778
|
+
TextMarkupSchema.omit(VIEWER_STATE),
|
|
779
|
+
NoteSchema.omit(VIEWER_STATE),
|
|
780
|
+
FreeTextSchema.omit(VIEWER_STATE),
|
|
781
|
+
SquareSchema.omit(VIEWER_STATE),
|
|
782
|
+
CircleSchema.omit(VIEWER_STATE),
|
|
783
|
+
LineSchema.omit(VIEWER_STATE),
|
|
784
|
+
PolylineSchema.omit(VIEWER_STATE),
|
|
785
|
+
PolygonSchema.omit(VIEWER_STATE),
|
|
786
|
+
InkShape.omit(VIEWER_STATE).superRefine(checkPressure),
|
|
787
|
+
StampSchema.omit(VIEWER_STATE),
|
|
788
|
+
LoupeSchema.omit(VIEWER_STATE),
|
|
789
|
+
MaskSchema.omit(VIEWER_STATE),
|
|
790
|
+
RedactSchema.omit(VIEWER_STATE)
|
|
791
|
+
]);
|
|
792
|
+
const AnnotationDocumentSchema = z.object({
|
|
793
|
+
format: z.literal(GLOSS_FORMAT),
|
|
794
|
+
version: z.literal(GLOSS_VERSION),
|
|
795
|
+
documentKey: z.string().optional(),
|
|
796
|
+
/** Validated item by item on import so unknown types can be preserved. */
|
|
797
|
+
annotations: z.array(z.unknown())
|
|
798
|
+
});
|
|
799
|
+
//#endregion
|
|
800
|
+
//#region src/model/annotations/embedded.ts
|
|
801
|
+
const SUPPORTED_SUBTYPES = /* @__PURE__ */ new Set([
|
|
802
|
+
"Highlight",
|
|
803
|
+
"Underline",
|
|
804
|
+
"StrikeOut",
|
|
805
|
+
"Squiggly",
|
|
806
|
+
"Text",
|
|
807
|
+
"FreeText",
|
|
808
|
+
"Square",
|
|
809
|
+
"Circle",
|
|
810
|
+
"Line",
|
|
811
|
+
"Polygon",
|
|
812
|
+
"PolyLine",
|
|
813
|
+
"Ink",
|
|
814
|
+
"Redact"
|
|
815
|
+
]);
|
|
816
|
+
function pdfColorToHex(c, fallback) {
|
|
817
|
+
if (!c || c.length < 3) return fallback;
|
|
818
|
+
return `#${[
|
|
819
|
+
c[0],
|
|
820
|
+
c[1],
|
|
821
|
+
c[2]
|
|
822
|
+
].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
|
823
|
+
}
|
|
824
|
+
/** PDF.js hands back `rect` already in user space, but not always ordered; normalise with min/max. */
|
|
825
|
+
function rectFromPdf(r) {
|
|
826
|
+
return rectFromPoints({
|
|
827
|
+
x: r[0],
|
|
828
|
+
y: r[1]
|
|
829
|
+
}, {
|
|
830
|
+
x: r[2],
|
|
831
|
+
y: r[3]
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/** A PDF name exactly as PDF.js gives it (names are case-sensitive), or undefined if invalid. */
|
|
835
|
+
function pdfName(raw) {
|
|
836
|
+
return raw !== void 0 && PDF_NAME$1.test(raw) ? raw : void 0;
|
|
837
|
+
}
|
|
838
|
+
/** `head` and `tail` from a Line's or PolyLine's `/LE`, left out when both are `None`. */
|
|
839
|
+
function lineEndingsOf(raw) {
|
|
840
|
+
const endings = raw?.map((e) => pdfName(e) ?? "None");
|
|
841
|
+
return endings && endings.length === 2 && endings.some((e) => e !== "None") ? {
|
|
842
|
+
head: endings[0],
|
|
843
|
+
tail: endings[1]
|
|
844
|
+
} : {};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Imported ids are `pdf:<page>:<pdfjsId>` (page is 0-based, pdfjsId is the annotation's object
|
|
848
|
+
* reference such as `12R`). They are stable across opens of the same file, and `inReplyTo`
|
|
849
|
+
* uses the same scheme so threads resolve.
|
|
850
|
+
*/
|
|
851
|
+
function importedId(page, pdfjsId) {
|
|
852
|
+
return `pdf:${page}:${pdfjsId}`;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Imports one page's PDF.js annotation data. `targets` is @internal: the annotations whose
|
|
856
|
+
* missing fields the document reads from the file after `ready`.
|
|
857
|
+
*/
|
|
858
|
+
function importEmbeddedPage(page, raw, importedAt) {
|
|
859
|
+
const counts = {};
|
|
860
|
+
const annotations = [];
|
|
861
|
+
const targets = [];
|
|
862
|
+
for (const data of raw) {
|
|
863
|
+
counts[data.subtype] = (counts[data.subtype] ?? 0) + 1;
|
|
864
|
+
if (!SUPPORTED_SUBTYPES.has(data.subtype) || data.hidden || data.noView) continue;
|
|
865
|
+
const converted = convert(page, data, importedAt);
|
|
866
|
+
if (!converted) continue;
|
|
867
|
+
annotations.push(converted);
|
|
868
|
+
const target = targetOf(converted.id, data);
|
|
869
|
+
if (target) targets.push(target);
|
|
870
|
+
}
|
|
871
|
+
return {
|
|
872
|
+
annotations,
|
|
873
|
+
counts,
|
|
874
|
+
targets
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function targetOf(id, d) {
|
|
878
|
+
const rect = Array.from(d.rect);
|
|
879
|
+
if (d.subtype === "Text") return d.name === "NoIcon" ? {
|
|
880
|
+
id,
|
|
881
|
+
subtype: "Text",
|
|
882
|
+
rect
|
|
883
|
+
} : void 0;
|
|
884
|
+
if (d.subtype !== "FreeText") return void 0;
|
|
885
|
+
const daFont = d.defaultAppearanceData?.fontName;
|
|
886
|
+
return {
|
|
887
|
+
id,
|
|
888
|
+
subtype: "FreeText",
|
|
889
|
+
rect,
|
|
890
|
+
...daFont ? { daFont } : {}
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
function summarise(perPage) {
|
|
894
|
+
const totals = /* @__PURE__ */ new Map();
|
|
895
|
+
for (const counts of perPage) for (const [k, v] of Object.entries(counts)) totals.set(k, (totals.get(k) ?? 0) + v);
|
|
896
|
+
return [...totals.entries()].map(([subtype, count]) => ({
|
|
897
|
+
subtype,
|
|
898
|
+
count,
|
|
899
|
+
supported: SUPPORTED_SUBTYPES.has(subtype)
|
|
900
|
+
})).sort((a, b) => a.subtype < b.subtype ? -1 : a.subtype > b.subtype ? 1 : 0);
|
|
901
|
+
}
|
|
902
|
+
function convert(page, d, importedAt) {
|
|
903
|
+
const created = parsePdfDate(d.creationDate, parsePdfDate(d.modificationDate, importedAt));
|
|
904
|
+
const author = d.titleObj?.str;
|
|
905
|
+
const contents = d.contentsObj?.str;
|
|
906
|
+
const stroke = d.borderStyle?.width ?? 1;
|
|
907
|
+
const quads = d.quadPoints ? quadsFromFlat(d.quadPoints) : [];
|
|
908
|
+
const optional = (v) => v !== void 0 && v !== "";
|
|
909
|
+
const withColor = (type, source = d.color) => pdfColorToHex(source, staticDefaults.color(type));
|
|
910
|
+
const intent = pdfName(d.it);
|
|
911
|
+
const base = (type, color = withColor(type)) => ({
|
|
912
|
+
id: importedId(page, d.id),
|
|
913
|
+
page,
|
|
914
|
+
rect: rectFromPdf(d.rect),
|
|
915
|
+
color,
|
|
916
|
+
opacity: d.opacity ?? 1,
|
|
917
|
+
...optional(author) ? { author } : {},
|
|
918
|
+
...optional(contents) ? { contents } : {},
|
|
919
|
+
...d.inReplyTo ? { inReplyTo: importedId(page, d.inReplyTo) } : {},
|
|
920
|
+
...intent !== void 0 ? { intent } : {},
|
|
921
|
+
createdAt: created,
|
|
922
|
+
modifiedAt: parsePdfDate(d.modificationDate, created),
|
|
923
|
+
source: "pdf",
|
|
924
|
+
locked: true
|
|
925
|
+
});
|
|
926
|
+
const fillOf = (c) => c && c.length >= 3 ? { interiorColor: pdfColorToHex(c, "#FFFFFF") } : {};
|
|
927
|
+
let candidate;
|
|
928
|
+
switch (d.subtype) {
|
|
929
|
+
case "Highlight":
|
|
930
|
+
case "Underline":
|
|
931
|
+
case "StrikeOut":
|
|
932
|
+
case "Squiggly": {
|
|
933
|
+
if (quads.length === 0) return void 0;
|
|
934
|
+
const type = d.subtype === "StrikeOut" ? "strikeout" : d.subtype.toLowerCase();
|
|
935
|
+
candidate = {
|
|
936
|
+
...base(type),
|
|
937
|
+
type,
|
|
938
|
+
quads
|
|
939
|
+
};
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
case "Text": {
|
|
943
|
+
const icon = d.name === "NoIcon" ? void 0 : pdfName(d.name);
|
|
944
|
+
candidate = {
|
|
945
|
+
...base("text"),
|
|
946
|
+
type: "text",
|
|
947
|
+
...icon !== void 0 ? { icon } : {}
|
|
948
|
+
};
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case "FreeText": {
|
|
952
|
+
const da = d.defaultAppearanceData;
|
|
953
|
+
const { color: _stroke, ...rest } = base("freetext");
|
|
954
|
+
candidate = {
|
|
955
|
+
...rest,
|
|
956
|
+
...d.color && d.color.length >= 3 ? { color: pdfColorToHex(d.color, "#FFFFFF") } : {},
|
|
957
|
+
type: "freetext",
|
|
958
|
+
contents: contents ?? "",
|
|
959
|
+
width: d.borderStyle?.width ?? 0,
|
|
960
|
+
defaultStyle: {
|
|
961
|
+
color: pdfColorToHex(da?.fontColor, staticDefaults.color("freetext")),
|
|
962
|
+
...da && da.fontSize > 0 ? { fontSize: da.fontSize } : {}
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
case "Square":
|
|
968
|
+
case "Circle": {
|
|
969
|
+
const type = d.subtype.toLowerCase();
|
|
970
|
+
candidate = {
|
|
971
|
+
...base(type),
|
|
972
|
+
type,
|
|
973
|
+
width: stroke,
|
|
974
|
+
...fillOf(d.interiorColor)
|
|
975
|
+
};
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
case "Line": {
|
|
979
|
+
const l = d.lineCoordinates ?? [
|
|
980
|
+
d.rect[0],
|
|
981
|
+
d.rect[1],
|
|
982
|
+
d.rect[2],
|
|
983
|
+
d.rect[3]
|
|
984
|
+
];
|
|
985
|
+
candidate = {
|
|
986
|
+
...base("line"),
|
|
987
|
+
type: "line",
|
|
988
|
+
start: {
|
|
989
|
+
x: l[0],
|
|
990
|
+
y: l[1]
|
|
991
|
+
},
|
|
992
|
+
end: {
|
|
993
|
+
x: l[2],
|
|
994
|
+
y: l[3]
|
|
995
|
+
},
|
|
996
|
+
width: stroke,
|
|
997
|
+
...lineEndingsOf(d.lineEndings)
|
|
998
|
+
};
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
case "Polygon":
|
|
1002
|
+
case "PolyLine": {
|
|
1003
|
+
const type = d.subtype === "Polygon" ? "polygon" : "polyline";
|
|
1004
|
+
const vertices = d.vertices ? pointsFromFlat(d.vertices) : [];
|
|
1005
|
+
if (vertices.length < 2) return void 0;
|
|
1006
|
+
candidate = {
|
|
1007
|
+
...base(type),
|
|
1008
|
+
type,
|
|
1009
|
+
vertices,
|
|
1010
|
+
width: stroke,
|
|
1011
|
+
...fillOf(d.interiorColor),
|
|
1012
|
+
...type === "polyline" ? lineEndingsOf(d.lineEndings) : {}
|
|
1013
|
+
};
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
case "Ink": {
|
|
1017
|
+
const inklist = (d.inkLists ?? []).map((p) => pointsFromFlat(p)).filter((p) => p.length > 0);
|
|
1018
|
+
if (inklist.length === 0) return void 0;
|
|
1019
|
+
candidate = {
|
|
1020
|
+
...base("ink"),
|
|
1021
|
+
type: "ink",
|
|
1022
|
+
inklist,
|
|
1023
|
+
width: stroke
|
|
1024
|
+
};
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
case "Redact":
|
|
1028
|
+
candidate = {
|
|
1029
|
+
...base("redact"),
|
|
1030
|
+
type: "redact",
|
|
1031
|
+
interiorColor: pdfColorToHex(d.interiorColor, staticDefaults.fill()),
|
|
1032
|
+
...quads.length > 0 ? { quads } : {}
|
|
1033
|
+
};
|
|
1034
|
+
break;
|
|
1035
|
+
default: return;
|
|
1036
|
+
}
|
|
1037
|
+
const parsed = AnnotationSchema.safeParse(candidate);
|
|
1038
|
+
return parsed.success ? parsed.data : void 0;
|
|
1039
|
+
}
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/pdf/lexer.ts
|
|
1042
|
+
/** Thrown when the bytes are not valid PDF object syntax. */
|
|
1043
|
+
var PdfSyntaxError = class extends Error {
|
|
1044
|
+
constructor(message) {
|
|
1045
|
+
super(message);
|
|
1046
|
+
this.name = "PdfSyntaxError";
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
const isWhite$1 = (c) => c === 0 || c === 9 || c === 10 || c === 12 || c === 13 || c === 32;
|
|
1050
|
+
const isDelimiter = (c) => c === 40 || c === 41 || c === 60 || c === 62 || c === 91 || c === 93 || c === 123 || c === 125 || c === 47 || c === 37;
|
|
1051
|
+
const isRegular = (c) => !isWhite$1(c) && !isDelimiter(c);
|
|
1052
|
+
const isDigit = (c) => c >= 48 && c <= 57;
|
|
1053
|
+
const latin1$1 = (bytes, from, to) => {
|
|
1054
|
+
let out = "";
|
|
1055
|
+
for (let i = from; i < to; i++) out += String.fromCharCode(bytes[i]);
|
|
1056
|
+
return out;
|
|
1057
|
+
};
|
|
1058
|
+
function skipSpace(bytes, offset) {
|
|
1059
|
+
let i = offset;
|
|
1060
|
+
while (i < bytes.length) {
|
|
1061
|
+
const c = bytes[i];
|
|
1062
|
+
if (isWhite$1(c)) i++;
|
|
1063
|
+
else if (c === 37) while (i < bytes.length && bytes[i] !== 10 && bytes[i] !== 13) i++;
|
|
1064
|
+
else break;
|
|
1065
|
+
}
|
|
1066
|
+
return i;
|
|
1067
|
+
}
|
|
1068
|
+
function tokenEnd(bytes, offset) {
|
|
1069
|
+
let i = offset;
|
|
1070
|
+
while (i < bytes.length && isRegular(bytes[i])) i++;
|
|
1071
|
+
return i;
|
|
1072
|
+
}
|
|
1073
|
+
const hexValue = (c) => {
|
|
1074
|
+
if (c === void 0) return -1;
|
|
1075
|
+
if (c >= 48 && c <= 57) return c - 48;
|
|
1076
|
+
if (c >= 65 && c <= 70) return c - 65 + 10;
|
|
1077
|
+
if (c >= 97 && c <= 102) return c - 97 + 10;
|
|
1078
|
+
return -1;
|
|
1079
|
+
};
|
|
1080
|
+
function parseName(bytes, offset) {
|
|
1081
|
+
const end = tokenEnd(bytes, offset + 1);
|
|
1082
|
+
let name = "";
|
|
1083
|
+
for (let i = offset + 1; i < end; i++) {
|
|
1084
|
+
const c = bytes[i];
|
|
1085
|
+
const hi = c === 35 ? hexValue(bytes[i + 1]) : -1;
|
|
1086
|
+
const lo = hi >= 0 ? hexValue(bytes[i + 2]) : -1;
|
|
1087
|
+
if (hi >= 0 && lo >= 0 && i + 2 < end) {
|
|
1088
|
+
name += String.fromCharCode(hi * 16 + lo);
|
|
1089
|
+
i += 2;
|
|
1090
|
+
} else name += String.fromCharCode(c);
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
value: {
|
|
1094
|
+
kind: "name",
|
|
1095
|
+
name
|
|
1096
|
+
},
|
|
1097
|
+
end
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
function skipLiteralString(bytes, offset) {
|
|
1101
|
+
let depth = 0;
|
|
1102
|
+
for (let i = offset; i < bytes.length; i++) {
|
|
1103
|
+
const c = bytes[i];
|
|
1104
|
+
if (c === 92) i++;
|
|
1105
|
+
else if (c === 40) depth++;
|
|
1106
|
+
else if (c === 41 && --depth === 0) return i + 1;
|
|
1107
|
+
}
|
|
1108
|
+
throw new PdfSyntaxError("Unterminated string");
|
|
1109
|
+
}
|
|
1110
|
+
/** After a non-negative integer ending at `end`, looks for `gen R`. */
|
|
1111
|
+
function tryReference(bytes, num, end) {
|
|
1112
|
+
let i = end;
|
|
1113
|
+
if (i >= bytes.length || !isWhite$1(bytes[i])) return void 0;
|
|
1114
|
+
i = skipSpace(bytes, i);
|
|
1115
|
+
const genStart = i;
|
|
1116
|
+
while (i < bytes.length && isDigit(bytes[i])) i++;
|
|
1117
|
+
if (i === genStart || i >= bytes.length || !isWhite$1(bytes[i])) return void 0;
|
|
1118
|
+
const gen = Number(latin1$1(bytes, genStart, i));
|
|
1119
|
+
i = skipSpace(bytes, i);
|
|
1120
|
+
if (bytes[i] !== 82 || i + 1 < bytes.length && isRegular(bytes[i + 1])) return;
|
|
1121
|
+
return {
|
|
1122
|
+
value: {
|
|
1123
|
+
kind: "ref",
|
|
1124
|
+
num,
|
|
1125
|
+
gen
|
|
1126
|
+
},
|
|
1127
|
+
end: i + 1
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
function parseNumberOrKeyword(bytes, offset) {
|
|
1131
|
+
const end = tokenEnd(bytes, offset);
|
|
1132
|
+
const token = latin1$1(bytes, offset, end);
|
|
1133
|
+
if (token === "true") return {
|
|
1134
|
+
value: true,
|
|
1135
|
+
end
|
|
1136
|
+
};
|
|
1137
|
+
if (token === "false") return {
|
|
1138
|
+
value: false,
|
|
1139
|
+
end
|
|
1140
|
+
};
|
|
1141
|
+
if (token === "null") return {
|
|
1142
|
+
value: null,
|
|
1143
|
+
end
|
|
1144
|
+
};
|
|
1145
|
+
if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(token)) {
|
|
1146
|
+
const n = Number(token);
|
|
1147
|
+
if (/^\d+$/.test(token)) {
|
|
1148
|
+
const ref = tryReference(bytes, n, end);
|
|
1149
|
+
if (ref) return ref;
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
value: n,
|
|
1153
|
+
end
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
throw new PdfSyntaxError(`Unexpected token "${token}" at ${offset}`);
|
|
1157
|
+
}
|
|
1158
|
+
/** Parses one value at `offset`, skipping whitespace and comments first. */
|
|
1159
|
+
function parseValue(bytes, offset) {
|
|
1160
|
+
const i = skipSpace(bytes, offset);
|
|
1161
|
+
if (i >= bytes.length) throw new PdfSyntaxError("Unexpected end of data");
|
|
1162
|
+
const c = bytes[i];
|
|
1163
|
+
if (c === 47) return parseName(bytes, i);
|
|
1164
|
+
if (c === 40) return {
|
|
1165
|
+
value: { kind: "string" },
|
|
1166
|
+
end: skipLiteralString(bytes, i)
|
|
1167
|
+
};
|
|
1168
|
+
if (c === 60 && bytes[i + 1] === 60) return parseDict(bytes, i + 2);
|
|
1169
|
+
if (c === 60) {
|
|
1170
|
+
let j = i + 1;
|
|
1171
|
+
while (j < bytes.length && bytes[j] !== 62) j++;
|
|
1172
|
+
if (j >= bytes.length) throw new PdfSyntaxError("Unterminated hex string");
|
|
1173
|
+
return {
|
|
1174
|
+
value: { kind: "string" },
|
|
1175
|
+
end: j + 1
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
if (c === 91) return parseArray(bytes, i + 1);
|
|
1179
|
+
if (isRegular(c)) return parseNumberOrKeyword(bytes, i);
|
|
1180
|
+
throw new PdfSyntaxError(`Unexpected "${String.fromCharCode(c)}" at ${i}`);
|
|
1181
|
+
}
|
|
1182
|
+
function parseArray(bytes, offset) {
|
|
1183
|
+
const items = [];
|
|
1184
|
+
let i = offset;
|
|
1185
|
+
for (;;) {
|
|
1186
|
+
i = skipSpace(bytes, i);
|
|
1187
|
+
if (i >= bytes.length) throw new PdfSyntaxError("Unterminated array");
|
|
1188
|
+
if (bytes[i] === 93) return {
|
|
1189
|
+
value: items,
|
|
1190
|
+
end: i + 1
|
|
1191
|
+
};
|
|
1192
|
+
const item = parseValue(bytes, i);
|
|
1193
|
+
items.push(item.value);
|
|
1194
|
+
i = item.end;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
function parseDict(bytes, offset) {
|
|
1198
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1199
|
+
let i = offset;
|
|
1200
|
+
for (;;) {
|
|
1201
|
+
i = skipSpace(bytes, i);
|
|
1202
|
+
if (i >= bytes.length) throw new PdfSyntaxError("Unterminated dictionary");
|
|
1203
|
+
if (bytes[i] === 62 && bytes[i + 1] === 62) return {
|
|
1204
|
+
value: {
|
|
1205
|
+
kind: "dict",
|
|
1206
|
+
entries
|
|
1207
|
+
},
|
|
1208
|
+
end: i + 2
|
|
1209
|
+
};
|
|
1210
|
+
if (bytes[i] !== 47) throw new PdfSyntaxError(`Expected a name key at ${i}`);
|
|
1211
|
+
const key = parseName(bytes, i);
|
|
1212
|
+
const val = parseValue(bytes, key.end);
|
|
1213
|
+
entries.set(key.value.name, val.value);
|
|
1214
|
+
i = val.end;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
function readInteger(bytes, offset) {
|
|
1218
|
+
const i = skipSpace(bytes, offset);
|
|
1219
|
+
let end = i;
|
|
1220
|
+
while (end < bytes.length && isDigit(bytes[end])) end++;
|
|
1221
|
+
if (end === i) throw new PdfSyntaxError(`Expected an integer at ${i}`);
|
|
1222
|
+
return {
|
|
1223
|
+
n: Number(latin1$1(bytes, i, end)),
|
|
1224
|
+
end
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/** Parses `num gen obj <value> [stream]` at `offset`. `streamStart` is the first data byte after `stream` and its EOL. */
|
|
1228
|
+
function parseIndirect(bytes, offset) {
|
|
1229
|
+
const num = readInteger(bytes, offset);
|
|
1230
|
+
const gen = readInteger(bytes, num.end);
|
|
1231
|
+
const kw = skipSpace(bytes, gen.end);
|
|
1232
|
+
if (latin1$1(bytes, kw, tokenEnd(bytes, kw)) !== "obj") throw new PdfSyntaxError(`Expected "obj" at ${kw}`);
|
|
1233
|
+
const { value, end } = parseValue(bytes, kw + 3);
|
|
1234
|
+
const result = {
|
|
1235
|
+
num: num.n,
|
|
1236
|
+
gen: gen.n,
|
|
1237
|
+
value
|
|
1238
|
+
};
|
|
1239
|
+
const next = skipSpace(bytes, end);
|
|
1240
|
+
if (latin1$1(bytes, next, next + 6) === "stream" && !isRegular(bytes[next + 6] ?? 32)) {
|
|
1241
|
+
let start = next + 6;
|
|
1242
|
+
if (bytes[start] === 13 && bytes[start + 1] === 10) start += 2;
|
|
1243
|
+
else if (bytes[start] === 10 || bytes[start] === 13) start += 1;
|
|
1244
|
+
result.streamStart = start;
|
|
1245
|
+
}
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
/** Reads a dictionary entry. */
|
|
1249
|
+
const dictGet = (d, key) => d.entries.get(key);
|
|
1250
|
+
/** True for a name object, optionally one with the given spelling. */
|
|
1251
|
+
const isName = (v, name) => typeof v === "object" && v !== null && v.kind === "name" && (name === void 0 || v.name === name);
|
|
1252
|
+
/** True for a dictionary object. */
|
|
1253
|
+
const isDict = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && v.kind === "dict";
|
|
1254
|
+
/** True for an indirect reference. */
|
|
1255
|
+
const isRef = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && v.kind === "ref";
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/pdf/annotation-fields.ts
|
|
1258
|
+
/** `12R` → {num:12,gen:0}, `12R3` → {num:12,gen:3}; anything else undefined. */
|
|
1259
|
+
function refOfPdfjsId(id) {
|
|
1260
|
+
const m = /^(\d+)R(\d+)?$/.exec(id);
|
|
1261
|
+
if (!m) return void 0;
|
|
1262
|
+
return {
|
|
1263
|
+
kind: "ref",
|
|
1264
|
+
num: Number(m[1]),
|
|
1265
|
+
gen: m[2] === void 0 ? 0 : Number(m[2])
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
const FAMILIES = [
|
|
1269
|
+
[/^(helvetica|helv)/i, "Helvetica"],
|
|
1270
|
+
[/^(times|tiro)/i, "Times"],
|
|
1271
|
+
[/^cour(ier|$)/i, "Courier"]
|
|
1272
|
+
];
|
|
1273
|
+
/**
|
|
1274
|
+
* BaseFont (or bare resource name) → Inkbar font fields; undefined for Symbol and ZapfDingbats.
|
|
1275
|
+
* A family matches by prefix, so `HelveticaNeue` reads as Helvetica and `CourierNewPS` as
|
|
1276
|
+
* Courier. Any other name keeps its own family, minus a trailing `MT` or `PSMT`.
|
|
1277
|
+
* With `knownOnly`, a name outside Helvetica, Times and Courier gives undefined: a bare
|
|
1278
|
+
* resource id such as `F1` is not a family.
|
|
1279
|
+
*/
|
|
1280
|
+
function fontFromBaseName(name, knownOnly = false) {
|
|
1281
|
+
const bare = name.replace(/^[A-Z]{6}\+/, "");
|
|
1282
|
+
if (/^(symbol|zapfdingbats|zadb)/i.test(bare)) return void 0;
|
|
1283
|
+
const cut = bare.search(/[-,]/);
|
|
1284
|
+
const base = cut < 0 ? bare : bare.slice(0, cut);
|
|
1285
|
+
const style = cut < 0 ? "" : bare.slice(cut + 1);
|
|
1286
|
+
const known = FAMILIES.find(([re]) => re.test(base));
|
|
1287
|
+
if (knownOnly && !known) return void 0;
|
|
1288
|
+
const fontFamily = known ? known[1] : base.replace(/(PS)?MT$/, "");
|
|
1289
|
+
if (!fontFamily) return void 0;
|
|
1290
|
+
const font = { fontFamily };
|
|
1291
|
+
if (/bold|black|heavy/i.test(style)) font.fontWeight = "bold";
|
|
1292
|
+
if (/italic|oblique/i.test(style)) font.fontStyle = "italic";
|
|
1293
|
+
return font;
|
|
1294
|
+
}
|
|
1295
|
+
const finite = (v) => typeof v === "number" && Number.isFinite(v);
|
|
1296
|
+
async function numbers$1(objs, v, counts) {
|
|
1297
|
+
const arr = await objs.resolve(v);
|
|
1298
|
+
if (!Array.isArray(arr) || !counts.includes(arr.length)) return void 0;
|
|
1299
|
+
const out = await Promise.all(arr.map((n) => objs.resolve(n)));
|
|
1300
|
+
return out.every(finite) ? out : void 0;
|
|
1301
|
+
}
|
|
1302
|
+
async function dictOf(objs, v) {
|
|
1303
|
+
const d = await objs.resolve(v);
|
|
1304
|
+
return isDict(d) ? d : void 0;
|
|
1305
|
+
}
|
|
1306
|
+
/** The font's /BaseFont, looked up in a /DR resource dictionary. */
|
|
1307
|
+
async function baseFontIn(objs, dr, resource) {
|
|
1308
|
+
const res = await dictOf(objs, dr);
|
|
1309
|
+
const fonts = res && await dictOf(objs, dictGet(res, "Font"));
|
|
1310
|
+
const font = fonts && await dictOf(objs, dictGet(fonts, resource));
|
|
1311
|
+
const base = font && await objs.resolve(dictGet(font, "BaseFont"));
|
|
1312
|
+
return isName(base) ? base.name : void 0;
|
|
1313
|
+
}
|
|
1314
|
+
async function readFont(objs, annot, daFont) {
|
|
1315
|
+
const root = await dictOf(objs, dictGet(objs.trailer, "Root"));
|
|
1316
|
+
const acroForm = root && await dictOf(objs, dictGet(root, "AcroForm"));
|
|
1317
|
+
const sources = [dictGet(annot, "DR"), acroForm && dictGet(acroForm, "DR")];
|
|
1318
|
+
for (const dr of sources) {
|
|
1319
|
+
const base = await baseFontIn(objs, dr, daFont);
|
|
1320
|
+
if (base !== void 0) return fontFromBaseName(base);
|
|
1321
|
+
}
|
|
1322
|
+
return fontFromBaseName(daFont, true);
|
|
1323
|
+
}
|
|
1324
|
+
async function readHead(objs, le) {
|
|
1325
|
+
let v = await objs.resolve(le);
|
|
1326
|
+
if (Array.isArray(v)) v = await objs.resolve(v[0]);
|
|
1327
|
+
return isName(v) && PDF_NAME$1.test(v.name) ? v.name : void 0;
|
|
1328
|
+
}
|
|
1329
|
+
/** `rect` is the annotation's [x1 y1 x2 y2], used to reject a fringe that leaves no box. */
|
|
1330
|
+
async function readFreeTextFields(objs, ref, rect, daFont) {
|
|
1331
|
+
const fields = {};
|
|
1332
|
+
try {
|
|
1333
|
+
const annot = await objs.get(ref);
|
|
1334
|
+
if (!isDict(annot)) return fields;
|
|
1335
|
+
const cl = await numbers$1(objs, dictGet(annot, "CL"), [4, 6]);
|
|
1336
|
+
if (cl) {
|
|
1337
|
+
fields.callout = [];
|
|
1338
|
+
for (let i = 0; i < cl.length; i += 2) fields.callout.push({
|
|
1339
|
+
x: cl[i],
|
|
1340
|
+
y: cl[i + 1]
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
const rd = await numbers$1(objs, dictGet(annot, "RD"), [4]);
|
|
1344
|
+
if (rd && rect.length === 4 && rd.every((n) => n >= 0)) {
|
|
1345
|
+
const [left, top, right, bottom] = rd;
|
|
1346
|
+
const width = Math.abs(rect[2] - rect[0]);
|
|
1347
|
+
const height = Math.abs(rect[3] - rect[1]);
|
|
1348
|
+
if (left + right < width && top + bottom < height) fields.fringe = {
|
|
1349
|
+
left,
|
|
1350
|
+
top,
|
|
1351
|
+
right,
|
|
1352
|
+
bottom
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
const head = await readHead(objs, dictGet(annot, "LE"));
|
|
1356
|
+
if (head !== void 0) fields.head = head;
|
|
1357
|
+
if (daFont !== void 0) {
|
|
1358
|
+
const font = await readFont(objs, annot, daFont);
|
|
1359
|
+
if (font) fields.font = font;
|
|
1360
|
+
}
|
|
1361
|
+
} catch {}
|
|
1362
|
+
return fields;
|
|
1363
|
+
}
|
|
1364
|
+
async function readTextIcon(objs, ref) {
|
|
1365
|
+
try {
|
|
1366
|
+
const annot = await objs.get(ref);
|
|
1367
|
+
if (!isDict(annot)) return void 0;
|
|
1368
|
+
const name = await objs.resolve(dictGet(annot, "Name"));
|
|
1369
|
+
return isName(name) && PDF_NAME$1.test(name.name) ? name.name : void 0;
|
|
1370
|
+
} catch {
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
//#endregion
|
|
1375
|
+
//#region src/pdf/stream.ts
|
|
1376
|
+
const ENDSTREAM = Uint8Array.from("endstream", (c) => c.charCodeAt(0));
|
|
1377
|
+
function matchesAt(bytes, at, pattern) {
|
|
1378
|
+
if (at < 0 || at + pattern.length > bytes.length) return false;
|
|
1379
|
+
for (let i = 0; i < pattern.length; i++) if (bytes[at + i] !== pattern[i]) return false;
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
function findEndstream(bytes, from) {
|
|
1383
|
+
for (let i = from; i + ENDSTREAM.length <= bytes.length; i++) if (matchesAt(bytes, i, ENDSTREAM)) return i;
|
|
1384
|
+
return bytes.length;
|
|
1385
|
+
}
|
|
1386
|
+
/** Raw stream bytes: trusts `length` when `endstream` follows it, otherwise scans. */
|
|
1387
|
+
function rawData(bytes, start, length) {
|
|
1388
|
+
if (length !== void 0 && Number.isInteger(length) && length >= 0) {
|
|
1389
|
+
let i = start + length;
|
|
1390
|
+
while (i < bytes.length && (bytes[i] === 10 || bytes[i] === 13 || bytes[i] === 32)) i++;
|
|
1391
|
+
if (matchesAt(bytes, i, ENDSTREAM)) return bytes.subarray(start, start + length);
|
|
1392
|
+
}
|
|
1393
|
+
let end = findEndstream(bytes, start);
|
|
1394
|
+
if (end > start && bytes[end - 1] === 10) end--;
|
|
1395
|
+
if (end > start && bytes[end - 1] === 13) end--;
|
|
1396
|
+
return bytes.subarray(start, end);
|
|
1397
|
+
}
|
|
1398
|
+
/** The single filter parameter dictionary, if any. Anything else that is defined is an error. */
|
|
1399
|
+
function decodeParms(dict) {
|
|
1400
|
+
const parms = dictGet(dict, "DecodeParms") ?? dictGet(dict, "DP");
|
|
1401
|
+
const first = Array.isArray(parms) ? parms[0] : parms;
|
|
1402
|
+
if (first === void 0 || first === null) return void 0;
|
|
1403
|
+
if (!isDict(first)) throw new PdfSyntaxError("Unsupported /DecodeParms");
|
|
1404
|
+
return first;
|
|
1405
|
+
}
|
|
1406
|
+
function numberParam(parms, key, fallback) {
|
|
1407
|
+
const v = parms ? dictGet(parms, key) : void 0;
|
|
1408
|
+
return typeof v === "number" ? v : fallback;
|
|
1409
|
+
}
|
|
1410
|
+
async function inflate(data) {
|
|
1411
|
+
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate"));
|
|
1412
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
1413
|
+
}
|
|
1414
|
+
function paeth(a, b, c) {
|
|
1415
|
+
const p = a + b - c;
|
|
1416
|
+
const pa = Math.abs(p - a);
|
|
1417
|
+
const pb = Math.abs(p - b);
|
|
1418
|
+
const pc = Math.abs(p - c);
|
|
1419
|
+
if (pa <= pb && pa <= pc) return a;
|
|
1420
|
+
return pb <= pc ? b : c;
|
|
1421
|
+
}
|
|
1422
|
+
function undoPngPredictor(data, columns, colors, bits) {
|
|
1423
|
+
const bpp = Math.max(1, Math.ceil(colors * bits / 8));
|
|
1424
|
+
const rowBytes = Math.ceil(columns * colors * bits / 8);
|
|
1425
|
+
const rows = Math.floor(data.length / (rowBytes + 1));
|
|
1426
|
+
const out = new Uint8Array(rows * rowBytes);
|
|
1427
|
+
for (let r = 0; r < rows; r++) {
|
|
1428
|
+
const type = data[r * (rowBytes + 1)];
|
|
1429
|
+
const src = r * (rowBytes + 1) + 1;
|
|
1430
|
+
const dst = r * rowBytes;
|
|
1431
|
+
for (let x = 0; x < rowBytes; x++) {
|
|
1432
|
+
const raw = data[src + x];
|
|
1433
|
+
const left = x >= bpp ? out[dst + x - bpp] : 0;
|
|
1434
|
+
const up = r > 0 ? out[dst - rowBytes + x] : 0;
|
|
1435
|
+
const upLeft = r > 0 && x >= bpp ? out[dst - rowBytes + x - bpp] : 0;
|
|
1436
|
+
let value;
|
|
1437
|
+
switch (type) {
|
|
1438
|
+
case 0:
|
|
1439
|
+
value = raw;
|
|
1440
|
+
break;
|
|
1441
|
+
case 1:
|
|
1442
|
+
value = raw + left;
|
|
1443
|
+
break;
|
|
1444
|
+
case 2:
|
|
1445
|
+
value = raw + up;
|
|
1446
|
+
break;
|
|
1447
|
+
case 3:
|
|
1448
|
+
value = raw + (left + up >> 1);
|
|
1449
|
+
break;
|
|
1450
|
+
case 4:
|
|
1451
|
+
value = raw + paeth(left, up, upLeft);
|
|
1452
|
+
break;
|
|
1453
|
+
default: throw new PdfSyntaxError(`Unknown PNG filter type ${type}`);
|
|
1454
|
+
}
|
|
1455
|
+
out[dst + x] = value & 255;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return out;
|
|
1459
|
+
}
|
|
1460
|
+
/** The decoded data of the stream whose dictionary is `dict` and whose data starts at `start`. `length` is `/Length`, already resolved. */
|
|
1461
|
+
async function decodeStream(bytes, dict, start, length) {
|
|
1462
|
+
const filter = dictGet(dict, "Filter") ?? dictGet(dict, "F");
|
|
1463
|
+
const filters = Array.isArray(filter) ? filter : filter === void 0 ? [] : [filter];
|
|
1464
|
+
if (filters.length === 0) return rawData(bytes, start, length);
|
|
1465
|
+
if (filters.length > 1 || !isName(filters[0], "FlateDecode")) throw new PdfSyntaxError("Unsupported stream filter");
|
|
1466
|
+
const inflated = await inflate(rawData(bytes, start, length));
|
|
1467
|
+
const parms = decodeParms(dict);
|
|
1468
|
+
const predictor = numberParam(parms, "Predictor", 1);
|
|
1469
|
+
if (predictor === 1) return inflated;
|
|
1470
|
+
if (predictor < 10) throw new PdfSyntaxError(`Unsupported predictor ${predictor}`);
|
|
1471
|
+
return undoPngPredictor(inflated, numberParam(parms, "Columns", 1), numberParam(parms, "Colors", 1), numberParam(parms, "BitsPerComponent", 8));
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/pdf/xref.ts
|
|
1475
|
+
const isWhite = (c) => c === 0 || c === 9 || c === 10 || c === 12 || c === 13 || c === 32;
|
|
1476
|
+
const latin1 = (bytes, from, to) => {
|
|
1477
|
+
let out = "";
|
|
1478
|
+
for (let i = from; i < to; i++) out += String.fromCharCode(bytes[i]);
|
|
1479
|
+
return out;
|
|
1480
|
+
};
|
|
1481
|
+
function readToken(bytes, offset) {
|
|
1482
|
+
let i = offset;
|
|
1483
|
+
while (i < bytes.length && isWhite(bytes[i])) i++;
|
|
1484
|
+
const start = i;
|
|
1485
|
+
while (i < bytes.length && !isWhite(bytes[i])) i++;
|
|
1486
|
+
return {
|
|
1487
|
+
token: latin1(bytes, start, i),
|
|
1488
|
+
end: i
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function readInt(bytes, offset) {
|
|
1492
|
+
const { token, end } = readToken(bytes, offset);
|
|
1493
|
+
if (!/^\d+$/.test(token)) throw new Error(`Expected an integer at ${offset}`);
|
|
1494
|
+
return {
|
|
1495
|
+
n: Number(token),
|
|
1496
|
+
end
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
function findStartxref(bytes) {
|
|
1500
|
+
const tail = latin1(bytes, Math.max(0, bytes.length - 1024), bytes.length);
|
|
1501
|
+
const at = tail.lastIndexOf("startxref");
|
|
1502
|
+
if (at < 0) throw new Error("No startxref");
|
|
1503
|
+
return readInt(bytes, bytes.length - tail.length + at + 9).n;
|
|
1504
|
+
}
|
|
1505
|
+
function readTable(bytes, offset) {
|
|
1506
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1507
|
+
let i = readToken(bytes, offset).end;
|
|
1508
|
+
for (;;) {
|
|
1509
|
+
const head = readToken(bytes, i);
|
|
1510
|
+
if (head.token.startsWith("trailer")) {
|
|
1511
|
+
const trailer = parseValue(bytes, head.end - (head.token.length - 7)).value;
|
|
1512
|
+
if (!isDict(trailer)) throw new Error("Trailer is not a dictionary");
|
|
1513
|
+
return {
|
|
1514
|
+
entries,
|
|
1515
|
+
trailer
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
const first = readInt(bytes, i);
|
|
1519
|
+
const count = readInt(bytes, first.end);
|
|
1520
|
+
i = count.end;
|
|
1521
|
+
for (let k = 0; k < count.n; k++) {
|
|
1522
|
+
const off = readInt(bytes, i);
|
|
1523
|
+
const gen = readInt(bytes, off.end);
|
|
1524
|
+
const kind = readToken(bytes, gen.end);
|
|
1525
|
+
i = kind.end;
|
|
1526
|
+
if (kind.token === "n") entries.set(first.n + k, {
|
|
1527
|
+
type: "offset",
|
|
1528
|
+
offset: off.n,
|
|
1529
|
+
gen: gen.n
|
|
1530
|
+
});
|
|
1531
|
+
else if (kind.token === "f") entries.set(first.n + k, { type: "free" });
|
|
1532
|
+
else throw new Error(`Bad xref entry type "${kind.token}"`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const numbers = (v) => Array.isArray(v) && v.every((x) => typeof x === "number" && x >= 0) ? v : void 0;
|
|
1537
|
+
async function readStream(bytes, offset) {
|
|
1538
|
+
const obj = parseIndirect(bytes, offset);
|
|
1539
|
+
const dict = obj.value;
|
|
1540
|
+
if (!isDict(dict) || obj.streamStart === void 0) throw new Error("Xref stream expected");
|
|
1541
|
+
const length = dict.entries.get("Length");
|
|
1542
|
+
const data = await decodeStream(bytes, dict, obj.streamStart, typeof length === "number" ? length : void 0);
|
|
1543
|
+
const w = numbers(dict.entries.get("W"));
|
|
1544
|
+
if (!w || w.length !== 3 || w.some((x) => x > 8)) throw new Error("Bad /W");
|
|
1545
|
+
const size = dict.entries.get("Size");
|
|
1546
|
+
const index = numbers(dict.entries.get("Index")) ?? [0, typeof size === "number" ? size : 0];
|
|
1547
|
+
const [w0, w1, w2] = w;
|
|
1548
|
+
const rowLength = w0 + w1 + w2;
|
|
1549
|
+
if (rowLength === 0) throw new Error("Empty xref rows");
|
|
1550
|
+
const field = (at, width) => {
|
|
1551
|
+
let n = 0;
|
|
1552
|
+
for (let k = 0; k < width; k++) n = n * 256 + data[at + k];
|
|
1553
|
+
return n;
|
|
1554
|
+
};
|
|
1555
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1556
|
+
let row = 0;
|
|
1557
|
+
for (let s = 0; s + 1 < index.length; s += 2) for (let k = 0; k < index[s + 1]; k++, row++) {
|
|
1558
|
+
const at = row * rowLength;
|
|
1559
|
+
if (at + rowLength > data.length) return {
|
|
1560
|
+
entries,
|
|
1561
|
+
trailer: dict
|
|
1562
|
+
};
|
|
1563
|
+
const type = w0 === 0 ? 1 : field(at, w0);
|
|
1564
|
+
const a = field(at + w0, w1);
|
|
1565
|
+
const b = field(at + w0 + w1, w2);
|
|
1566
|
+
const num = index[s] + k;
|
|
1567
|
+
if (type === 0) entries.set(num, { type: "free" });
|
|
1568
|
+
else if (type === 1) entries.set(num, {
|
|
1569
|
+
type: "offset",
|
|
1570
|
+
offset: a,
|
|
1571
|
+
gen: b
|
|
1572
|
+
});
|
|
1573
|
+
else if (type === 2) entries.set(num, {
|
|
1574
|
+
type: "compressed",
|
|
1575
|
+
stream: a,
|
|
1576
|
+
index: b
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
entries,
|
|
1581
|
+
trailer: dict
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
async function readSection(bytes, offset) {
|
|
1585
|
+
if (!Number.isInteger(offset) || offset < 0 || offset >= bytes.length) throw new Error("Xref offset out of range");
|
|
1586
|
+
return readToken(bytes, offset).token === "xref" ? readTable(bytes, offset) : readStream(bytes, offset);
|
|
1587
|
+
}
|
|
1588
|
+
/** Newest entry wins along the /Prev chain (and a hybrid file's /XRefStm). Undefined when startxref or a section cannot be read. */
|
|
1589
|
+
async function readXref(bytes) {
|
|
1590
|
+
try {
|
|
1591
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1592
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1593
|
+
let trailer;
|
|
1594
|
+
let next = findStartxref(bytes);
|
|
1595
|
+
while (next !== void 0 && !visited.has(next)) {
|
|
1596
|
+
visited.add(next);
|
|
1597
|
+
const section = await readSection(bytes, next);
|
|
1598
|
+
trailer ??= section.trailer;
|
|
1599
|
+
const hybrid = section.trailer.entries.get("XRefStm");
|
|
1600
|
+
if (typeof hybrid === "number" && !visited.has(hybrid)) {
|
|
1601
|
+
visited.add(hybrid);
|
|
1602
|
+
const extra = await readSection(bytes, hybrid);
|
|
1603
|
+
for (const [num, e] of extra.entries) if (!entries.has(num)) entries.set(num, e);
|
|
1604
|
+
}
|
|
1605
|
+
for (const [num, e] of section.entries) if (!entries.has(num)) entries.set(num, e);
|
|
1606
|
+
const prev = section.trailer.entries.get("Prev");
|
|
1607
|
+
next = typeof prev === "number" ? prev : void 0;
|
|
1608
|
+
}
|
|
1609
|
+
return trailer ? {
|
|
1610
|
+
entries,
|
|
1611
|
+
trailer
|
|
1612
|
+
} : void 0;
|
|
1613
|
+
} catch {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/pdf/objects.ts
|
|
1619
|
+
var Reader = class {
|
|
1620
|
+
bytes;
|
|
1621
|
+
xref;
|
|
1622
|
+
trailer;
|
|
1623
|
+
objectStreams = /* @__PURE__ */ new Map();
|
|
1624
|
+
constructor(bytes, xref) {
|
|
1625
|
+
this.bytes = bytes;
|
|
1626
|
+
this.xref = xref;
|
|
1627
|
+
this.trailer = xref.trailer;
|
|
1628
|
+
}
|
|
1629
|
+
async get(ref) {
|
|
1630
|
+
try {
|
|
1631
|
+
return await this.load(ref, true);
|
|
1632
|
+
} catch {
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
async resolve(v) {
|
|
1637
|
+
return isRef(v) ? this.get(v) : v;
|
|
1638
|
+
}
|
|
1639
|
+
/** `followLength` is false while resolving a /Length, so a self-referencing length cannot recurse. */
|
|
1640
|
+
async load(ref, followLength) {
|
|
1641
|
+
const entry = this.xref.entries.get(ref.num);
|
|
1642
|
+
if (!entry || entry.type === "free") return void 0;
|
|
1643
|
+
if (entry.type === "offset") {
|
|
1644
|
+
if (entry.gen !== ref.gen) return void 0;
|
|
1645
|
+
const obj = parseIndirect(this.bytes, entry.offset);
|
|
1646
|
+
return obj.num === ref.num && obj.gen === ref.gen ? obj.value : void 0;
|
|
1647
|
+
}
|
|
1648
|
+
if (ref.gen !== 0) return void 0;
|
|
1649
|
+
const container = await this.objectStream(entry.stream, followLength);
|
|
1650
|
+
if (!container) return void 0;
|
|
1651
|
+
let pair = container.pairs[entry.index];
|
|
1652
|
+
if (pair?.num !== ref.num) pair = container.pairs.find((p) => p.num === ref.num);
|
|
1653
|
+
if (!pair) return void 0;
|
|
1654
|
+
return parseValue(container.decoded, container.first + pair.offset).value;
|
|
1655
|
+
}
|
|
1656
|
+
objectStream(num, followLength) {
|
|
1657
|
+
let cached = this.objectStreams.get(num);
|
|
1658
|
+
if (!cached) {
|
|
1659
|
+
cached = this.readObjectStream(num, followLength).catch(() => void 0);
|
|
1660
|
+
this.objectStreams.set(num, cached);
|
|
1661
|
+
}
|
|
1662
|
+
return cached;
|
|
1663
|
+
}
|
|
1664
|
+
async readObjectStream(num, followLength) {
|
|
1665
|
+
const entry = this.xref.entries.get(num);
|
|
1666
|
+
if (entry?.type !== "offset") return void 0;
|
|
1667
|
+
const obj = parseIndirect(this.bytes, entry.offset);
|
|
1668
|
+
const dict = obj.value;
|
|
1669
|
+
if (obj.num !== num || !isDict(dict) || obj.streamStart === void 0) return void 0;
|
|
1670
|
+
const n = dict.entries.get("N");
|
|
1671
|
+
const first = dict.entries.get("First");
|
|
1672
|
+
if (typeof n !== "number" || typeof first !== "number") return void 0;
|
|
1673
|
+
let length = dict.entries.get("Length");
|
|
1674
|
+
if (isRef(length)) length = followLength ? await this.loadLength(length) : void 0;
|
|
1675
|
+
const decoded = await decodeStream(this.bytes, dict, obj.streamStart, typeof length === "number" ? length : void 0);
|
|
1676
|
+
const pairs = [];
|
|
1677
|
+
let at = 0;
|
|
1678
|
+
for (let i = 0; i < n; i++) {
|
|
1679
|
+
const a = parseValue(decoded, at);
|
|
1680
|
+
const b = parseValue(decoded, a.end);
|
|
1681
|
+
if (typeof a.value !== "number" || typeof b.value !== "number") return void 0;
|
|
1682
|
+
pairs.push({
|
|
1683
|
+
num: a.value,
|
|
1684
|
+
offset: b.value
|
|
1685
|
+
});
|
|
1686
|
+
at = b.end;
|
|
1687
|
+
}
|
|
1688
|
+
return {
|
|
1689
|
+
decoded,
|
|
1690
|
+
first,
|
|
1691
|
+
pairs
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
async loadLength(ref) {
|
|
1695
|
+
try {
|
|
1696
|
+
return await this.load(ref, false);
|
|
1697
|
+
} catch {
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
/** Undefined when the file is encrypted (`/Encrypt` in the trailer) or its xref cannot be read. */
|
|
1703
|
+
async function openPdfObjects(bytes) {
|
|
1704
|
+
try {
|
|
1705
|
+
const xref = await readXref(bytes);
|
|
1706
|
+
if (!xref || xref.trailer.entries.has("Encrypt")) return void 0;
|
|
1707
|
+
return new Reader(bytes, xref);
|
|
1708
|
+
} catch {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1713
|
+
//#region src/model/annotations/embedded-fields.ts
|
|
1714
|
+
/**
|
|
1715
|
+
* `a` with `fields` merged in: `callout`, `fringe`, `head` and the font keys of `defaultStyle`
|
|
1716
|
+
* on a FreeText, `icon` on a note. Undefined when `a` is not embedded, the fields do not fit its
|
|
1717
|
+
* type, or the result fails the schema.
|
|
1718
|
+
*/
|
|
1719
|
+
function withEmbeddedFields(a, fields) {
|
|
1720
|
+
if (a.source !== "pdf") return void 0;
|
|
1721
|
+
const { callout, fringe, head, icon, font } = fields;
|
|
1722
|
+
let candidate;
|
|
1723
|
+
if (a.type === "text") {
|
|
1724
|
+
if (icon === void 0 || callout || fringe || head !== void 0 || font) return void 0;
|
|
1725
|
+
candidate = {
|
|
1726
|
+
...a,
|
|
1727
|
+
icon
|
|
1728
|
+
};
|
|
1729
|
+
} else if (a.type === "freetext") {
|
|
1730
|
+
if (icon !== void 0) return void 0;
|
|
1731
|
+
candidate = {
|
|
1732
|
+
...a,
|
|
1733
|
+
...callout ? { callout } : {},
|
|
1734
|
+
...fringe ? { fringe } : {},
|
|
1735
|
+
...head !== void 0 ? { head } : {},
|
|
1736
|
+
defaultStyle: {
|
|
1737
|
+
...a.defaultStyle,
|
|
1738
|
+
...font
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
} else return;
|
|
1742
|
+
const parsed = AnnotationSchema.safeParse(candidate);
|
|
1743
|
+
return parsed.success ? parsed.data : void 0;
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Reads every target once, calling `getData` only if a target names an annotation in
|
|
1747
|
+
* `annotations` by an object reference, and returns the fields read, by id. A target the file
|
|
1748
|
+
* adds nothing to, or whose fields would fail the schema, is left out. Never throws: any
|
|
1749
|
+
* failure leaves the annotations as they are.
|
|
1750
|
+
*/
|
|
1751
|
+
async function readEmbeddedFields(annotations, targets, getData) {
|
|
1752
|
+
const read = /* @__PURE__ */ new Map();
|
|
1753
|
+
if (targets.length === 0) return read;
|
|
1754
|
+
try {
|
|
1755
|
+
const byId = new Map(annotations.map((a) => [a.id, a]));
|
|
1756
|
+
const work = targets.flatMap((target) => {
|
|
1757
|
+
const current = byId.get(target.id);
|
|
1758
|
+
const ref = refOfPdfjsId(target.id.split(":")[2] ?? "");
|
|
1759
|
+
return current && ref ? [{
|
|
1760
|
+
target,
|
|
1761
|
+
current,
|
|
1762
|
+
ref
|
|
1763
|
+
}] : [];
|
|
1764
|
+
});
|
|
1765
|
+
if (work.length === 0) return read;
|
|
1766
|
+
const objs = await openPdfObjects(await getData());
|
|
1767
|
+
if (!objs) return read;
|
|
1768
|
+
for (const { target, current, ref } of work) try {
|
|
1769
|
+
const fields = await readFields(objs, target, ref);
|
|
1770
|
+
if (fields && withEmbeddedFields(current, fields)) read.set(current.id, fields);
|
|
1771
|
+
} catch {}
|
|
1772
|
+
} catch {}
|
|
1773
|
+
return read;
|
|
1774
|
+
}
|
|
1775
|
+
async function readFields(objs, target, ref) {
|
|
1776
|
+
if (target.subtype === "Text") {
|
|
1777
|
+
const icon = await readTextIcon(objs, ref);
|
|
1778
|
+
return icon === void 0 ? void 0 : { icon };
|
|
1779
|
+
}
|
|
1780
|
+
const fields = await readFreeTextFields(objs, ref, target.rect, target.daFont);
|
|
1781
|
+
return Object.keys(fields).length > 0 ? fields : void 0;
|
|
1782
|
+
}
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/model/env.ts
|
|
1785
|
+
/** True only when the build or runtime says production; browsers without `process` count as dev. */
|
|
1786
|
+
function isProduction() {
|
|
1787
|
+
try {
|
|
1788
|
+
return process.env.NODE_ENV === "production";
|
|
1789
|
+
} catch {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
//#endregion
|
|
1794
|
+
//#region src/model/annotations/history.ts
|
|
1795
|
+
/**
|
|
1796
|
+
* Snapshot-based undo and redo over an uncontrolled store.
|
|
1797
|
+
*
|
|
1798
|
+
* In controlled mode the host owns the list, so history is the host's concern (the Vue layer
|
|
1799
|
+
* offers `useHistory`). There `createHistory` returns an inert History: `canUndo` and `canRedo`
|
|
1800
|
+
* stay false, `undo` and `redo` do nothing, and nothing is recorded.
|
|
1801
|
+
*/
|
|
1802
|
+
function createHistory(store, opts = {}) {
|
|
1803
|
+
const canUndo = writable(false);
|
|
1804
|
+
const canRedo = writable(false);
|
|
1805
|
+
if (store.mode === "controlled") return {
|
|
1806
|
+
canUndo: readonly(canUndo),
|
|
1807
|
+
canRedo: readonly(canRedo),
|
|
1808
|
+
undo() {},
|
|
1809
|
+
redo() {},
|
|
1810
|
+
clear() {},
|
|
1811
|
+
batch(fn) {
|
|
1812
|
+
fn();
|
|
1813
|
+
},
|
|
1814
|
+
destroy() {}
|
|
1815
|
+
};
|
|
1816
|
+
const limit = opts.limit ?? 200;
|
|
1817
|
+
const undoStack = [];
|
|
1818
|
+
const redoStack = [];
|
|
1819
|
+
let previous = store.list.get();
|
|
1820
|
+
let replaying = false;
|
|
1821
|
+
/** Open batch depth; while above zero, changes update `previous` but push no entry. */
|
|
1822
|
+
let depth = 0;
|
|
1823
|
+
/** The list when the outermost batch opened (or was restarted by a replace). */
|
|
1824
|
+
let batchStart = null;
|
|
1825
|
+
const sync = () => {
|
|
1826
|
+
canUndo.set(undoStack.length > 0);
|
|
1827
|
+
canRedo.set(redoStack.length > 0);
|
|
1828
|
+
};
|
|
1829
|
+
let destroyed = false;
|
|
1830
|
+
const unsubscribe = store.on("change", (change) => {
|
|
1831
|
+
const current = store.list.get();
|
|
1832
|
+
if (replaying || change.kind === "replace" && change.reason === "history") {
|
|
1833
|
+
previous = current;
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
if (change.kind === "replace" && change.reason === "embedded") {
|
|
1837
|
+
const fill = (list) => list.map((a) => {
|
|
1838
|
+
const f = change.fields.get(a.id);
|
|
1839
|
+
return f && withEmbeddedFields(a, f) || a;
|
|
1840
|
+
});
|
|
1841
|
+
for (const entry of [...undoStack, ...redoStack]) {
|
|
1842
|
+
entry.before = fill(entry.before);
|
|
1843
|
+
entry.after = fill(entry.after);
|
|
1844
|
+
}
|
|
1845
|
+
if (batchStart) batchStart = fill(batchStart);
|
|
1846
|
+
previous = current;
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (change.kind === "replace") {
|
|
1850
|
+
undoStack.length = 0;
|
|
1851
|
+
redoStack.length = 0;
|
|
1852
|
+
previous = current;
|
|
1853
|
+
if (depth > 0) batchStart = current;
|
|
1854
|
+
sync();
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (depth > 0) {
|
|
1858
|
+
previous = current;
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
undoStack.push({
|
|
1862
|
+
before: previous,
|
|
1863
|
+
after: current
|
|
1864
|
+
});
|
|
1865
|
+
if (undoStack.length > limit) undoStack.shift();
|
|
1866
|
+
redoStack.length = 0;
|
|
1867
|
+
previous = current;
|
|
1868
|
+
sync();
|
|
1869
|
+
});
|
|
1870
|
+
const apply = (list) => {
|
|
1871
|
+
replaying = true;
|
|
1872
|
+
try {
|
|
1873
|
+
store.replaceAll([...list], "history");
|
|
1874
|
+
} finally {
|
|
1875
|
+
replaying = false;
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
return {
|
|
1879
|
+
canUndo: readonly(canUndo),
|
|
1880
|
+
canRedo: readonly(canRedo),
|
|
1881
|
+
undo() {
|
|
1882
|
+
const entry = undoStack.pop();
|
|
1883
|
+
if (destroyed || !entry) return;
|
|
1884
|
+
redoStack.push(entry);
|
|
1885
|
+
apply(entry.before);
|
|
1886
|
+
sync();
|
|
1887
|
+
},
|
|
1888
|
+
redo() {
|
|
1889
|
+
const entry = redoStack.pop();
|
|
1890
|
+
if (destroyed || !entry) return;
|
|
1891
|
+
undoStack.push(entry);
|
|
1892
|
+
apply(entry.after);
|
|
1893
|
+
sync();
|
|
1894
|
+
},
|
|
1895
|
+
batch(fn) {
|
|
1896
|
+
if (destroyed) {
|
|
1897
|
+
fn();
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (depth++ === 0) batchStart = store.list.get();
|
|
1901
|
+
try {
|
|
1902
|
+
const result = fn();
|
|
1903
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function" && !isProduction()) console.warn("[inkbar] history.batch(fn): fn returned a promise. Changes after its first await are not part of the batch.");
|
|
1904
|
+
} finally {
|
|
1905
|
+
if (--depth === 0 && !destroyed) {
|
|
1906
|
+
const before = batchStart;
|
|
1907
|
+
batchStart = null;
|
|
1908
|
+
const after = store.list.get();
|
|
1909
|
+
if (before && !sameAnnotations(before, after)) {
|
|
1910
|
+
undoStack.push({
|
|
1911
|
+
before,
|
|
1912
|
+
after
|
|
1913
|
+
});
|
|
1914
|
+
if (undoStack.length > limit) undoStack.shift();
|
|
1915
|
+
redoStack.length = 0;
|
|
1916
|
+
sync();
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
},
|
|
1921
|
+
destroy() {
|
|
1922
|
+
destroyed = true;
|
|
1923
|
+
batchStart = null;
|
|
1924
|
+
unsubscribe();
|
|
1925
|
+
undoStack.length = 0;
|
|
1926
|
+
redoStack.length = 0;
|
|
1927
|
+
sync();
|
|
1928
|
+
},
|
|
1929
|
+
clear() {
|
|
1930
|
+
undoStack.length = 0;
|
|
1931
|
+
redoStack.length = 0;
|
|
1932
|
+
previous = store.list.get();
|
|
1933
|
+
sync();
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
/** The same annotation objects in the same order. */
|
|
1938
|
+
function sameAnnotations(a, b) {
|
|
1939
|
+
return a === b || a.length === b.length && a.every((x, i) => x === b[i]);
|
|
1940
|
+
}
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/model/annotations/gloss-mapping.ts
|
|
1943
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1944
|
+
/** `rest` with `own` merged into a copy of its `custom.inkbar`; `own` wins. */
|
|
1945
|
+
function withCustom(rest, own) {
|
|
1946
|
+
const custom = isObject(rest.custom) ? rest.custom : {};
|
|
1947
|
+
const inkbar = isObject(custom.inkbar) ? custom.inkbar : {};
|
|
1948
|
+
return {
|
|
1949
|
+
...rest,
|
|
1950
|
+
custom: {
|
|
1951
|
+
...custom,
|
|
1952
|
+
inkbar: {
|
|
1953
|
+
...inkbar,
|
|
1954
|
+
...own
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* An annotation as a Gloss file writes it. Gloss has no loupe or mask and no pen pressure or
|
|
1961
|
+
* free-text border colour, so these go under `custom.inkbar`, with loupes and masks written as
|
|
1962
|
+
* the circle or square they draw.
|
|
1963
|
+
*/
|
|
1964
|
+
function toFile(annotation) {
|
|
1965
|
+
switch (annotation.type) {
|
|
1966
|
+
case "ink": {
|
|
1967
|
+
const { pressure, ...rest } = annotation;
|
|
1968
|
+
return pressure ? withCustom(rest, { pressure }) : rest;
|
|
1969
|
+
}
|
|
1970
|
+
case "freetext": {
|
|
1971
|
+
const { borderColor, ...rest } = annotation;
|
|
1972
|
+
return borderColor ? withCustom(rest, { borderColor }) : rest;
|
|
1973
|
+
}
|
|
1974
|
+
case "loupe": {
|
|
1975
|
+
const { magnification, ...rest } = annotation;
|
|
1976
|
+
return withCustom({
|
|
1977
|
+
...rest,
|
|
1978
|
+
type: "circle"
|
|
1979
|
+
}, {
|
|
1980
|
+
type: "loupe",
|
|
1981
|
+
magnification
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
case "mask": {
|
|
1985
|
+
const { shape, dim, ...rest } = annotation;
|
|
1986
|
+
return withCustom({
|
|
1987
|
+
...rest,
|
|
1988
|
+
type: shape === "ellipse" ? "circle" : "square"
|
|
1989
|
+
}, {
|
|
1990
|
+
type: "mask",
|
|
1991
|
+
dim
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
default: return annotation;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Moves `keys` from `custom.inkbar` to the top level of `raw`, replacing any top-level value
|
|
1999
|
+
* (an absent key deletes it), then drops `custom.inkbar`, and `custom`, if that leaves them
|
|
2000
|
+
* empty. Everything else under `custom` stays as it was.
|
|
2001
|
+
*/
|
|
2002
|
+
function lift(raw, inkbar, keys, extra = {}) {
|
|
2003
|
+
const item = {
|
|
2004
|
+
...raw,
|
|
2005
|
+
...extra
|
|
2006
|
+
};
|
|
2007
|
+
const left = { ...inkbar };
|
|
2008
|
+
let consumed = false;
|
|
2009
|
+
for (const key of keys) {
|
|
2010
|
+
delete item[key];
|
|
2011
|
+
if (key in left) {
|
|
2012
|
+
item[key] = left[key];
|
|
2013
|
+
delete left[key];
|
|
2014
|
+
consumed = true;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
if (!consumed || !isObject(raw.custom)) return item;
|
|
2018
|
+
const { inkbar: _inkbar, ...others } = raw.custom;
|
|
2019
|
+
const custom = Object.keys(left).length > 0 ? {
|
|
2020
|
+
...others,
|
|
2021
|
+
inkbar: left
|
|
2022
|
+
} : others;
|
|
2023
|
+
if (Object.keys(custom).length > 0) item.custom = custom;
|
|
2024
|
+
else delete item.custom;
|
|
2025
|
+
return item;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* A raw file item as Inkbar parses it: Inkbar's own data moves out of `custom.inkbar`. An item
|
|
2029
|
+
* it doesn't recognise passes through unchanged.
|
|
2030
|
+
*/
|
|
2031
|
+
function fromFile(raw) {
|
|
2032
|
+
const inkbar = isObject(raw.custom) && isObject(raw.custom.inkbar) ? raw.custom.inkbar : {};
|
|
2033
|
+
const from = (keys, extra) => ({
|
|
2034
|
+
item: lift(raw, inkbar, keys, extra),
|
|
2035
|
+
lifted: keys
|
|
2036
|
+
});
|
|
2037
|
+
const unchanged = {
|
|
2038
|
+
item: raw,
|
|
2039
|
+
lifted: []
|
|
2040
|
+
};
|
|
2041
|
+
switch (raw.type) {
|
|
2042
|
+
case "ink": return from(["pressure"]);
|
|
2043
|
+
case "freetext": return from(["borderColor"]);
|
|
2044
|
+
case "circle":
|
|
2045
|
+
if (inkbar.type === "loupe") return from(["type", "magnification"]);
|
|
2046
|
+
if (inkbar.type === "mask") return from(["type", "dim"], { shape: "ellipse" });
|
|
2047
|
+
return unchanged;
|
|
2048
|
+
case "square":
|
|
2049
|
+
if (inkbar.type === "mask") return from(["type", "dim"], { shape: "rect" });
|
|
2050
|
+
return unchanged;
|
|
2051
|
+
default: return unchanged;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region src/model/annotations/json.ts
|
|
2056
|
+
/**
|
|
2057
|
+
* @internal The Gloss types this core parses, taken from its own schemas rather than Gloss's
|
|
2058
|
+
* list, so a type a later Gloss adds is still treated as foreign here. Loupes and masks are
|
|
2059
|
+
* Inkbar's own types: a file writes them as circles and squares, so a `loupe` or `mask` item is
|
|
2060
|
+
* foreign too.
|
|
2061
|
+
*/
|
|
2062
|
+
const KNOWN_TYPES = new Set(ExportedAnnotationSchema.options.flatMap((o) => [...o.shape.type._zod.values]).filter((type) => type !== "loupe" && type !== "mask"));
|
|
2063
|
+
/**
|
|
2064
|
+
* Session annotations plus any preserved foreign items. Embedded (`source: 'pdf'`) ones stay in
|
|
2065
|
+
* the file. `source` and `locked` are viewer state and are left out.
|
|
2066
|
+
*
|
|
2067
|
+
* The result is a JSON copy (`JSON.parse(JSON.stringify(...))`), the same data a serialised
|
|
2068
|
+
* export would carry: editing it never touches the store, and values JSON cannot hold, such as
|
|
2069
|
+
* functions or `undefined` inside `custom`, are left out exactly as `JSON.stringify` would.
|
|
2070
|
+
*
|
|
2071
|
+
* Gloss has no loupe or mask, pen pressure or free-text border colour, so Inkbar writes these
|
|
2072
|
+
* under `custom.inkbar`, with loupes and masks as the circle or square they draw. Every other key
|
|
2073
|
+
* under `custom` goes out as it is.
|
|
2074
|
+
*
|
|
2075
|
+
* The result validates against the Gloss schema (`gloss-format/schema.json`). Foreign items
|
|
2076
|
+
* validate as unknown annotations while their type is one Gloss does not define.
|
|
2077
|
+
*/
|
|
2078
|
+
function exportAnnotations(store, preserved = [], documentKey) {
|
|
2079
|
+
const session = store.list.get().filter((a) => a.source === "session").map(({ source: _source, locked: _locked, ...rest }) => toFile(rest));
|
|
2080
|
+
return {
|
|
2081
|
+
format: GLOSS_FORMAT$1,
|
|
2082
|
+
version: GLOSS_VERSION$1,
|
|
2083
|
+
...documentKey === void 0 ? {} : { documentKey },
|
|
2084
|
+
annotations: JSON.parse(JSON.stringify([...session, ...preserved]))
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Validates the envelope (throws a TypeError when it is not a gloss v1 document),
|
|
2089
|
+
* then each item on its own. Embedded annotations are never touched; imported items become
|
|
2090
|
+
* session annotations.
|
|
2091
|
+
*/
|
|
2092
|
+
function importAnnotations(store, data, opts) {
|
|
2093
|
+
const plan = planImport(store, data, opts);
|
|
2094
|
+
plan.apply();
|
|
2095
|
+
return plan.result;
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* @internal Validation half of `importAnnotations`, so the document can record foreign items
|
|
2099
|
+
* before the store publishes the change.
|
|
2100
|
+
*
|
|
2101
|
+
* Ids are unique across the payload: the first item with an id claims it, and later ones (known
|
|
2102
|
+
* or foreign) are reported in `errors`, even when the first was itself rejected. An id that
|
|
2103
|
+
* matches an embedded annotation is rejected for known and foreign items alike. In `merge` mode an incoming item replaces whatever session
|
|
2104
|
+
* annotation has its id, whether the incoming item is known or foreign.
|
|
2105
|
+
*/
|
|
2106
|
+
function planImport(store, data, opts) {
|
|
2107
|
+
const raw = typeof data === "string" ? JSON.parse(data) : data;
|
|
2108
|
+
const envelope = AnnotationDocumentSchema.safeParse(raw);
|
|
2109
|
+
if (!envelope.success) throw new TypeError(`Expected a ${GLOSS_FORMAT$1} v${GLOSS_VERSION$1} document: ${envelope.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
2110
|
+
const embeddedIds = new Set(store.list.get().filter((a) => a.source === "pdf").map((a) => a.id));
|
|
2111
|
+
const result = {
|
|
2112
|
+
imported: 0,
|
|
2113
|
+
unknown: [],
|
|
2114
|
+
errors: []
|
|
2115
|
+
};
|
|
2116
|
+
const incoming = [];
|
|
2117
|
+
/** id -> index of the first item that used it, whether that item was accepted or not. */
|
|
2118
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2119
|
+
/** Ids the import actually brings in. */
|
|
2120
|
+
const accepted = /* @__PURE__ */ new Set();
|
|
2121
|
+
/**
|
|
2122
|
+
* Reserves `id` for the item at `index`. Later items with the same id are duplicates even
|
|
2123
|
+
* when this one is rejected for reusing an embedded id.
|
|
2124
|
+
*/
|
|
2125
|
+
const claim = (id, index) => {
|
|
2126
|
+
const first = seen.get(id);
|
|
2127
|
+
if (first !== void 0) {
|
|
2128
|
+
result.errors.push({
|
|
2129
|
+
index,
|
|
2130
|
+
issues: [`id: duplicate id "${id}", already used by item ${first}`]
|
|
2131
|
+
});
|
|
2132
|
+
return false;
|
|
2133
|
+
}
|
|
2134
|
+
seen.set(id, index);
|
|
2135
|
+
if (embeddedIds.has(id)) {
|
|
2136
|
+
result.errors.push({
|
|
2137
|
+
index,
|
|
2138
|
+
issues: ["id: matches an annotation embedded in the PDF, which import never replaces"]
|
|
2139
|
+
});
|
|
2140
|
+
return false;
|
|
2141
|
+
}
|
|
2142
|
+
accepted.add(id);
|
|
2143
|
+
return true;
|
|
2144
|
+
};
|
|
2145
|
+
envelope.data.annotations.forEach((raw, index) => {
|
|
2146
|
+
const type = raw?.type;
|
|
2147
|
+
if (typeof type === "string" && !KNOWN_TYPES.has(type)) {
|
|
2148
|
+
if (!isForeign(raw)) result.errors.push({
|
|
2149
|
+
index,
|
|
2150
|
+
issues: [`type: unknown type "${type}" needs a string id and a numeric page to be preserved`]
|
|
2151
|
+
});
|
|
2152
|
+
else if (claim(raw.id, index)) result.unknown.push(raw);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const { item, lifted } = isRecord(raw) && typeof type === "string" ? fromFile(raw) : {
|
|
2156
|
+
item: raw,
|
|
2157
|
+
lifted: []
|
|
2158
|
+
};
|
|
2159
|
+
const parsed = ExportedAnnotationSchema.safeParse(item);
|
|
2160
|
+
if (!parsed.success) {
|
|
2161
|
+
result.errors.push({
|
|
2162
|
+
index,
|
|
2163
|
+
issues: parsed.error.issues.map((i) => {
|
|
2164
|
+
const path = i.path.join(".");
|
|
2165
|
+
return `${typeof i.path[0] === "string" && lifted.includes(i.path[0]) ? `custom.inkbar.${path}` : path}: ${i.message}`;
|
|
2166
|
+
})
|
|
2167
|
+
});
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (!claim(parsed.data.id, index)) return;
|
|
2171
|
+
incoming.push({
|
|
2172
|
+
...parsed.data,
|
|
2173
|
+
source: "session"
|
|
2174
|
+
});
|
|
2175
|
+
result.imported++;
|
|
2176
|
+
});
|
|
2177
|
+
if (result.unknown.length > 0 && !isProduction()) {
|
|
2178
|
+
const types = [...new Set(result.unknown.map((u) => u.type))];
|
|
2179
|
+
console.warn(`[inkbar] importAnnotations kept ${result.unknown.length} annotation(s) of unknown type (${types.join(", ")}). They are not shown, and exportAnnotations writes them back out.`);
|
|
2180
|
+
}
|
|
2181
|
+
const ids = accepted;
|
|
2182
|
+
return {
|
|
2183
|
+
result,
|
|
2184
|
+
ids,
|
|
2185
|
+
apply() {
|
|
2186
|
+
const current = store.list.get();
|
|
2187
|
+
const embedded = current.filter((a) => a.source === "pdf");
|
|
2188
|
+
if (opts.mode === "replace") {
|
|
2189
|
+
store.replaceAll([...embedded, ...incoming], "import");
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const byId = new Map(current.filter((a) => a.source === "session" && !ids.has(a.id)).map((a) => [a.id, a]));
|
|
2193
|
+
for (const a of incoming) byId.set(a.id, a);
|
|
2194
|
+
store.replaceAll([...embedded, ...byId.values()], "import");
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
function isRecord(item) {
|
|
2199
|
+
return typeof item === "object" && item !== null && !Array.isArray(item);
|
|
2200
|
+
}
|
|
2201
|
+
function isForeign(item) {
|
|
2202
|
+
if (!isRecord(item)) return false;
|
|
2203
|
+
const { type, id, page } = item;
|
|
2204
|
+
return typeof type === "string" && typeof id === "string" && typeof page === "number";
|
|
2205
|
+
}
|
|
2206
|
+
//#endregion
|
|
2207
|
+
//#region src/model/annotations/store.ts
|
|
2208
|
+
/** Keys a locked annotation refuses to change. */
|
|
2209
|
+
const LOCKED_KEYS = /* @__PURE__ */ new Set([
|
|
2210
|
+
"page",
|
|
2211
|
+
"rect",
|
|
2212
|
+
"quads",
|
|
2213
|
+
"inklist",
|
|
2214
|
+
"start",
|
|
2215
|
+
"end",
|
|
2216
|
+
"vertices",
|
|
2217
|
+
"callout",
|
|
2218
|
+
"contents",
|
|
2219
|
+
"imagedata",
|
|
2220
|
+
"rotation"
|
|
2221
|
+
]);
|
|
2222
|
+
function keepKey(target, from, key) {
|
|
2223
|
+
if (Object.prototype.hasOwnProperty.call(from, key)) target[key] = from[key];
|
|
2224
|
+
else delete target[key];
|
|
2225
|
+
}
|
|
2226
|
+
/** `proposed` as a replacement for embedded `current`, with what the file owns kept from `current`. */
|
|
2227
|
+
function guardEmbedded(current, proposed) {
|
|
2228
|
+
if (proposed === current) return current;
|
|
2229
|
+
if (proposed.type !== current.type) return current;
|
|
2230
|
+
const candidate = {
|
|
2231
|
+
...proposed,
|
|
2232
|
+
id: current.id,
|
|
2233
|
+
createdAt: current.createdAt,
|
|
2234
|
+
source: "pdf"
|
|
2235
|
+
};
|
|
2236
|
+
keepKey(candidate, current, "locked");
|
|
2237
|
+
if (current.locked) for (const key of LOCKED_KEYS) keepKey(candidate, current, key);
|
|
2238
|
+
const parsed = AnnotationSchema.safeParse(candidate);
|
|
2239
|
+
return parsed.success ? parsed.data : current;
|
|
2240
|
+
}
|
|
2241
|
+
function deriveRect(input) {
|
|
2242
|
+
switch (input.type) {
|
|
2243
|
+
case "highlight":
|
|
2244
|
+
case "underline":
|
|
2245
|
+
case "strikeout":
|
|
2246
|
+
case "squiggly": return unionRects(input.quads.map(rectFromQuad));
|
|
2247
|
+
case "redact":
|
|
2248
|
+
if (input.quads) return unionRects(input.quads.map(rectFromQuad));
|
|
2249
|
+
break;
|
|
2250
|
+
case "ink": {
|
|
2251
|
+
const pad = (input.width ?? 2) / 2;
|
|
2252
|
+
const r = bounds(input.inklist.flat());
|
|
2253
|
+
return {
|
|
2254
|
+
x: r.x - pad,
|
|
2255
|
+
y: r.y - pad,
|
|
2256
|
+
width: r.width + pad * 2,
|
|
2257
|
+
height: r.height + pad * 2
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
case "line": return bounds([input.start, input.end]);
|
|
2261
|
+
case "polyline":
|
|
2262
|
+
case "polygon": return bounds(input.vertices);
|
|
2263
|
+
}
|
|
2264
|
+
if (input.rect) return input.rect;
|
|
2265
|
+
throw new TypeError(`rect is required for ${input.type}`);
|
|
2266
|
+
}
|
|
2267
|
+
function bounds(pts) {
|
|
2268
|
+
const xs = pts.map((p) => p.x);
|
|
2269
|
+
const ys = pts.map((p) => p.y);
|
|
2270
|
+
const x = Math.min(...xs);
|
|
2271
|
+
const y = Math.min(...ys);
|
|
2272
|
+
return {
|
|
2273
|
+
x,
|
|
2274
|
+
y,
|
|
2275
|
+
width: Math.max(...xs) - x,
|
|
2276
|
+
height: Math.max(...ys) - y
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
/** `o` without its `undefined` values. */
|
|
2280
|
+
function withoutUndefined(o) {
|
|
2281
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* A `defaultStyle` patch merges one level deep into the current one, so changing the weight
|
|
2285
|
+
* keeps the font and size. An `undefined` value removes its key.
|
|
2286
|
+
*/
|
|
2287
|
+
function mergedStyle(before, patch) {
|
|
2288
|
+
const next = patch.defaultStyle;
|
|
2289
|
+
if (before.type !== "freetext" || next === void 0 || next === null) return {};
|
|
2290
|
+
return { defaultStyle: withoutUndefined({
|
|
2291
|
+
...before.defaultStyle,
|
|
2292
|
+
...next
|
|
2293
|
+
}) };
|
|
2294
|
+
}
|
|
2295
|
+
/** Pending-map key; only annotation changes are tracked, and the prefix keeps ids apart from any other slot. */
|
|
2296
|
+
function changeKey(change) {
|
|
2297
|
+
return `annotation:${change.kind === "update" ? change.id : change.annotation.id}`;
|
|
2298
|
+
}
|
|
2299
|
+
/** Structural equality for plain JSON values, ignoring `modifiedAt` at the top level. */
|
|
2300
|
+
function sameIgnoringModifiedAt(a, b) {
|
|
2301
|
+
const { modifiedAt: _a, ...restA } = a;
|
|
2302
|
+
const { modifiedAt: _b, ...restB } = b;
|
|
2303
|
+
return deepEqual(restA, restB);
|
|
2304
|
+
}
|
|
2305
|
+
function deepEqual(a, b) {
|
|
2306
|
+
if (Object.is(a, b)) return true;
|
|
2307
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
2308
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
2309
|
+
const ka = Object.keys(a);
|
|
2310
|
+
const kb = Object.keys(b);
|
|
2311
|
+
if (ka.length !== kb.length) return false;
|
|
2312
|
+
return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
2313
|
+
}
|
|
2314
|
+
function isApplied(change, current) {
|
|
2315
|
+
switch (change.kind) {
|
|
2316
|
+
case "add": return current.some((a) => a.id === change.annotation.id);
|
|
2317
|
+
case "remove": return !current.some((a) => a.id === change.annotation.id);
|
|
2318
|
+
case "update": {
|
|
2319
|
+
const mine = current.find((a) => a.id === change.id);
|
|
2320
|
+
if (!mine) return false;
|
|
2321
|
+
return mine === change.after || sameIgnoringModifiedAt(mine, change.after);
|
|
2322
|
+
}
|
|
2323
|
+
case "replace": return true;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
function createAnnotationStore(opts) {
|
|
2327
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
2328
|
+
const nextId = opts.id ?? (() => v7());
|
|
2329
|
+
const list = writable([...opts.mode.initial ?? []]);
|
|
2330
|
+
const selection = writable([]);
|
|
2331
|
+
const emitter = new Emitter();
|
|
2332
|
+
const controlled = opts.mode.mode === "controlled" ? opts.mode : void 0;
|
|
2333
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2334
|
+
/**
|
|
2335
|
+
* Controlled mode: the list as the store last proposed it, embedded items included. Later
|
|
2336
|
+
* proposals build on it, so several mutations in a row (one highlight per page, a bulk
|
|
2337
|
+
* remove, add then select) all reach the host. It resets to the applied list when the host
|
|
2338
|
+
* pushes a value or a pending change's check fires without one.
|
|
2339
|
+
*/
|
|
2340
|
+
let proposed;
|
|
2341
|
+
let destroyed = false;
|
|
2342
|
+
/** What mutations build on: the pending proposal in controlled mode, else the list. */
|
|
2343
|
+
const basis = () => proposed ?? list.get();
|
|
2344
|
+
const findIn = (items, id) => items.find((a) => a.id === id);
|
|
2345
|
+
function completeInput(input) {
|
|
2346
|
+
const ts = now();
|
|
2347
|
+
const candidate = {
|
|
2348
|
+
...input,
|
|
2349
|
+
id: input.id ?? nextId(),
|
|
2350
|
+
rect: input.rect ?? deriveRect(input),
|
|
2351
|
+
color: input.color ?? opts.defaults.color(input.type),
|
|
2352
|
+
opacity: input.opacity ?? opts.defaults.opacity(input.type),
|
|
2353
|
+
createdAt: ts,
|
|
2354
|
+
modifiedAt: ts,
|
|
2355
|
+
source: "session"
|
|
2356
|
+
};
|
|
2357
|
+
if (input.type === "freetext") {
|
|
2358
|
+
if (input.color === void 0) delete candidate.color;
|
|
2359
|
+
candidate.defaultStyle = {
|
|
2360
|
+
color: opts.defaults.color("freetext"),
|
|
2361
|
+
...withoutUndefined(input.defaultStyle ?? {})
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
if (input.type === "redact") {
|
|
2365
|
+
if (input.interiorColor === void 0) candidate.interiorColor = opts.defaults.fill();
|
|
2366
|
+
}
|
|
2367
|
+
return AnnotationSchema.parse(candidate);
|
|
2368
|
+
}
|
|
2369
|
+
function clearPending(key) {
|
|
2370
|
+
const entry = pending.get(key);
|
|
2371
|
+
if (!entry) return;
|
|
2372
|
+
clearTimeout(entry.timer);
|
|
2373
|
+
pending.delete(key);
|
|
2374
|
+
}
|
|
2375
|
+
function warnIfIgnored(key) {
|
|
2376
|
+
const entry = pending.get(key);
|
|
2377
|
+
if (!entry) return;
|
|
2378
|
+
pending.delete(key);
|
|
2379
|
+
if (pending.size === 0) proposed = void 0;
|
|
2380
|
+
pruneSelection(basis());
|
|
2381
|
+
if (isApplied(entry.change, list.get()) || isProduction()) return;
|
|
2382
|
+
console.warn(`[inkbar] ControlledMutationIgnored: the host did not apply a ${entry.change.kind} for annotation ${entry.id}. In controlled mode, pass the list from onChange back into the store.`);
|
|
2383
|
+
}
|
|
2384
|
+
function commit(next, change) {
|
|
2385
|
+
if (controlled) {
|
|
2386
|
+
if (change.kind !== "replace") {
|
|
2387
|
+
const key = changeKey(change);
|
|
2388
|
+
clearPending(key);
|
|
2389
|
+
const entry = {
|
|
2390
|
+
id: change.kind === "update" ? change.id : change.annotation.id,
|
|
2391
|
+
change,
|
|
2392
|
+
timer: setTimeout(() => {
|
|
2393
|
+
entry.timer = setTimeout(() => {
|
|
2394
|
+
warnIfIgnored(key);
|
|
2395
|
+
}, 0);
|
|
2396
|
+
}, 0)
|
|
2397
|
+
};
|
|
2398
|
+
pending.set(key, entry);
|
|
2399
|
+
}
|
|
2400
|
+
proposed = change.kind === "replace" ? [...list.get().filter((a) => a.source === "pdf"), ...next.filter((a) => a.source !== "pdf")] : next;
|
|
2401
|
+
controlled.onChange(next.filter((a) => a.source !== "pdf"), change);
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
list.set(next);
|
|
2405
|
+
emitter.emit("change", change);
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Uncontrolled `replaceAll` cannot drop or forge embedded annotations: every current embedded
|
|
2409
|
+
* item stays (taking the list's version of it, guarded like `update`, when the list has one),
|
|
2410
|
+
* and items claiming `source: 'pdf'` that the store does not hold are dropped. History
|
|
2411
|
+
* snapshots contain the embedded items, so undo and redo of their edits still work.
|
|
2412
|
+
*/
|
|
2413
|
+
function withEmbedded(proposed) {
|
|
2414
|
+
const current = new Map(list.get().filter((a) => a.source === "pdf").map((a) => [a.id, a]));
|
|
2415
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2416
|
+
const out = [];
|
|
2417
|
+
for (const a of proposed) {
|
|
2418
|
+
const mine = current.get(a.id);
|
|
2419
|
+
if (mine) {
|
|
2420
|
+
if (!seen.has(a.id)) out.push(guardEmbedded(mine, a));
|
|
2421
|
+
seen.add(a.id);
|
|
2422
|
+
} else if (a.source !== "pdf") out.push(a);
|
|
2423
|
+
}
|
|
2424
|
+
return [...[...current.values()].filter((a) => !seen.has(a.id)), ...out];
|
|
2425
|
+
}
|
|
2426
|
+
function pruneSelection(next) {
|
|
2427
|
+
const known = new Set(next.map((a) => a.id));
|
|
2428
|
+
const current = selection.get();
|
|
2429
|
+
if (current.every((id) => known.has(id))) return;
|
|
2430
|
+
selection.set(current.filter((id) => known.has(id)));
|
|
2431
|
+
}
|
|
2432
|
+
return {
|
|
2433
|
+
mode: opts.mode.mode,
|
|
2434
|
+
list: readonly(list),
|
|
2435
|
+
selection: readonly(selection),
|
|
2436
|
+
defaults: opts.defaults,
|
|
2437
|
+
get: (id) => list.get().find((a) => a.id === id),
|
|
2438
|
+
_getProposed: (id) => findIn(basis(), id),
|
|
2439
|
+
byPage: (page) => list.get().filter((a) => a.page === page),
|
|
2440
|
+
completeInput,
|
|
2441
|
+
add(input) {
|
|
2442
|
+
const a = completeInput(input);
|
|
2443
|
+
if (findIn(basis(), a.id)) throw new Error(`An annotation with id ${a.id} already exists`);
|
|
2444
|
+
if (destroyed) return a;
|
|
2445
|
+
const replaced = a.type === "mask" ? basis().filter((x) => x.type === "mask" && x.page === a.page && x.source !== "pdf") : [];
|
|
2446
|
+
const gone = new Set(replaced.map((x) => x.id));
|
|
2447
|
+
for (const x of replaced) clearPending(changeKey({
|
|
2448
|
+
kind: "add",
|
|
2449
|
+
annotation: x
|
|
2450
|
+
}));
|
|
2451
|
+
commit([...basis().filter((x) => !gone.has(x.id)), a], {
|
|
2452
|
+
kind: "add",
|
|
2453
|
+
annotation: a
|
|
2454
|
+
});
|
|
2455
|
+
if (!controlled && gone.size > 0) pruneSelection(list.get());
|
|
2456
|
+
return a;
|
|
2457
|
+
},
|
|
2458
|
+
update(id, patch) {
|
|
2459
|
+
const before = findIn(basis(), id);
|
|
2460
|
+
if (destroyed || !before) return void 0;
|
|
2461
|
+
if (before.locked && Object.keys(patch).some((k) => LOCKED_KEYS.has(k))) return void 0;
|
|
2462
|
+
const candidate = {
|
|
2463
|
+
...before,
|
|
2464
|
+
...patch,
|
|
2465
|
+
...mergedStyle(before, patch),
|
|
2466
|
+
id: before.id,
|
|
2467
|
+
type: before.type,
|
|
2468
|
+
createdAt: before.createdAt,
|
|
2469
|
+
source: before.source,
|
|
2470
|
+
modifiedAt: now()
|
|
2471
|
+
};
|
|
2472
|
+
if (before.source === "pdf") keepKey(candidate, before, "locked");
|
|
2473
|
+
const after = AnnotationSchema.parse(candidate);
|
|
2474
|
+
const swap = (items) => items.map((a) => a.id === id ? after : a);
|
|
2475
|
+
const next = swap(basis());
|
|
2476
|
+
const change = {
|
|
2477
|
+
kind: "update",
|
|
2478
|
+
id,
|
|
2479
|
+
before,
|
|
2480
|
+
after
|
|
2481
|
+
};
|
|
2482
|
+
if (controlled && before.source === "pdf") {
|
|
2483
|
+
list.set(swap(list.get()));
|
|
2484
|
+
if (proposed) proposed = next;
|
|
2485
|
+
emitter.emit("change", change);
|
|
2486
|
+
} else commit(next, change);
|
|
2487
|
+
return after;
|
|
2488
|
+
},
|
|
2489
|
+
remove(id) {
|
|
2490
|
+
const items = basis();
|
|
2491
|
+
const target = findIn(items, id);
|
|
2492
|
+
if (destroyed || !target || target.locked) return false;
|
|
2493
|
+
const doomed = /* @__PURE__ */ new Set([id]);
|
|
2494
|
+
let grew = true;
|
|
2495
|
+
while (grew) {
|
|
2496
|
+
grew = false;
|
|
2497
|
+
for (const a of items) if (a.inReplyTo !== void 0 && doomed.has(a.inReplyTo) && !doomed.has(a.id)) {
|
|
2498
|
+
doomed.add(a.id);
|
|
2499
|
+
grew = true;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const next = items.filter((a) => !doomed.has(a.id));
|
|
2503
|
+
commit(next, {
|
|
2504
|
+
kind: "remove",
|
|
2505
|
+
annotation: target,
|
|
2506
|
+
removed: [target, ...items.filter((a) => a.id !== id && doomed.has(a.id))]
|
|
2507
|
+
});
|
|
2508
|
+
if (!controlled) pruneSelection(next);
|
|
2509
|
+
return true;
|
|
2510
|
+
},
|
|
2511
|
+
select(ids) {
|
|
2512
|
+
const known = new Set(basis().map((a) => a.id));
|
|
2513
|
+
selection.set(ids.filter((id) => known.has(id)));
|
|
2514
|
+
},
|
|
2515
|
+
replaceAll(list, reason) {
|
|
2516
|
+
if (destroyed) return;
|
|
2517
|
+
const next = controlled ? [...list] : withEmbedded(list);
|
|
2518
|
+
commit(next, {
|
|
2519
|
+
kind: "replace",
|
|
2520
|
+
reason
|
|
2521
|
+
});
|
|
2522
|
+
if (!controlled) pruneSelection(next);
|
|
2523
|
+
},
|
|
2524
|
+
setControlledValue(value) {
|
|
2525
|
+
if (!controlled) throw new TypeError("setControlledValue is only valid in controlled mode");
|
|
2526
|
+
if (destroyed) return;
|
|
2527
|
+
const embedded = list.get().filter((a) => a.source === "pdf");
|
|
2528
|
+
const embeddedIds = new Set(embedded.map((a) => a.id));
|
|
2529
|
+
const next = [...embedded, ...value.filter((a) => !embeddedIds.has(a.id))];
|
|
2530
|
+
list.set(next);
|
|
2531
|
+
proposed = void 0;
|
|
2532
|
+
for (const [key, entry] of [...pending]) if (isApplied(entry.change, next)) clearPending(key);
|
|
2533
|
+
pruneSelection(next);
|
|
2534
|
+
emitter.emit("change", {
|
|
2535
|
+
kind: "replace",
|
|
2536
|
+
reason: "sync"
|
|
2537
|
+
});
|
|
2538
|
+
},
|
|
2539
|
+
_setEmbedded(items) {
|
|
2540
|
+
if (destroyed) return;
|
|
2541
|
+
const ids = new Set(items.map((a) => a.id));
|
|
2542
|
+
const next = [...items, ...list.get().filter((a) => a.source !== "pdf" && !ids.has(a.id))];
|
|
2543
|
+
proposed = void 0;
|
|
2544
|
+
list.set(next);
|
|
2545
|
+
pruneSelection(next);
|
|
2546
|
+
emitter.emit("change", {
|
|
2547
|
+
kind: "replace",
|
|
2548
|
+
reason: "import"
|
|
2549
|
+
});
|
|
2550
|
+
},
|
|
2551
|
+
_patchEmbedded(fields) {
|
|
2552
|
+
if (destroyed) return;
|
|
2553
|
+
const applied = /* @__PURE__ */ new Map();
|
|
2554
|
+
const merge = (items) => items.map((a) => {
|
|
2555
|
+
const f = a.source === "pdf" ? fields.get(a.id) : void 0;
|
|
2556
|
+
const merged = f && withEmbeddedFields(a, f);
|
|
2557
|
+
if (!merged) return a;
|
|
2558
|
+
applied.set(a.id, f);
|
|
2559
|
+
return merged;
|
|
2560
|
+
});
|
|
2561
|
+
const next = merge(list.get());
|
|
2562
|
+
if (applied.size === 0) return;
|
|
2563
|
+
list.set(next);
|
|
2564
|
+
if (proposed) proposed = merge(proposed);
|
|
2565
|
+
emitter.emit("change", {
|
|
2566
|
+
kind: "replace",
|
|
2567
|
+
reason: "embedded",
|
|
2568
|
+
fields: applied
|
|
2569
|
+
});
|
|
2570
|
+
},
|
|
2571
|
+
on: (event, fn) => emitter.on(event, fn),
|
|
2572
|
+
destroy() {
|
|
2573
|
+
destroyed = true;
|
|
2574
|
+
proposed = void 0;
|
|
2575
|
+
for (const key of [...pending.keys()]) clearPending(key);
|
|
2576
|
+
emitter.clear();
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
//#endregion
|
|
2581
|
+
//#region src/model/page-cache.ts
|
|
2582
|
+
function createPageCache(fetch) {
|
|
2583
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2584
|
+
return {
|
|
2585
|
+
get(index) {
|
|
2586
|
+
const cached = cache.get(index);
|
|
2587
|
+
if (cached) return cached;
|
|
2588
|
+
const fetched = fetch(index);
|
|
2589
|
+
cache.set(index, fetched);
|
|
2590
|
+
fetched.catch(() => {
|
|
2591
|
+
if (cache.get(index) === fetched) cache.delete(index);
|
|
2592
|
+
});
|
|
2593
|
+
return fetched;
|
|
2594
|
+
},
|
|
2595
|
+
clear: () => cache.clear()
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
//#endregion
|
|
2599
|
+
//#region src/model/pages.ts
|
|
2600
|
+
function pageInfoFromProxy(page, index) {
|
|
2601
|
+
const [x1, y1, x2, y2] = page.view;
|
|
2602
|
+
const box = {
|
|
2603
|
+
x: Math.min(x1, x2),
|
|
2604
|
+
y: Math.min(y1, y2),
|
|
2605
|
+
width: Math.abs(x2 - x1),
|
|
2606
|
+
height: Math.abs(y2 - y1)
|
|
2607
|
+
};
|
|
2608
|
+
return {
|
|
2609
|
+
index,
|
|
2610
|
+
box,
|
|
2611
|
+
rotate: normaliseRotation(page.rotate),
|
|
2612
|
+
userUnit: page.userUnit > 0 ? page.userUnit : 1,
|
|
2613
|
+
width: box.width,
|
|
2614
|
+
height: box.height
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
/** PDF.js already rejects /Rotate values that are not multiples of 90; this also folds negatives. */
|
|
2618
|
+
function normaliseRotation(rotate) {
|
|
2619
|
+
return (Math.round(rotate / 90) * 90 % 360 + 360) % 360;
|
|
2620
|
+
}
|
|
2621
|
+
//#endregion
|
|
2622
|
+
//#region src/model/source.ts
|
|
2623
|
+
function resolveSource(src, fetchImpl = globalThis.fetch) {
|
|
2624
|
+
if (typeof src === "string") return {
|
|
2625
|
+
key: src,
|
|
2626
|
+
bytes: () => fetchBytes(src, fetchImpl)
|
|
2627
|
+
};
|
|
2628
|
+
if (src instanceof ArrayBuffer || src instanceof Uint8Array) {
|
|
2629
|
+
const data = toBytes(src);
|
|
2630
|
+
return {
|
|
2631
|
+
key: `bytes:${v7()}`,
|
|
2632
|
+
bytes: () => Promise.resolve(data.slice())
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
if ("url" in src) {
|
|
2636
|
+
const { url } = src;
|
|
2637
|
+
return {
|
|
2638
|
+
key: src.key,
|
|
2639
|
+
bytes: () => fetchBytes(url, fetchImpl)
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
if ("data" in src) {
|
|
2643
|
+
const data = toBytes(src.data);
|
|
2644
|
+
return {
|
|
2645
|
+
key: src.key,
|
|
2646
|
+
bytes: () => Promise.resolve(data.slice())
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
const blob = src;
|
|
2650
|
+
return {
|
|
2651
|
+
key: `bytes:${v7()}`,
|
|
2652
|
+
bytes: async () => new Uint8Array(await blob.arrayBuffer())
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* A view over the host's bytes. `bytes()` hands PDF.js a copy each time: PDF.js transfers the
|
|
2657
|
+
* buffer it is given to its worker, which would empty the host's array and break a reopen
|
|
2658
|
+
* (a password retry, for one).
|
|
2659
|
+
*/
|
|
2660
|
+
function toBytes(data) {
|
|
2661
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2662
|
+
}
|
|
2663
|
+
async function fetchBytes(url, fetchImpl) {
|
|
2664
|
+
if (typeof fetchImpl !== "function") throw new InkError("SourceFetchFailed", "No fetch implementation available for URL sources", { url });
|
|
2665
|
+
let res;
|
|
2666
|
+
try {
|
|
2667
|
+
res = await fetchImpl(url, { credentials: "same-origin" });
|
|
2668
|
+
} catch (cause) {
|
|
2669
|
+
throw new InkError("SourceFetchFailed", `Could not fetch ${url}`, { url }, cause);
|
|
2670
|
+
}
|
|
2671
|
+
if (!res.ok) throw new InkError("SourceFetchFailed", `Fetching ${url} returned HTTP ${res.status}`, {
|
|
2672
|
+
url,
|
|
2673
|
+
status: res.status
|
|
2674
|
+
});
|
|
2675
|
+
try {
|
|
2676
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
2677
|
+
} catch (cause) {
|
|
2678
|
+
throw new InkError("SourceFetchFailed", `Reading the body of ${url} failed`, { url }, cause);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
//#endregion
|
|
2682
|
+
//#region src/model/text.ts
|
|
2683
|
+
/** Fractions of the font size used when PDF.js has no usable metrics for the font. */
|
|
2684
|
+
const FALLBACK_ASCENT = .75;
|
|
2685
|
+
const FALLBACK_DESCENT = .25;
|
|
2686
|
+
/**
|
|
2687
|
+
* Converts `getTextContent()` output to user-space boxes.
|
|
2688
|
+
*
|
|
2689
|
+
* For an item with transform [a, b, c, d, e, f] (already in user space):
|
|
2690
|
+
* - (e, f) is the start of the run on the baseline;
|
|
2691
|
+
* - u = (a, b) / |(a, b)| is the baseline direction, along which the run advances `width`;
|
|
2692
|
+
* - v = (c, d) / |(c, d)| is the glyph-up direction, and `fontSize` = |(c, d)|;
|
|
2693
|
+
* - the box spans from `descent * fontSize` below the baseline to `ascent * fontSize` above it,
|
|
2694
|
+
* using the font's ascent/descent from `styles` when PDF.js provides them, else 0.75 / 0.25.
|
|
2695
|
+
*
|
|
2696
|
+
* So p3 = (e, f) - v * descent * fontSize, p4 = p3 + u * width, p1 = p3 + v * (ascent + descent) * fontSize,
|
|
2697
|
+
* p2 = p4 + (p1 - p3). `rect` is the axis-aligned bounding box of the quad.
|
|
2698
|
+
*/
|
|
2699
|
+
function textContentFromPdfjs(page, tc) {
|
|
2700
|
+
const items = [];
|
|
2701
|
+
for (const raw of tc.items) {
|
|
2702
|
+
if (!("str" in raw)) continue;
|
|
2703
|
+
const item = raw;
|
|
2704
|
+
const [a, b, c, d, e, f] = item.transform;
|
|
2705
|
+
const baseLen = Math.hypot(a, b);
|
|
2706
|
+
const fontSize = Math.hypot(c, d);
|
|
2707
|
+
const u = baseLen === 0 ? {
|
|
2708
|
+
x: 1,
|
|
2709
|
+
y: 0
|
|
2710
|
+
} : {
|
|
2711
|
+
x: a / baseLen,
|
|
2712
|
+
y: b / baseLen
|
|
2713
|
+
};
|
|
2714
|
+
const v = fontSize === 0 ? {
|
|
2715
|
+
x: -u.y,
|
|
2716
|
+
y: u.x
|
|
2717
|
+
} : {
|
|
2718
|
+
x: c / fontSize,
|
|
2719
|
+
y: d / fontSize
|
|
2720
|
+
};
|
|
2721
|
+
const { ascent, descent } = metrics(tc.styles[item.fontName]);
|
|
2722
|
+
const below = descent * fontSize;
|
|
2723
|
+
const total = (ascent + descent) * fontSize;
|
|
2724
|
+
const w = item.width;
|
|
2725
|
+
const p3 = {
|
|
2726
|
+
x: e - v.x * below,
|
|
2727
|
+
y: f - v.y * below
|
|
2728
|
+
};
|
|
2729
|
+
const p4 = {
|
|
2730
|
+
x: p3.x + u.x * w,
|
|
2731
|
+
y: p3.y + u.y * w
|
|
2732
|
+
};
|
|
2733
|
+
const quad = {
|
|
2734
|
+
p1: {
|
|
2735
|
+
x: p3.x + v.x * total,
|
|
2736
|
+
y: p3.y + v.y * total
|
|
2737
|
+
},
|
|
2738
|
+
p2: {
|
|
2739
|
+
x: p4.x + v.x * total,
|
|
2740
|
+
y: p4.y + v.y * total
|
|
2741
|
+
},
|
|
2742
|
+
p3,
|
|
2743
|
+
p4
|
|
2744
|
+
};
|
|
2745
|
+
items.push({
|
|
2746
|
+
str: item.str,
|
|
2747
|
+
dir: isDir(item.dir) ? item.dir : "ltr",
|
|
2748
|
+
fontName: item.fontName,
|
|
2749
|
+
fontSize,
|
|
2750
|
+
hasEOL: item.hasEOL,
|
|
2751
|
+
quad,
|
|
2752
|
+
rect: rectFromQuad(quad)
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
return {
|
|
2756
|
+
page,
|
|
2757
|
+
items
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
function metrics(style) {
|
|
2761
|
+
if (!style || style.vertical) return {
|
|
2762
|
+
ascent: FALLBACK_ASCENT,
|
|
2763
|
+
descent: FALLBACK_DESCENT
|
|
2764
|
+
};
|
|
2765
|
+
const ascent = style.ascent;
|
|
2766
|
+
const descent = Math.abs(style.descent);
|
|
2767
|
+
if (!(ascent > 0) || !Number.isFinite(descent) || ascent + descent <= 0) return {
|
|
2768
|
+
ascent: FALLBACK_ASCENT,
|
|
2769
|
+
descent: FALLBACK_DESCENT
|
|
2770
|
+
};
|
|
2771
|
+
return {
|
|
2772
|
+
ascent,
|
|
2773
|
+
descent
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
function isDir(dir) {
|
|
2777
|
+
return dir === "ltr" || dir === "rtl" || dir === "ttb";
|
|
2778
|
+
}
|
|
2779
|
+
//#endregion
|
|
2780
|
+
//#region src/model/document.ts
|
|
2781
|
+
/**
|
|
2782
|
+
* Opens a document. Resolves once loading has finished, whether it succeeded or not:
|
|
2783
|
+
* check `status` or await `ready`, which rejects with the InkError. Only a missing
|
|
2784
|
+
* `assets` option (a programmer error) makes this promise itself reject.
|
|
2785
|
+
*/
|
|
2786
|
+
async function openDocument(source, options) {
|
|
2787
|
+
assertAssets(options?.assets);
|
|
2788
|
+
const resolved = resolveSource(source, options.fetch);
|
|
2789
|
+
const status = writable({ state: "loading" });
|
|
2790
|
+
const pages = [];
|
|
2791
|
+
const info = {
|
|
2792
|
+
pageCount: 0,
|
|
2793
|
+
isEncrypted: false
|
|
2794
|
+
};
|
|
2795
|
+
let pageCache;
|
|
2796
|
+
let destroyed = false;
|
|
2797
|
+
let handles;
|
|
2798
|
+
let destroyTask;
|
|
2799
|
+
const store = createAnnotationStore({
|
|
2800
|
+
mode: options.annotations ?? { mode: "uncontrolled" },
|
|
2801
|
+
defaults: options.defaults ?? staticDefaults
|
|
2802
|
+
});
|
|
2803
|
+
const embedded = [];
|
|
2804
|
+
let preserved = [];
|
|
2805
|
+
/** Embedded annotations whose PDF.js data lacks fields the file holds; read after `ready`. */
|
|
2806
|
+
const targets = [];
|
|
2807
|
+
const destroyedError = () => new InkError("DocumentDestroyed", "The document has been destroyed");
|
|
2808
|
+
const ready = (async () => {
|
|
2809
|
+
const pdfjs = await loadPdfjs(options.assets);
|
|
2810
|
+
const { task, proxy } = await openProxy(pdfjs, await resolved.bytes(), {
|
|
2811
|
+
assets: options.assets,
|
|
2812
|
+
password: options.password,
|
|
2813
|
+
verbosity: options.verbosity
|
|
2814
|
+
});
|
|
2815
|
+
destroyTask = () => task.destroy();
|
|
2816
|
+
const cache = createPageCache((index) => proxy.getPage(index + 1));
|
|
2817
|
+
pageCache = cache;
|
|
2818
|
+
const getPage = (index) => {
|
|
2819
|
+
if (destroyed) return Promise.reject(destroyedError());
|
|
2820
|
+
if (!Number.isInteger(index) || index < 0 || index >= proxy.numPages) return Promise.reject(new InkError("PageOutOfRange", `Page ${index} does not exist`, {
|
|
2821
|
+
index,
|
|
2822
|
+
pageCount: proxy.numPages
|
|
2823
|
+
}));
|
|
2824
|
+
return cache.get(index);
|
|
2825
|
+
};
|
|
2826
|
+
handles = {
|
|
2827
|
+
module: pdfjs,
|
|
2828
|
+
proxy,
|
|
2829
|
+
getPage
|
|
2830
|
+
};
|
|
2831
|
+
const docInfo = (await proxy.getMetadata().catch(() => {}))?.info ?? {};
|
|
2832
|
+
const title = nonEmptyString(docInfo.Title);
|
|
2833
|
+
const author = nonEmptyString(docInfo.Author);
|
|
2834
|
+
if (title !== void 0) info.title = title;
|
|
2835
|
+
if (author !== void 0) info.author = author;
|
|
2836
|
+
info.pageCount = proxy.numPages;
|
|
2837
|
+
info.isEncrypted = docInfo.EncryptFilterName !== null && docInfo.EncryptFilterName !== void 0;
|
|
2838
|
+
for (let i = 0; i < proxy.numPages; i++) pages.push(pageInfoFromProxy(await getPage(i), i));
|
|
2839
|
+
const importedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2840
|
+
const perPage = await mapInOrder(proxy.numPages, EMBEDDED_CONCURRENCY, async (i) => {
|
|
2841
|
+
return importEmbeddedPage(i, await (await getPage(i)).getAnnotations().catch(() => {
|
|
2842
|
+
return [];
|
|
2843
|
+
}), importedAt);
|
|
2844
|
+
});
|
|
2845
|
+
embedded.push(...summarise(perPage.map((r) => r.counts)));
|
|
2846
|
+
const fromFile = perPage.flatMap((r) => r.annotations);
|
|
2847
|
+
targets.push(...perPage.flatMap((r) => r.targets));
|
|
2848
|
+
if (fromFile.length > 0) store._setEmbedded(fromFile);
|
|
2849
|
+
})().then(() => {
|
|
2850
|
+
status.set({ state: "ready" });
|
|
2851
|
+
}, (cause) => {
|
|
2852
|
+
const error = isInkError(cause) ? cause : new InkError("InvalidPdf", errorMessage(cause), {}, cause);
|
|
2853
|
+
if (error.code === "PasswordRequired") info.isEncrypted = true;
|
|
2854
|
+
status.set({
|
|
2855
|
+
state: "error",
|
|
2856
|
+
error
|
|
2857
|
+
});
|
|
2858
|
+
throw error;
|
|
2859
|
+
});
|
|
2860
|
+
await ready.catch(() => void 0);
|
|
2861
|
+
const historyOption = options.history ?? true;
|
|
2862
|
+
const historyLimit = historyOption === false ? 0 : historyOption === true ? void 0 : historyOption.limit;
|
|
2863
|
+
const history = createHistory(store, historyLimit === void 0 ? {} : { limit: historyLimit });
|
|
2864
|
+
ready.then(() => {
|
|
2865
|
+
if (destroyed || !handles) return;
|
|
2866
|
+
const { proxy } = handles;
|
|
2867
|
+
readEmbeddedFields(store.list.get(), targets, () => proxy.getData()).then((patched) => {
|
|
2868
|
+
if (!destroyed && patched.size > 0) store._patchEmbedded(patched);
|
|
2869
|
+
}).catch(() => void 0);
|
|
2870
|
+
}, () => void 0);
|
|
2871
|
+
return {
|
|
2872
|
+
key: resolved.key,
|
|
2873
|
+
status: readonly(status),
|
|
2874
|
+
ready,
|
|
2875
|
+
pages,
|
|
2876
|
+
info,
|
|
2877
|
+
async text(page) {
|
|
2878
|
+
if (destroyed) throw destroyedError();
|
|
2879
|
+
if (!handles) throw statusError(status.get());
|
|
2880
|
+
return textContentFromPdfjs(page, await (await handles.getPage(page)).getTextContent());
|
|
2881
|
+
},
|
|
2882
|
+
annotations: store,
|
|
2883
|
+
embedded,
|
|
2884
|
+
history,
|
|
2885
|
+
exportAnnotations(format) {
|
|
2886
|
+
if (format !== "json") throw new TypeError(`Unsupported export format ${String(format)}`);
|
|
2887
|
+
return exportAnnotations(store, preserved, resolved.key);
|
|
2888
|
+
},
|
|
2889
|
+
importAnnotations(data, opts = { mode: "replace" }) {
|
|
2890
|
+
const plan = planImport(store, data, opts);
|
|
2891
|
+
preserved = opts.mode === "replace" ? plan.result.unknown : [...preserved.filter((u) => !plan.ids.has(u.id)), ...plan.result.unknown];
|
|
2892
|
+
plan.apply();
|
|
2893
|
+
return plan.result;
|
|
2894
|
+
},
|
|
2895
|
+
save() {
|
|
2896
|
+
return Promise.reject(new InkError("EngineUnavailable", "Saving needs @inkbar/engine, which is not registered. Use exportAnnotations(\"json\") to keep annotations without writing the PDF."));
|
|
2897
|
+
},
|
|
2898
|
+
destroy() {
|
|
2899
|
+
if (destroyed) return;
|
|
2900
|
+
destroyed = true;
|
|
2901
|
+
history.destroy();
|
|
2902
|
+
store.destroy();
|
|
2903
|
+
pageCache?.clear();
|
|
2904
|
+
if (destroyTask) teardown(destroyTask);
|
|
2905
|
+
},
|
|
2906
|
+
get _pdfjs() {
|
|
2907
|
+
if (!handles) throw statusError(status.get());
|
|
2908
|
+
return handles;
|
|
2909
|
+
}
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
const EMBEDDED_CONCURRENCY = 8;
|
|
2913
|
+
/** Runs `task(0..count-1)` with at most `limit` in flight; results come back in index order. */
|
|
2914
|
+
async function mapInOrder(count, limit, task) {
|
|
2915
|
+
const results = new Array(count);
|
|
2916
|
+
let next = 0;
|
|
2917
|
+
const worker = async () => {
|
|
2918
|
+
while (next < count) {
|
|
2919
|
+
const i = next++;
|
|
2920
|
+
results[i] = await task(i);
|
|
2921
|
+
}
|
|
2922
|
+
};
|
|
2923
|
+
await Promise.all(Array.from({ length: Math.min(limit, count) }, worker));
|
|
2924
|
+
return results;
|
|
2925
|
+
}
|
|
2926
|
+
function teardown(destroyTask) {
|
|
2927
|
+
destroyTask().catch(() => {});
|
|
2928
|
+
}
|
|
2929
|
+
function statusError(s) {
|
|
2930
|
+
return s.state === "error" ? s.error : new InkError("InvalidPdf", "The document has not finished loading");
|
|
2931
|
+
}
|
|
2932
|
+
function nonEmptyString(value) {
|
|
2933
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
2934
|
+
}
|
|
2935
|
+
function errorMessage(cause) {
|
|
2936
|
+
if (cause instanceof Error) return cause.message;
|
|
2937
|
+
return String(cause);
|
|
2938
|
+
}
|
|
2939
|
+
const shift = (p, dx, dy) => ({
|
|
2940
|
+
x: p.x + dx,
|
|
2941
|
+
y: p.y + dy
|
|
2942
|
+
});
|
|
2943
|
+
/** The geometry patch that translates `a` by (dx, dy) in user space. */
|
|
2944
|
+
function translated(a, dx, dy) {
|
|
2945
|
+
const patch = { rect: translateRect(a.rect, dx, dy) };
|
|
2946
|
+
if ("quads" in a && a.quads) patch.quads = a.quads.map((q) => translateQuad(q, dx, dy));
|
|
2947
|
+
if ("callout" in a && a.callout) patch.callout = a.callout.map((p) => shift(p, dx, dy));
|
|
2948
|
+
switch (a.type) {
|
|
2949
|
+
case "line":
|
|
2950
|
+
patch.start = shift(a.start, dx, dy);
|
|
2951
|
+
patch.end = shift(a.end, dx, dy);
|
|
2952
|
+
break;
|
|
2953
|
+
case "polyline":
|
|
2954
|
+
case "polygon":
|
|
2955
|
+
patch.vertices = a.vertices.map((p) => shift(p, dx, dy));
|
|
2956
|
+
break;
|
|
2957
|
+
case "ink": patch.inklist = a.inklist.map((path) => path.map((p) => shift(p, dx, dy)));
|
|
2958
|
+
}
|
|
2959
|
+
return patch;
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* True when `rect` is the annotation's whole shape, so resizing means changing `rect`: area
|
|
2963
|
+
* redactions, free text, squares, circles, stamps, loupes and masks. Text-derived marks (quads), note icons and
|
|
2964
|
+
* point-based shapes are not.
|
|
2965
|
+
*/
|
|
2966
|
+
function hasRectGeometry(a) {
|
|
2967
|
+
switch (a.type) {
|
|
2968
|
+
case "redact": return a.quads === void 0 || a.quads.length === 0;
|
|
2969
|
+
case "freetext":
|
|
2970
|
+
case "square":
|
|
2971
|
+
case "circle":
|
|
2972
|
+
case "stamp":
|
|
2973
|
+
case "loupe":
|
|
2974
|
+
case "mask": return true;
|
|
2975
|
+
default: return false;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
/**
|
|
2979
|
+
* Grows (positive) or shrinks (negative) a rect by `dw` and `dh` points, keeping its top-left
|
|
2980
|
+
* corner (user space: left edge and top edge, `y + height`) where it is. Neither side goes
|
|
2981
|
+
* below `MIN_SIZE`. With `keepAspect`, the side that changes leads and the other follows it in
|
|
2982
|
+
* proportion (the width when both change).
|
|
2983
|
+
*/
|
|
2984
|
+
function resizedRect(r, dw, dh, keepAspect = false) {
|
|
2985
|
+
let width = Math.max(1, r.width + dw);
|
|
2986
|
+
let height = Math.max(1, r.height + dh);
|
|
2987
|
+
if (keepAspect && r.width > 0 && r.height > 0) {
|
|
2988
|
+
const aspect = r.width / r.height;
|
|
2989
|
+
if (dw !== 0) height = width / aspect;
|
|
2990
|
+
else width = height * aspect;
|
|
2991
|
+
const grow = Math.max(1 / width, 1 / height, 1);
|
|
2992
|
+
width *= grow;
|
|
2993
|
+
height *= grow;
|
|
2994
|
+
}
|
|
2995
|
+
const top = r.y + r.height;
|
|
2996
|
+
return {
|
|
2997
|
+
x: r.x,
|
|
2998
|
+
y: top - height,
|
|
2999
|
+
width,
|
|
3000
|
+
height
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* True for highlight, underline, strikeout and squiggly: marks on text, which a drag passes
|
|
3005
|
+
* through to text selection and a click selects.
|
|
3006
|
+
*/
|
|
3007
|
+
function isTextMarkup(a) {
|
|
3008
|
+
return a.type === "highlight" || a.type === "underline" || a.type === "strikeout" || a.type === "squiggly";
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* True for the point-based shapes a corner handle resizes by scaling their points: polylines,
|
|
3012
|
+
* polygons and ink. With `hasRectGeometry`, the annotations that get corner handles.
|
|
3013
|
+
*/
|
|
3014
|
+
function scalesPoints(a) {
|
|
3015
|
+
return a.type === "polyline" || a.type === "polygon" || a.type === "ink";
|
|
3016
|
+
}
|
|
3017
|
+
/** True when a corner handle resizes `a`: rect-shaped types and point-based shapes. */
|
|
3018
|
+
function hasCornerHandles(a) {
|
|
3019
|
+
return hasRectGeometry(a) || scalesPoints(a);
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* The geometry patch that maps `a` from the box `from` onto the box `to`, both in user space:
|
|
3023
|
+
* `rect`, every point field (quads, ink paths, vertices, line ends, callout) and a free text's
|
|
3024
|
+
* `fringe` scale with the boxes. Stroke widths and font sizes stay as they are.
|
|
3025
|
+
*/
|
|
3026
|
+
function scaled(a, from, to) {
|
|
3027
|
+
const sx = from.width === 0 ? 1 : to.width / from.width;
|
|
3028
|
+
const sy = from.height === 0 ? 1 : to.height / from.height;
|
|
3029
|
+
const map = (p) => ({
|
|
3030
|
+
x: to.x + (p.x - from.x) * sx,
|
|
3031
|
+
y: to.y + (p.y - from.y) * sy
|
|
3032
|
+
});
|
|
3033
|
+
const sameBox = (r) => r.x === from.x && r.y === from.y && r.width === from.width && r.height === from.height;
|
|
3034
|
+
const patch = { rect: sameBox(a.rect) ? { ...to } : {
|
|
3035
|
+
...map(a.rect),
|
|
3036
|
+
width: a.rect.width * sx,
|
|
3037
|
+
height: a.rect.height * sy
|
|
3038
|
+
} };
|
|
3039
|
+
if ("quads" in a && a.quads) patch.quads = a.quads.map((q) => ({
|
|
3040
|
+
p1: map(q.p1),
|
|
3041
|
+
p2: map(q.p2),
|
|
3042
|
+
p3: map(q.p3),
|
|
3043
|
+
p4: map(q.p4)
|
|
3044
|
+
}));
|
|
3045
|
+
if ("callout" in a && a.callout) patch.callout = a.callout.map(map);
|
|
3046
|
+
if (a.type === "freetext" && a.fringe) patch.fringe = {
|
|
3047
|
+
left: a.fringe.left * sx,
|
|
3048
|
+
top: a.fringe.top * sy,
|
|
3049
|
+
right: a.fringe.right * sx,
|
|
3050
|
+
bottom: a.fringe.bottom * sy
|
|
3051
|
+
};
|
|
3052
|
+
switch (a.type) {
|
|
3053
|
+
case "line":
|
|
3054
|
+
patch.start = map(a.start);
|
|
3055
|
+
patch.end = map(a.end);
|
|
3056
|
+
break;
|
|
3057
|
+
case "polyline":
|
|
3058
|
+
case "polygon":
|
|
3059
|
+
patch.vertices = a.vertices.map(map);
|
|
3060
|
+
break;
|
|
3061
|
+
case "ink": patch.inklist = a.inklist.map((path) => path.map(map));
|
|
3062
|
+
}
|
|
3063
|
+
return patch;
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* The patch that moves one end of a line to `to` and refits `rect` to the new ends, padded by
|
|
3067
|
+
* half the stroke width on every side.
|
|
3068
|
+
*/
|
|
3069
|
+
function movedEndpoint(a, which, to) {
|
|
3070
|
+
const start = which === "start" ? to : a.start;
|
|
3071
|
+
const end = which === "end" ? to : a.end;
|
|
3072
|
+
const pad = a.width / 2;
|
|
3073
|
+
const x = Math.min(start.x, end.x);
|
|
3074
|
+
const y = Math.min(start.y, end.y);
|
|
3075
|
+
return {
|
|
3076
|
+
[which]: { ...to },
|
|
3077
|
+
rect: {
|
|
3078
|
+
x: x - pad,
|
|
3079
|
+
y: y - pad,
|
|
3080
|
+
width: Math.abs(end.x - start.x) + pad * 2,
|
|
3081
|
+
height: Math.abs(end.y - start.y) + pad * 2
|
|
3082
|
+
}
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* True for annotations whose resize keeps their aspect ratio without `Shift`: stamps, loupes,
|
|
3087
|
+
* masks and signatures (ink with subject `Signature`).
|
|
3088
|
+
*/
|
|
3089
|
+
function keepsAspect(a) {
|
|
3090
|
+
if (a.type === "stamp" || a.type === "loupe" || a.type === "mask") return true;
|
|
3091
|
+
return a.type === "ink" && a.subject === "Signature";
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* True for a speech bubble: a standard free-text callout (`intent: 'FreeTextCallout'`, `rect`,
|
|
3095
|
+
* `fringe`, `callout`) with Inkbar's `cornerRadius`. Inkbar draws it as one rounded outline with
|
|
3096
|
+
* a wedge from where `callout`'s last point meets the box to the tip, `callout[0]`.
|
|
3097
|
+
*/
|
|
3098
|
+
function isBubble(a) {
|
|
3099
|
+
return a.type === "freetext" && a.intent === "FreeTextCallout" && a.cornerRadius !== void 0 && a.callout !== void 0 && a.callout.length >= 2;
|
|
3100
|
+
}
|
|
3101
|
+
/** True for a free text with a callout to edit: a speech bubble or a standard callout. */
|
|
3102
|
+
function hasCallout(a) {
|
|
3103
|
+
return isBubble(a) || isStandardCallout(a);
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* The patch that puts a free text's text box at `box`, refitting `rect` and `fringe` around
|
|
3107
|
+
* it. A callout's points become `callout` (its own by default) and `rect` holds them too; any
|
|
3108
|
+
* other box keeps its fringe, or has `rect` equal to the box without one.
|
|
3109
|
+
*/
|
|
3110
|
+
function withBox(a, box, callout = a.callout) {
|
|
3111
|
+
if (hasCallout(a) && callout) return {
|
|
3112
|
+
...withCalloutBounds(box, callout, calloutStroke(a).width, a.head),
|
|
3113
|
+
callout: callout.map((p) => ({ ...p }))
|
|
3114
|
+
};
|
|
3115
|
+
const f = a.fringe;
|
|
3116
|
+
if (!f) return { rect: { ...box } };
|
|
3117
|
+
return { rect: {
|
|
3118
|
+
x: box.x - f.left,
|
|
3119
|
+
y: box.y - f.bottom,
|
|
3120
|
+
width: box.width + f.left + f.right,
|
|
3121
|
+
height: box.height + f.bottom + f.top
|
|
3122
|
+
} };
|
|
3123
|
+
}
|
|
3124
|
+
const EDGES = [
|
|
3125
|
+
"left",
|
|
3126
|
+
"right",
|
|
3127
|
+
"bottom",
|
|
3128
|
+
"top"
|
|
3129
|
+
];
|
|
3130
|
+
/**
|
|
3131
|
+
* The edge of `box` nearest `p`. `bubblePath` (dom/overlay-render.ts) picks the wedge's edge by
|
|
3132
|
+
* the same rule in CSS px; change both together.
|
|
3133
|
+
*/
|
|
3134
|
+
function nearestEdge(p, box) {
|
|
3135
|
+
const gaps = {
|
|
3136
|
+
left: Math.abs(p.x - box.x),
|
|
3137
|
+
right: Math.abs(p.x - (box.x + box.width)),
|
|
3138
|
+
bottom: Math.abs(p.y - box.y),
|
|
3139
|
+
top: Math.abs(p.y - (box.y + box.height))
|
|
3140
|
+
};
|
|
3141
|
+
return EDGES.reduce((best, e) => gaps[e] < gaps[best] ? e : best);
|
|
3142
|
+
}
|
|
3143
|
+
/** The side of `box` that `p` lies furthest beyond. */
|
|
3144
|
+
function edgeBeyond(p, box) {
|
|
3145
|
+
const beyond = {
|
|
3146
|
+
left: box.x - p.x,
|
|
3147
|
+
right: p.x - (box.x + box.width),
|
|
3148
|
+
bottom: box.y - p.y,
|
|
3149
|
+
top: p.y - (box.y + box.height)
|
|
3150
|
+
};
|
|
3151
|
+
return EDGES.reduce((best, e) => beyond[e] > beyond[best] ? e : best);
|
|
3152
|
+
}
|
|
3153
|
+
/**
|
|
3154
|
+
* `p` carried from the box `from` onto the box `to`: it keeps its fraction along the box edge
|
|
3155
|
+
* nearest it and its distance off that edge.
|
|
3156
|
+
*/
|
|
3157
|
+
function onMovedEdge(p, from, to) {
|
|
3158
|
+
const edge = nearestEdge(p, from);
|
|
3159
|
+
const along = (v, start, size, next, nextSize) => next + (size === 0 ? nextSize / 2 : (v - start) / size * nextSize);
|
|
3160
|
+
if (edge === "left" || edge === "right") {
|
|
3161
|
+
const x = edge === "left" ? from.x : from.x + from.width;
|
|
3162
|
+
return {
|
|
3163
|
+
x: (edge === "left" ? to.x : to.x + to.width) + p.x - x,
|
|
3164
|
+
y: along(p.y, from.y, from.height, to.y, to.height)
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
const y = edge === "bottom" ? from.y : from.y + from.height;
|
|
3168
|
+
const ny = edge === "bottom" ? to.y : to.y + to.height;
|
|
3169
|
+
return {
|
|
3170
|
+
x: along(p.x, from.x, from.width, to.x, to.width),
|
|
3171
|
+
y: ny + p.y - y
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* `p` placed on `box`'s `edge` at its own position along it, kept `margin` points clear of the
|
|
3176
|
+
* corners (at most half the edge).
|
|
3177
|
+
*/
|
|
3178
|
+
function ontoEdge(p, box, edge, margin) {
|
|
3179
|
+
const clamp = (v, start, size) => {
|
|
3180
|
+
const m = Math.min(margin, size / 2);
|
|
3181
|
+
return Math.min(Math.max(v, start + m), start + size - m);
|
|
3182
|
+
};
|
|
3183
|
+
if (edge === "left" || edge === "right") return {
|
|
3184
|
+
x: edge === "left" ? box.x : box.x + box.width,
|
|
3185
|
+
y: clamp(p.y, box.y, box.height)
|
|
3186
|
+
};
|
|
3187
|
+
return {
|
|
3188
|
+
x: clamp(p.x, box.x, box.width),
|
|
3189
|
+
y: edge === "bottom" ? box.y : box.y + box.height
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* The patch that resizes a free text's text box to `to`. A callout's tip and bend stay where
|
|
3194
|
+
* they are and its last point moves with the box edge it sits on (`onMovedEdge`).
|
|
3195
|
+
*/
|
|
3196
|
+
function resizedBox(a, to) {
|
|
3197
|
+
if (!hasCallout(a)) return withBox(a, to);
|
|
3198
|
+
const from = freeTextBox(a);
|
|
3199
|
+
const last = a.callout.length - 1;
|
|
3200
|
+
return withBox(a, to, a.callout.map((p, i) => i === last ? onMovedEdge(p, from, to) : p));
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* The patch that moves a callout's tip to `to`, leaving its text box where it is. A callout
|
|
3204
|
+
* without a bend (every bubble) moves its edge point to the side the tip now lies furthest
|
|
3205
|
+
* beyond, at its own position along that side and clear of the corners, so the line or wedge
|
|
3206
|
+
* never crosses the box; on the same side it stays put. A bend and its edge point stay.
|
|
3207
|
+
*/
|
|
3208
|
+
function movedTip(a, to) {
|
|
3209
|
+
const box = freeTextBox(a);
|
|
3210
|
+
if (a.callout.length !== 2) return withBox(a, box, [to, ...a.callout.slice(1)]);
|
|
3211
|
+
const edge = a.callout[1];
|
|
3212
|
+
const side = edgeBeyond(to, box);
|
|
3213
|
+
const margin = (a.cornerRadius ?? 0) + 8;
|
|
3214
|
+
return withBox(a, box, [to, nearestEdge(edge, box) === side ? edge : ontoEdge(edge, box, side, margin)]);
|
|
3215
|
+
}
|
|
3216
|
+
//#endregion
|
|
3217
|
+
//#region src/model/annotations/style-map.ts
|
|
3218
|
+
/** For each type, the control-level props it supports and the field each writes. */
|
|
3219
|
+
const FIELDS = {
|
|
3220
|
+
square: {
|
|
3221
|
+
border: "color",
|
|
3222
|
+
fill: "interiorColor",
|
|
3223
|
+
opacity: "opacity",
|
|
3224
|
+
width: "width",
|
|
3225
|
+
dashes: "dashes"
|
|
3226
|
+
},
|
|
3227
|
+
circle: {
|
|
3228
|
+
border: "color",
|
|
3229
|
+
fill: "interiorColor",
|
|
3230
|
+
opacity: "opacity",
|
|
3231
|
+
width: "width",
|
|
3232
|
+
dashes: "dashes"
|
|
3233
|
+
},
|
|
3234
|
+
polygon: {
|
|
3235
|
+
border: "color",
|
|
3236
|
+
fill: "interiorColor",
|
|
3237
|
+
opacity: "opacity",
|
|
3238
|
+
width: "width",
|
|
3239
|
+
dashes: "dashes"
|
|
3240
|
+
},
|
|
3241
|
+
polyline: {
|
|
3242
|
+
border: "color",
|
|
3243
|
+
opacity: "opacity",
|
|
3244
|
+
width: "width",
|
|
3245
|
+
dashes: "dashes",
|
|
3246
|
+
head: "head",
|
|
3247
|
+
tail: "tail"
|
|
3248
|
+
},
|
|
3249
|
+
line: {
|
|
3250
|
+
border: "color",
|
|
3251
|
+
opacity: "opacity",
|
|
3252
|
+
width: "width",
|
|
3253
|
+
dashes: "dashes",
|
|
3254
|
+
head: "head",
|
|
3255
|
+
tail: "tail"
|
|
3256
|
+
},
|
|
3257
|
+
ink: {
|
|
3258
|
+
border: "color",
|
|
3259
|
+
opacity: "opacity",
|
|
3260
|
+
width: "width"
|
|
3261
|
+
},
|
|
3262
|
+
highlight: {
|
|
3263
|
+
border: "color",
|
|
3264
|
+
opacity: "opacity"
|
|
3265
|
+
},
|
|
3266
|
+
underline: {
|
|
3267
|
+
border: "color",
|
|
3268
|
+
opacity: "opacity"
|
|
3269
|
+
},
|
|
3270
|
+
strikeout: {
|
|
3271
|
+
border: "color",
|
|
3272
|
+
opacity: "opacity"
|
|
3273
|
+
},
|
|
3274
|
+
squiggly: {
|
|
3275
|
+
border: "color",
|
|
3276
|
+
opacity: "opacity"
|
|
3277
|
+
},
|
|
3278
|
+
redact: {
|
|
3279
|
+
border: "color",
|
|
3280
|
+
opacity: "opacity"
|
|
3281
|
+
},
|
|
3282
|
+
freetext: {
|
|
3283
|
+
border: "borderColor",
|
|
3284
|
+
fill: "color",
|
|
3285
|
+
textColor: "defaultStyle.color",
|
|
3286
|
+
opacity: "opacity",
|
|
3287
|
+
width: "width",
|
|
3288
|
+
dashes: "dashes",
|
|
3289
|
+
fontFamily: "defaultStyle.fontFamily",
|
|
3290
|
+
fontSize: "defaultStyle.fontSize",
|
|
3291
|
+
fontWeight: "defaultStyle.fontWeight",
|
|
3292
|
+
fontStyle: "defaultStyle.fontStyle",
|
|
3293
|
+
textDecoration: "defaultStyle.textDecoration",
|
|
3294
|
+
textAlign: "defaultStyle.textAlign"
|
|
3295
|
+
},
|
|
3296
|
+
loupe: {
|
|
3297
|
+
border: "color",
|
|
3298
|
+
opacity: "opacity",
|
|
3299
|
+
width: "width"
|
|
3300
|
+
},
|
|
3301
|
+
mask: {},
|
|
3302
|
+
text: {},
|
|
3303
|
+
stamp: {}
|
|
3304
|
+
};
|
|
3305
|
+
const GROUPS = {
|
|
3306
|
+
square: "shape",
|
|
3307
|
+
circle: "shape",
|
|
3308
|
+
line: "shape",
|
|
3309
|
+
polyline: "shape",
|
|
3310
|
+
polygon: "shape",
|
|
3311
|
+
loupe: "shape",
|
|
3312
|
+
ink: "ink",
|
|
3313
|
+
freetext: "text",
|
|
3314
|
+
highlight: "markup",
|
|
3315
|
+
underline: "markup",
|
|
3316
|
+
strikeout: "markup",
|
|
3317
|
+
squiggly: "markup",
|
|
3318
|
+
redact: "redact",
|
|
3319
|
+
text: void 0,
|
|
3320
|
+
stamp: void 0,
|
|
3321
|
+
mask: void 0
|
|
3322
|
+
};
|
|
3323
|
+
const SUPPORTED = new Map(Object.keys(FIELDS).map((type) => [type, new Set(Object.keys(FIELDS[type]))]));
|
|
3324
|
+
/**
|
|
3325
|
+
* The props a speech bubble (`intent: 'FreeTextCallout'`) takes from the `shape` group: its
|
|
3326
|
+
* outline, fill and opacity, as the shape tool creates it. Its text props stay in `text`.
|
|
3327
|
+
*/
|
|
3328
|
+
const BUBBLE_SHAPE_PROPS = /* @__PURE__ */ new Set([
|
|
3329
|
+
"border",
|
|
3330
|
+
"fill",
|
|
3331
|
+
"opacity",
|
|
3332
|
+
"width",
|
|
3333
|
+
"dashes"
|
|
3334
|
+
]);
|
|
3335
|
+
/**
|
|
3336
|
+
* The style group an annotation's type belongs to; notes belong to none. With `key`, the group
|
|
3337
|
+
* that owns that prop on `a`: a speech bubble's outline and fill are `shape`'s, its text `text`'s.
|
|
3338
|
+
*/
|
|
3339
|
+
function styleGroupOf(a, key) {
|
|
3340
|
+
if (key !== void 0 && a.type === "freetext" && a.intent === "FreeTextCallout" && BUBBLE_SHAPE_PROPS.has(key)) return "shape";
|
|
3341
|
+
return GROUPS[a.type];
|
|
3342
|
+
}
|
|
3343
|
+
/** Every group that owns some prop on `a`: two for a speech bubble, else at most one. */
|
|
3344
|
+
function styleGroupsOf(a) {
|
|
3345
|
+
const out = /* @__PURE__ */ new Set();
|
|
3346
|
+
const own = GROUPS[a.type];
|
|
3347
|
+
if (own) out.add(own);
|
|
3348
|
+
for (const key of supportedProps(a.type)) {
|
|
3349
|
+
const g = styleGroupOf(a, key);
|
|
3350
|
+
if (g) out.add(g);
|
|
3351
|
+
}
|
|
3352
|
+
return [...out];
|
|
3353
|
+
}
|
|
3354
|
+
/** The types in `group`, from the same table `styleGroupOf` reads. */
|
|
3355
|
+
function typesInGroup(group) {
|
|
3356
|
+
return Object.keys(GROUPS).filter((t) => GROUPS[t] === group);
|
|
3357
|
+
}
|
|
3358
|
+
/** The style group a type belongs to. */
|
|
3359
|
+
function styleGroupOfType(type) {
|
|
3360
|
+
return GROUPS[type];
|
|
3361
|
+
}
|
|
3362
|
+
/** The control-level props `type` supports. */
|
|
3363
|
+
function supportedProps(type) {
|
|
3364
|
+
return SUPPORTED.get(type) ?? /* @__PURE__ */ new Set();
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* The control values a field leaves out: `normal` weight and style, no decoration. The field
|
|
3368
|
+
* is absent rather than holding them, as XFDF writes only what differs.
|
|
3369
|
+
*/
|
|
3370
|
+
const UNSET = {
|
|
3371
|
+
fontWeight: "normal",
|
|
3372
|
+
fontStyle: "normal",
|
|
3373
|
+
textDecoration: "none"
|
|
3374
|
+
};
|
|
3375
|
+
/** The prefix of a field nested in `defaultStyle` (free text). */
|
|
3376
|
+
const STYLE = "defaultStyle.";
|
|
3377
|
+
/**
|
|
3378
|
+
* The field patch `patch` means on `type`. Props the type lacks are dropped. A `null` fill
|
|
3379
|
+
* becomes `interiorColor: undefined` (`color` on free text), which removes the fill when the
|
|
3380
|
+
* store applies it; so does `normal` weight or style and no decoration. Free-text text props go
|
|
3381
|
+
* into a partial `defaultStyle`, which the store merges into the current one.
|
|
3382
|
+
*/
|
|
3383
|
+
function toFields(type, patch) {
|
|
3384
|
+
const fields = FIELDS[type];
|
|
3385
|
+
const out = {};
|
|
3386
|
+
for (const key of Object.keys(patch)) {
|
|
3387
|
+
const field = fields[key];
|
|
3388
|
+
if (field === void 0) continue;
|
|
3389
|
+
const raw = patch[key];
|
|
3390
|
+
const value = raw === null || raw === UNSET[key] ? void 0 : raw;
|
|
3391
|
+
if (field.startsWith(STYLE)) {
|
|
3392
|
+
const style = out.defaultStyle ??= {};
|
|
3393
|
+
style[field.slice(13)] = value;
|
|
3394
|
+
} else out[field] = value;
|
|
3395
|
+
}
|
|
3396
|
+
return out;
|
|
3397
|
+
}
|
|
3398
|
+
/** The control-level props an annotation shows, for the props its type supports. */
|
|
3399
|
+
function fromAnnotation(a) {
|
|
3400
|
+
const fields = FIELDS[a.type];
|
|
3401
|
+
const record = a;
|
|
3402
|
+
const style = record.defaultStyle ?? {};
|
|
3403
|
+
const out = {};
|
|
3404
|
+
for (const key of Object.keys(fields)) {
|
|
3405
|
+
const field = fields[key];
|
|
3406
|
+
const value = field.startsWith(STYLE) ? style[field.slice(13)] : record[field];
|
|
3407
|
+
if (value !== void 0) out[key] = value;
|
|
3408
|
+
else if (key === "fill" || key === "dashes") out[key] = null;
|
|
3409
|
+
else if (key === "head" || key === "tail") out[key] = "None";
|
|
3410
|
+
else if (UNSET[key] !== void 0) out[key] = UNSET[key];
|
|
3411
|
+
}
|
|
3412
|
+
return out;
|
|
3413
|
+
}
|
|
3414
|
+
//#endregion
|
|
3415
|
+
//#region src/model/annotations/tool-style.ts
|
|
3416
|
+
const STYLE_GROUPS = [
|
|
3417
|
+
"shape",
|
|
3418
|
+
"ink",
|
|
3419
|
+
"text",
|
|
3420
|
+
"markup",
|
|
3421
|
+
"redact"
|
|
3422
|
+
];
|
|
3423
|
+
/** Every prop some type in `group` supports. */
|
|
3424
|
+
function groupProps(group) {
|
|
3425
|
+
const out = /* @__PURE__ */ new Set();
|
|
3426
|
+
for (const type of typesInGroup(group)) for (const key of supportedProps(type)) out.add(key);
|
|
3427
|
+
return out;
|
|
3428
|
+
}
|
|
3429
|
+
function sameValue(a, b) {
|
|
3430
|
+
if (Object.is(a, b)) return true;
|
|
3431
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
|
|
3432
|
+
return false;
|
|
3433
|
+
}
|
|
3434
|
+
function sameRecord(a, b) {
|
|
3435
|
+
const ka = Object.keys(a);
|
|
3436
|
+
if (ka.length !== Object.keys(b).length) return false;
|
|
3437
|
+
return ka.every((k) => k in b && sameValue(a[k], b[k]));
|
|
3438
|
+
}
|
|
3439
|
+
function sameSet(a, b) {
|
|
3440
|
+
return a.size === b.size && [...a].every((v) => b.has(v));
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* A readable recomputed from `fn` whenever any source fires, subscribed to its sources only
|
|
3444
|
+
* while it has subscribers. Equal results (by `eq`) are not re-emitted and keep their identity.
|
|
3445
|
+
*/
|
|
3446
|
+
function computed(sources, fn, eq) {
|
|
3447
|
+
const subs = /* @__PURE__ */ new Set();
|
|
3448
|
+
let cached;
|
|
3449
|
+
let stops;
|
|
3450
|
+
/** Recomputes, keeping the previous value's identity when equal. True when it changed. */
|
|
3451
|
+
const recompute = () => {
|
|
3452
|
+
const next = fn();
|
|
3453
|
+
if (cached && eq(next, cached.value)) return false;
|
|
3454
|
+
cached = { value: next };
|
|
3455
|
+
return true;
|
|
3456
|
+
};
|
|
3457
|
+
const refresh = () => {
|
|
3458
|
+
if (!recompute() || !cached) return;
|
|
3459
|
+
const value = cached.value;
|
|
3460
|
+
for (const sub of [...subs]) sub(value);
|
|
3461
|
+
};
|
|
3462
|
+
return {
|
|
3463
|
+
get: () => {
|
|
3464
|
+
if (!stops) recompute();
|
|
3465
|
+
return cached.value;
|
|
3466
|
+
},
|
|
3467
|
+
subscribe(sub) {
|
|
3468
|
+
if (!stops) {
|
|
3469
|
+
recompute();
|
|
3470
|
+
stops = sources.map((s) => s.subscribe(refresh));
|
|
3471
|
+
}
|
|
3472
|
+
subs.add(sub);
|
|
3473
|
+
return () => {
|
|
3474
|
+
if (!subs.delete(sub) || subs.size > 0) return;
|
|
3475
|
+
for (const stop of stops ?? []) stop();
|
|
3476
|
+
stops = void 0;
|
|
3477
|
+
};
|
|
3478
|
+
}
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
function createToolStyle(deps) {
|
|
3482
|
+
const { store, history, activeGroup } = deps;
|
|
3483
|
+
const groups = new Map(STYLE_GROUPS.map((g) => [g, writable({})]));
|
|
3484
|
+
const groupStore = (g) => {
|
|
3485
|
+
const w = groups.get(g);
|
|
3486
|
+
if (!w) throw new RangeError(`Unknown style group ${String(g)}`);
|
|
3487
|
+
return w;
|
|
3488
|
+
};
|
|
3489
|
+
/** The selected annotations, including changes proposed to a controlled host. */
|
|
3490
|
+
const selected = () => store.selection.get().map((id) => store._getProposed(id) ?? store.get(id)).filter((a) => a !== void 0);
|
|
3491
|
+
const sources = [
|
|
3492
|
+
store.list,
|
|
3493
|
+
store.selection,
|
|
3494
|
+
...groups.values(),
|
|
3495
|
+
...deps.active ? [deps.active] : []
|
|
3496
|
+
];
|
|
3497
|
+
/** One value per prop over `items`, `'mixed'` where they differ. */
|
|
3498
|
+
const styleOf = (items) => {
|
|
3499
|
+
const out = {};
|
|
3500
|
+
for (const a of items) {
|
|
3501
|
+
const props = fromAnnotation(a);
|
|
3502
|
+
for (const [key, value] of Object.entries(props)) if (!(key in out)) out[key] = value;
|
|
3503
|
+
else if (out[key] !== "mixed" && !sameValue(out[key], value)) out[key] = "mixed";
|
|
3504
|
+
}
|
|
3505
|
+
return out;
|
|
3506
|
+
};
|
|
3507
|
+
const propsOf = (items) => {
|
|
3508
|
+
const out = /* @__PURE__ */ new Set();
|
|
3509
|
+
for (const a of items) for (const key of supportedProps(a.type)) out.add(key);
|
|
3510
|
+
return out;
|
|
3511
|
+
};
|
|
3512
|
+
/** The selection, when it is non-empty and wholly in `group`. */
|
|
3513
|
+
const selectedIn = (group) => {
|
|
3514
|
+
const items = selected();
|
|
3515
|
+
return items.length > 0 && items.every((a) => styleGroupsOf(a).includes(group)) ? items : null;
|
|
3516
|
+
};
|
|
3517
|
+
const current = computed(sources, () => {
|
|
3518
|
+
const g = activeGroup();
|
|
3519
|
+
if (!g) return styleOf(selected());
|
|
3520
|
+
const items = selectedIn(g);
|
|
3521
|
+
return items ? styleOf(items) : { ...groupStore(g).get() };
|
|
3522
|
+
}, (a, b) => sameRecord(a, b));
|
|
3523
|
+
const supported = computed(sources, () => {
|
|
3524
|
+
const g = activeGroup();
|
|
3525
|
+
if (!g) return propsOf(selected());
|
|
3526
|
+
const items = selectedIn(g);
|
|
3527
|
+
return items ? /* @__PURE__ */ new Set([...groupProps(g), ...propsOf(items)]) : groupProps(g);
|
|
3528
|
+
}, sameSet);
|
|
3529
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3530
|
+
function scoped(group) {
|
|
3531
|
+
const known = scopes.get(group);
|
|
3532
|
+
if (known) return known;
|
|
3533
|
+
groupStore(group);
|
|
3534
|
+
const scope = {
|
|
3535
|
+
group,
|
|
3536
|
+
current: computed(sources, () => {
|
|
3537
|
+
const items = selectedIn(group);
|
|
3538
|
+
return items ? styleOf(items) : { ...groupStore(group).get() };
|
|
3539
|
+
}, (a, b) => sameRecord(a, b)),
|
|
3540
|
+
supported: computed(sources, () => {
|
|
3541
|
+
const items = selectedIn(group);
|
|
3542
|
+
return items ? propsOf(items) : groupProps(group);
|
|
3543
|
+
}, sameSet),
|
|
3544
|
+
set: (patch) => set(patch, {
|
|
3545
|
+
group,
|
|
3546
|
+
apply: selectedIn(group) !== null
|
|
3547
|
+
})
|
|
3548
|
+
};
|
|
3549
|
+
scopes.set(group, scope);
|
|
3550
|
+
return scope;
|
|
3551
|
+
}
|
|
3552
|
+
function set(patch, opts = {}) {
|
|
3553
|
+
const items = selected();
|
|
3554
|
+
const active = opts.group ?? activeGroup();
|
|
3555
|
+
const keys = Object.keys(patch).filter((k) => patch[k] !== void 0);
|
|
3556
|
+
const targets = /* @__PURE__ */ new Map();
|
|
3557
|
+
if (active) targets.set(active, new Set(keys));
|
|
3558
|
+
else for (const a of items) for (const key of keys) {
|
|
3559
|
+
if (!supportedProps(a.type).has(key)) continue;
|
|
3560
|
+
const g = styleGroupOf(a, key);
|
|
3561
|
+
if (!g) continue;
|
|
3562
|
+
const own = targets.get(g) ?? /* @__PURE__ */ new Set();
|
|
3563
|
+
own.add(key);
|
|
3564
|
+
targets.set(g, own);
|
|
3565
|
+
}
|
|
3566
|
+
for (const [g, own] of targets) {
|
|
3567
|
+
const allowed = groupProps(g);
|
|
3568
|
+
const next = { ...groupStore(g).get() };
|
|
3569
|
+
let changed = false;
|
|
3570
|
+
for (const key of own) {
|
|
3571
|
+
if (!allowed.has(key)) continue;
|
|
3572
|
+
next[key] = patch[key];
|
|
3573
|
+
changed = true;
|
|
3574
|
+
}
|
|
3575
|
+
if (changed) groupStore(g).set(next);
|
|
3576
|
+
}
|
|
3577
|
+
if (!(opts.apply ?? (active === void 0 || selectedIn(active) !== null))) return;
|
|
3578
|
+
const edits = items.filter((a) => !a.locked).map((a) => ({
|
|
3579
|
+
id: a.id,
|
|
3580
|
+
fields: refit(a, toFields(a.type, patch))
|
|
3581
|
+
})).filter((e) => Object.keys(e.fields).length > 0);
|
|
3582
|
+
if (edits.length === 0) return;
|
|
3583
|
+
history.batch(() => {
|
|
3584
|
+
for (const e of edits) store.update(e.id, e.fields);
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
/**
|
|
3588
|
+
* `fields` with a callout's `rect` and `fringe` refit when they change its border width, so
|
|
3589
|
+
* the bounds keep covering the leader, its ending and the outline.
|
|
3590
|
+
*/
|
|
3591
|
+
function refit(a, fields) {
|
|
3592
|
+
if (!("width" in fields) || !hasCallout(a)) return fields;
|
|
3593
|
+
const next = {
|
|
3594
|
+
...a,
|
|
3595
|
+
...fields
|
|
3596
|
+
};
|
|
3597
|
+
return {
|
|
3598
|
+
...fields,
|
|
3599
|
+
...withBox(next, freeTextBox(a))
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
/** `value`'s keys that hold something. */
|
|
3603
|
+
const defined = (value) => Object.fromEntries(Object.entries(value ?? {}).filter(([, v]) => v !== void 0));
|
|
3604
|
+
function resolve(group, input) {
|
|
3605
|
+
const fields = toFields(input.type, groupStore(group).get());
|
|
3606
|
+
const out = { ...input };
|
|
3607
|
+
for (const [key, value] of Object.entries(fields)) if (key === "defaultStyle") out[key] = {
|
|
3608
|
+
...defined(value),
|
|
3609
|
+
...defined(out[key])
|
|
3610
|
+
};
|
|
3611
|
+
else if (value !== void 0 && out[key] === void 0) out[key] = value;
|
|
3612
|
+
return out;
|
|
3613
|
+
}
|
|
3614
|
+
return {
|
|
3615
|
+
get: (g) => groupStore(g).get(),
|
|
3616
|
+
readable: (g) => {
|
|
3617
|
+
const w = groupStore(g);
|
|
3618
|
+
return {
|
|
3619
|
+
get: () => w.get(),
|
|
3620
|
+
subscribe: (fn) => w.subscribe(fn)
|
|
3621
|
+
};
|
|
3622
|
+
},
|
|
3623
|
+
current,
|
|
3624
|
+
supported,
|
|
3625
|
+
set,
|
|
3626
|
+
resolve,
|
|
3627
|
+
scoped
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
//#endregion
|
|
3631
|
+
//#region src/model/annotations/threads.ts
|
|
3632
|
+
function isThreadRoot(a) {
|
|
3633
|
+
return a.type === "text" || isTextMarkup(a);
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* True for a reply: an annotation whose `inReplyTo` names a thread root (a note or text markup)
|
|
3637
|
+
* in `list`. Replies show in the thread popover only, never on the page.
|
|
3638
|
+
*/
|
|
3639
|
+
function isReply(a, list) {
|
|
3640
|
+
if (a.inReplyTo === void 0) return false;
|
|
3641
|
+
const parent = list.find((x) => x.id === a.inReplyTo);
|
|
3642
|
+
return parent !== void 0 && isThreadRoot(parent);
|
|
3643
|
+
}
|
|
3644
|
+
/** `list` less its replies (see `isReply`), in the same order. */
|
|
3645
|
+
function withoutReplies(list) {
|
|
3646
|
+
const roots = new Set(list.filter(isThreadRoot).map((a) => a.id));
|
|
3647
|
+
return list.filter((a) => a.inReplyTo === void 0 || !roots.has(a.inReplyTo));
|
|
3648
|
+
}
|
|
3649
|
+
/** Direct replies to `id`, in creation order. */
|
|
3650
|
+
function repliesTo(list, id) {
|
|
3651
|
+
return list.filter((a) => a.inReplyTo === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3652
|
+
}
|
|
3653
|
+
/**
|
|
3654
|
+
* The top of a thread: follows `inReplyTo` up through notes, stopping at text markup (which
|
|
3655
|
+
* roots a thread of its own), a missing parent, a parent that cannot root a thread, or a
|
|
3656
|
+
* cycle. Null when `start` is neither a note nor text markup.
|
|
3657
|
+
*/
|
|
3658
|
+
function threadRoot(list, start) {
|
|
3659
|
+
if (!isThreadRoot(start)) return null;
|
|
3660
|
+
const seen = /* @__PURE__ */ new Set([start.id]);
|
|
3661
|
+
let current = start;
|
|
3662
|
+
while (current.type === "text" && current.inReplyTo) {
|
|
3663
|
+
const id = current.inReplyTo;
|
|
3664
|
+
const parent = list.find((a) => a.id === id);
|
|
3665
|
+
if (!parent || !isThreadRoot(parent) || seen.has(parent.id)) break;
|
|
3666
|
+
seen.add(parent.id);
|
|
3667
|
+
current = parent;
|
|
3668
|
+
}
|
|
3669
|
+
return current;
|
|
3670
|
+
}
|
|
3671
|
+
/** The root, then every reply below it, depth first, each level in creation order. */
|
|
3672
|
+
function threadOf(list, root) {
|
|
3673
|
+
const out = [root];
|
|
3674
|
+
const seen = /* @__PURE__ */ new Set([root.id]);
|
|
3675
|
+
const visit = (id) => {
|
|
3676
|
+
for (const r of repliesTo(list, id)) {
|
|
3677
|
+
if (seen.has(r.id)) continue;
|
|
3678
|
+
seen.add(r.id);
|
|
3679
|
+
out.push(r);
|
|
3680
|
+
visit(r.id);
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
3683
|
+
visit(root.id);
|
|
3684
|
+
return out;
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* An unlocked root note with no text and no replies: nothing a reader would miss. Always
|
|
3688
|
+
* false for text markup, which is worth keeping without a comment.
|
|
3689
|
+
*/
|
|
3690
|
+
function isAbandonedNote(a, list) {
|
|
3691
|
+
if (a.type !== "text" || a.locked || a.inReplyTo || a.contents?.trim()) return false;
|
|
3692
|
+
return repliesTo(list, a.id).length === 0;
|
|
3693
|
+
}
|
|
3694
|
+
//#endregion
|
|
3695
|
+
//#region src/model/annotations/shape-geometry.ts
|
|
3696
|
+
/** The kinds the shape menu offers today, in menu order. Later kinds extend this. */
|
|
3697
|
+
const SHAPE_KINDS = [
|
|
3698
|
+
"line",
|
|
3699
|
+
"arrow",
|
|
3700
|
+
"rectangle",
|
|
3701
|
+
"roundedRectangle",
|
|
3702
|
+
"ellipse",
|
|
3703
|
+
"bubble",
|
|
3704
|
+
"star",
|
|
3705
|
+
"polygon",
|
|
3706
|
+
"loupe",
|
|
3707
|
+
"mask",
|
|
3708
|
+
"ovalMask"
|
|
3709
|
+
];
|
|
3710
|
+
const DEFAULT_SHAPE_OPTIONS = {
|
|
3711
|
+
kind: "rectangle",
|
|
3712
|
+
points: 5,
|
|
3713
|
+
sides: 6
|
|
3714
|
+
};
|
|
3715
|
+
const POINTER_ONLY = /* @__PURE__ */ new Set([
|
|
3716
|
+
"loupe",
|
|
3717
|
+
"mask",
|
|
3718
|
+
"ovalMask"
|
|
3719
|
+
]);
|
|
3720
|
+
/**
|
|
3721
|
+
* True for kinds only a pointer creates: the loupe and the mask. `Enter` with the shape tool
|
|
3722
|
+
* does not place them.
|
|
3723
|
+
*/
|
|
3724
|
+
function isPointerOnlyKind(kind) {
|
|
3725
|
+
return POINTER_ONLY.has(kind);
|
|
3726
|
+
}
|
|
3727
|
+
/** How strongly a new mask dims the page. */
|
|
3728
|
+
const DEFAULT_MASK_DIM = .5;
|
|
3729
|
+
/** Side of the shape a click or Enter places, in points. */
|
|
3730
|
+
const DEFAULT_SHAPE_SIZE = 72;
|
|
3731
|
+
/** Corner radius of a new rounded rectangle, in points. */
|
|
3732
|
+
const DEFAULT_CORNER_RADIUS = 8;
|
|
3733
|
+
/** How far below and left of its anchor a new speech bubble's tail tip sits, in points. */
|
|
3734
|
+
const BUBBLE_TAIL = 24;
|
|
3735
|
+
/** A new speech bubble's outline width, in points, before the style group's applies. */
|
|
3736
|
+
const BUBBLE_WIDTH = 1;
|
|
3737
|
+
const clean = (n) => n + 0;
|
|
3738
|
+
/** The box between two corners. */
|
|
3739
|
+
function rectOf(a, b) {
|
|
3740
|
+
return {
|
|
3741
|
+
x: Math.min(a.x, b.x),
|
|
3742
|
+
y: Math.min(a.y, b.y),
|
|
3743
|
+
width: Math.abs(b.x - a.x),
|
|
3744
|
+
height: Math.abs(b.y - a.y)
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* Vertices round a circle, the first at the top centre, clockwise on the page, stretched so their
|
|
3749
|
+
* bounds fill `rect`.
|
|
3750
|
+
*/
|
|
3751
|
+
function ring(rect, count, radius) {
|
|
3752
|
+
const unit = Array.from({ length: count }, (_, i) => {
|
|
3753
|
+
const angle = i * 2 * Math.PI / count;
|
|
3754
|
+
const r = radius(i);
|
|
3755
|
+
return {
|
|
3756
|
+
x: r * Math.sin(angle),
|
|
3757
|
+
y: r * Math.cos(angle)
|
|
3758
|
+
};
|
|
3759
|
+
});
|
|
3760
|
+
const xs = unit.map((p) => p.x);
|
|
3761
|
+
const ys = unit.map((p) => p.y);
|
|
3762
|
+
const fit = (v, min, max, start, size) => clean(max > min ? start + (v - min) / (max - min) * size : start + size / 2);
|
|
3763
|
+
const [minX, maxX, minY, maxY] = [
|
|
3764
|
+
Math.min(...xs),
|
|
3765
|
+
Math.max(...xs),
|
|
3766
|
+
Math.min(...ys),
|
|
3767
|
+
Math.max(...ys)
|
|
3768
|
+
];
|
|
3769
|
+
return unit.map((p) => ({
|
|
3770
|
+
x: fit(p.x, minX, maxX, rect.x, rect.width),
|
|
3771
|
+
y: fit(p.y, minY, maxY, rect.y, rect.height)
|
|
3772
|
+
}));
|
|
3773
|
+
}
|
|
3774
|
+
/**
|
|
3775
|
+
* A star's `2 * points` vertices, outer and inner alternating, stretched to fill `rect`, the first
|
|
3776
|
+
* at the top centre.
|
|
3777
|
+
*/
|
|
3778
|
+
function starVertices(rect, points, innerRatio = .5) {
|
|
3779
|
+
return ring(rect, Math.max(2, Math.round(points)) * 2, (i) => i % 2 === 0 ? 1 : innerRatio);
|
|
3780
|
+
}
|
|
3781
|
+
/** A regular polygon's vertices, stretched to fill `rect`, the first at the top centre. */
|
|
3782
|
+
function polygonVertices(rect, sides) {
|
|
3783
|
+
return ring(rect, Math.max(3, Math.round(sides)), () => 1);
|
|
3784
|
+
}
|
|
3785
|
+
const LINE_KINDS = /* @__PURE__ */ new Set(["line", "arrow"]);
|
|
3786
|
+
/** True for kinds drawn between two endpoints rather than inside a box. */
|
|
3787
|
+
function isLineKind(kind) {
|
|
3788
|
+
return LINE_KINDS.has(kind);
|
|
3789
|
+
}
|
|
3790
|
+
const H = Math.SQRT1_2;
|
|
3791
|
+
/** Unit steps every 45 degrees, counter-clockwise from +x (y up). */
|
|
3792
|
+
const EIGHT_WAYS = [
|
|
3793
|
+
{
|
|
3794
|
+
x: 1,
|
|
3795
|
+
y: 0
|
|
3796
|
+
},
|
|
3797
|
+
{
|
|
3798
|
+
x: H,
|
|
3799
|
+
y: H
|
|
3800
|
+
},
|
|
3801
|
+
{
|
|
3802
|
+
x: 0,
|
|
3803
|
+
y: 1
|
|
3804
|
+
},
|
|
3805
|
+
{
|
|
3806
|
+
x: -H,
|
|
3807
|
+
y: H
|
|
3808
|
+
},
|
|
3809
|
+
{
|
|
3810
|
+
x: -1,
|
|
3811
|
+
y: 0
|
|
3812
|
+
},
|
|
3813
|
+
{
|
|
3814
|
+
x: -H,
|
|
3815
|
+
y: -H
|
|
3816
|
+
},
|
|
3817
|
+
{
|
|
3818
|
+
x: 0,
|
|
3819
|
+
y: -1
|
|
3820
|
+
},
|
|
3821
|
+
{
|
|
3822
|
+
x: H,
|
|
3823
|
+
y: -H
|
|
3824
|
+
}
|
|
3825
|
+
];
|
|
3826
|
+
/**
|
|
3827
|
+
* Where the drag ends with Shift held: a square or circle for box kinds, the nearest 45 degree
|
|
3828
|
+
* angle for lines.
|
|
3829
|
+
*/
|
|
3830
|
+
function constrain(kind, start, end) {
|
|
3831
|
+
const dx = end.x - start.x;
|
|
3832
|
+
const dy = end.y - start.y;
|
|
3833
|
+
if (isLineKind(kind)) {
|
|
3834
|
+
const step = Math.round(Math.atan2(dy, dx) / (Math.PI / 4));
|
|
3835
|
+
const u = EIGHT_WAYS[(step % 8 + 8) % 8];
|
|
3836
|
+
const length = dx * u.x + dy * u.y;
|
|
3837
|
+
return {
|
|
3838
|
+
x: clean(start.x + length * u.x),
|
|
3839
|
+
y: clean(start.y + length * u.y)
|
|
3840
|
+
};
|
|
3841
|
+
}
|
|
3842
|
+
const size = Math.max(Math.abs(dx), Math.abs(dy));
|
|
3843
|
+
return {
|
|
3844
|
+
x: clean(start.x + (dx < 0 ? -size : size)),
|
|
3845
|
+
y: clean(start.y + (dy < 0 ? -size : size))
|
|
3846
|
+
};
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* The two drag corners of the default-size shape centred on `at`. A line or arrow runs left to
|
|
3850
|
+
* right on the page as the file displays it: along user space turned anticlockwise by the
|
|
3851
|
+
* page's /Rotate `rotation`, which undoes the page's clockwise turn, as a signature is placed.
|
|
3852
|
+
*/
|
|
3853
|
+
function defaultEnds(kind, at, rotation = 0) {
|
|
3854
|
+
const half = 36;
|
|
3855
|
+
if (!isLineKind(kind)) return [{
|
|
3856
|
+
x: at.x - half,
|
|
3857
|
+
y: at.y - half
|
|
3858
|
+
}, {
|
|
3859
|
+
x: at.x + half,
|
|
3860
|
+
y: at.y + half
|
|
3861
|
+
}];
|
|
3862
|
+
const rad = rotation * Math.PI / 180;
|
|
3863
|
+
const dx = Math.round(Math.cos(rad)) * half;
|
|
3864
|
+
const dy = Math.round(Math.sin(rad)) * half;
|
|
3865
|
+
return [{
|
|
3866
|
+
x: clean(at.x - dx),
|
|
3867
|
+
y: clean(at.y - dy)
|
|
3868
|
+
}, {
|
|
3869
|
+
x: clean(at.x + dx),
|
|
3870
|
+
y: clean(at.y + dy)
|
|
3871
|
+
}];
|
|
3872
|
+
}
|
|
3873
|
+
/** The annotation a shape kind creates between two points, or `undefined` for a kind without a creator yet. */
|
|
3874
|
+
function shapeInput(kind, page, start, end, opts) {
|
|
3875
|
+
const rect = rectOf(start, end);
|
|
3876
|
+
switch (kind) {
|
|
3877
|
+
case "line": return {
|
|
3878
|
+
type: "line",
|
|
3879
|
+
page,
|
|
3880
|
+
start,
|
|
3881
|
+
end
|
|
3882
|
+
};
|
|
3883
|
+
case "arrow": return {
|
|
3884
|
+
type: "line",
|
|
3885
|
+
page,
|
|
3886
|
+
start,
|
|
3887
|
+
end,
|
|
3888
|
+
intent: "LineArrow"
|
|
3889
|
+
};
|
|
3890
|
+
case "rectangle": return {
|
|
3891
|
+
type: "square",
|
|
3892
|
+
page,
|
|
3893
|
+
rect
|
|
3894
|
+
};
|
|
3895
|
+
case "roundedRectangle": return {
|
|
3896
|
+
type: "square",
|
|
3897
|
+
page,
|
|
3898
|
+
rect,
|
|
3899
|
+
cornerRadius: 8
|
|
3900
|
+
};
|
|
3901
|
+
case "ellipse": return {
|
|
3902
|
+
type: "circle",
|
|
3903
|
+
page,
|
|
3904
|
+
rect
|
|
3905
|
+
};
|
|
3906
|
+
case "bubble": {
|
|
3907
|
+
const edge = {
|
|
3908
|
+
x: clean(rect.x + rect.width / 4),
|
|
3909
|
+
y: clean(rect.y)
|
|
3910
|
+
};
|
|
3911
|
+
const callout = [{
|
|
3912
|
+
x: clean(edge.x - BUBBLE_TAIL),
|
|
3913
|
+
y: clean(edge.y - BUBBLE_TAIL)
|
|
3914
|
+
}, edge];
|
|
3915
|
+
return {
|
|
3916
|
+
type: "freetext",
|
|
3917
|
+
page,
|
|
3918
|
+
contents: "",
|
|
3919
|
+
intent: "FreeTextCallout",
|
|
3920
|
+
cornerRadius: 8,
|
|
3921
|
+
width: BUBBLE_WIDTH,
|
|
3922
|
+
...withCalloutBounds(rect, callout, BUBBLE_WIDTH, void 0),
|
|
3923
|
+
callout
|
|
3924
|
+
};
|
|
3925
|
+
}
|
|
3926
|
+
case "star": return {
|
|
3927
|
+
type: "polygon",
|
|
3928
|
+
page,
|
|
3929
|
+
vertices: starVertices(rect, opts.points)
|
|
3930
|
+
};
|
|
3931
|
+
case "polygon": return {
|
|
3932
|
+
type: "polygon",
|
|
3933
|
+
page,
|
|
3934
|
+
vertices: polygonVertices(rect, opts.sides)
|
|
3935
|
+
};
|
|
3936
|
+
case "loupe": return {
|
|
3937
|
+
type: "loupe",
|
|
3938
|
+
page,
|
|
3939
|
+
rect: rectOf(start, constrain(kind, start, end)),
|
|
3940
|
+
magnification: 2
|
|
3941
|
+
};
|
|
3942
|
+
case "mask":
|
|
3943
|
+
case "ovalMask": return {
|
|
3944
|
+
type: "mask",
|
|
3945
|
+
page,
|
|
3946
|
+
rect,
|
|
3947
|
+
shape: kind === "mask" ? "rect" : "ellipse",
|
|
3948
|
+
dim: DEFAULT_MASK_DIM
|
|
3949
|
+
};
|
|
3950
|
+
default: return;
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
//#endregion
|
|
3954
|
+
//#region src/model/recognise.ts
|
|
3955
|
+
/** The score a fit needs before a stroke is tidied into its shape. */
|
|
3956
|
+
const RECOGNISE_THRESHOLD = .8;
|
|
3957
|
+
/** Samples either side of a point that its turn is measured over. */
|
|
3958
|
+
const W = 3;
|
|
3959
|
+
/** Turns, over `2 * W` samples, that make a corner: outward, and back against the loop. */
|
|
3960
|
+
const CONVEX_TURN = 40 * Math.PI / 180;
|
|
3961
|
+
const CONCAVE_TURN = 15 * Math.PI / 180;
|
|
3962
|
+
/** Resampled points along the box diagonal. */
|
|
3963
|
+
const DENSITY = 50;
|
|
3964
|
+
/** A run of path is straight when its chord is at least this share of its length. */
|
|
3965
|
+
const STRAIGHT = .95;
|
|
3966
|
+
/** Corners whose neighbours this straight run through them (a turn under about 23 degrees) go. */
|
|
3967
|
+
const MERGE_STRAIGHT = .98;
|
|
3968
|
+
/** The ends of a closed stroke are within this share of the box diagonal. */
|
|
3969
|
+
const CLOSE_GAP = .25;
|
|
3970
|
+
/** Weights of a line's largest and mean straying from its chord, over its length. */
|
|
3971
|
+
const LINE_MAX = 2;
|
|
3972
|
+
const LINE_MEAN = 2;
|
|
3973
|
+
/** Weight of a polygon side's mean straying from its chord, over its length. */
|
|
3974
|
+
const SIDE_MEAN = 3;
|
|
3975
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
3976
|
+
const clamp01 = (n) => Math.min(Math.max(n, 0), 1);
|
|
3977
|
+
const round = (n) => Math.round(n * 100) / 100 + 0;
|
|
3978
|
+
const pt = (p) => ({
|
|
3979
|
+
x: round(p.x),
|
|
3980
|
+
y: round(p.y)
|
|
3981
|
+
});
|
|
3982
|
+
function boxOf(points) {
|
|
3983
|
+
let minX = Infinity;
|
|
3984
|
+
let minY = Infinity;
|
|
3985
|
+
let maxX = -Infinity;
|
|
3986
|
+
let maxY = -Infinity;
|
|
3987
|
+
for (const p of points) {
|
|
3988
|
+
minX = Math.min(minX, p.x);
|
|
3989
|
+
minY = Math.min(minY, p.y);
|
|
3990
|
+
maxX = Math.max(maxX, p.x);
|
|
3991
|
+
maxY = Math.max(maxY, p.y);
|
|
3992
|
+
}
|
|
3993
|
+
return {
|
|
3994
|
+
x: minX,
|
|
3995
|
+
y: minY,
|
|
3996
|
+
width: maxX - minX,
|
|
3997
|
+
height: maxY - minY
|
|
3998
|
+
};
|
|
3999
|
+
}
|
|
4000
|
+
const diagonal = (r) => Math.hypot(r.width, r.height);
|
|
4001
|
+
function lengthOf(points) {
|
|
4002
|
+
let total = 0;
|
|
4003
|
+
for (let i = 1; i < points.length; i++) total += dist(points[i - 1], points[i]);
|
|
4004
|
+
return total;
|
|
4005
|
+
}
|
|
4006
|
+
/** Points every `spacing` along the path, the first and last included. */
|
|
4007
|
+
function resample(points, spacing) {
|
|
4008
|
+
const out = [points[0]];
|
|
4009
|
+
let carried = 0;
|
|
4010
|
+
let prev = points[0];
|
|
4011
|
+
for (let i = 1; i < points.length; i++) {
|
|
4012
|
+
const next = points[i];
|
|
4013
|
+
let d = dist(prev, next);
|
|
4014
|
+
while (carried + d >= spacing) {
|
|
4015
|
+
const t = (spacing - carried) / d;
|
|
4016
|
+
const q = {
|
|
4017
|
+
x: prev.x + t * (next.x - prev.x),
|
|
4018
|
+
y: prev.y + t * (next.y - prev.y)
|
|
4019
|
+
};
|
|
4020
|
+
out.push(q);
|
|
4021
|
+
prev = q;
|
|
4022
|
+
d = dist(prev, next);
|
|
4023
|
+
carried = 0;
|
|
4024
|
+
}
|
|
4025
|
+
carried += d;
|
|
4026
|
+
prev = next;
|
|
4027
|
+
}
|
|
4028
|
+
const last = points[points.length - 1];
|
|
4029
|
+
if (dist(out[out.length - 1], last) > spacing / 2) out.push(last);
|
|
4030
|
+
else out[out.length - 1] = last;
|
|
4031
|
+
return out;
|
|
4032
|
+
}
|
|
4033
|
+
/** Distance from `p` to the segment `a`–`b`. */
|
|
4034
|
+
function toSegment(p, a, b) {
|
|
4035
|
+
const dx = b.x - a.x;
|
|
4036
|
+
const dy = b.y - a.y;
|
|
4037
|
+
const len2 = dx * dx + dy * dy;
|
|
4038
|
+
const t = len2 === 0 ? 0 : clamp01(((p.x - a.x) * dx + (p.y - a.y) * dy) / len2);
|
|
4039
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
4040
|
+
}
|
|
4041
|
+
/** Path lengths from the first point: `cum[i]` is the length up to point `i`. */
|
|
4042
|
+
function cumulative(points) {
|
|
4043
|
+
const cum = [0];
|
|
4044
|
+
for (let i = 1; i < points.length; i++) cum.push(cum[i - 1] + dist(points[i - 1], points[i]));
|
|
4045
|
+
return cum;
|
|
4046
|
+
}
|
|
4047
|
+
/**
|
|
4048
|
+
* Corner indices of the loop `r` (its last point joins its first), in order round it. A corner
|
|
4049
|
+
* is where the path turns sharply over a few samples: outward by more than `CONVEX_TURN`, or
|
|
4050
|
+
* back against the loop's own direction (a star's inner corner) by more than `CONCAVE_TURN`.
|
|
4051
|
+
*/
|
|
4052
|
+
function cornersOf(r) {
|
|
4053
|
+
const n = r.length;
|
|
4054
|
+
if (n < 8) return [];
|
|
4055
|
+
const at = (i) => r[(i % n + n) % n];
|
|
4056
|
+
const direction = Math.sign(winding(r, centroid(r))) || 1;
|
|
4057
|
+
const turns = r.map((p, i) => {
|
|
4058
|
+
const a = at(i - W);
|
|
4059
|
+
const b = at(i + W);
|
|
4060
|
+
const turn = Math.atan2(b.y - p.y, b.x - p.x) - Math.atan2(p.y - a.y, p.x - a.x);
|
|
4061
|
+
return direction * (turn > Math.PI ? turn - 2 * Math.PI : turn < -Math.PI ? turn + 2 * Math.PI : turn);
|
|
4062
|
+
});
|
|
4063
|
+
/** 1 for an outward corner, -1 for an inward one, 0 for none. */
|
|
4064
|
+
const kind = (i) => turns[i] > CONVEX_TURN ? 1 : turns[i] < -CONCAVE_TURN ? -1 : 0;
|
|
4065
|
+
const start = turns.findIndex((_, i) => kind(i) === 0);
|
|
4066
|
+
if (start < 0) return [];
|
|
4067
|
+
const corners = [];
|
|
4068
|
+
let best;
|
|
4069
|
+
for (let k = 0; k <= n; k++) {
|
|
4070
|
+
const i = (start + k) % n;
|
|
4071
|
+
const c = k < n ? kind(i) : 0;
|
|
4072
|
+
if (best !== void 0 && c !== kind(best)) {
|
|
4073
|
+
corners.push(best);
|
|
4074
|
+
best = void 0;
|
|
4075
|
+
}
|
|
4076
|
+
if (c !== 0 && (best === void 0 || Math.abs(turns[i]) > Math.abs(turns[best]))) best = i;
|
|
4077
|
+
}
|
|
4078
|
+
corners.sort((a, b) => a - b);
|
|
4079
|
+
for (let k = 0; corners.length > 1 && k < corners.length;) {
|
|
4080
|
+
const next = (k + 1) % corners.length;
|
|
4081
|
+
if ((corners[next] - corners[k] + n) % n > 6) k++;
|
|
4082
|
+
else corners.splice(Math.abs(turns[corners[k]]) < Math.abs(turns[corners[next]]) ? k : next, 1);
|
|
4083
|
+
}
|
|
4084
|
+
return mergeStraight(r, corners);
|
|
4085
|
+
}
|
|
4086
|
+
/** Drops corners whose neighbours run straight through them, the straightest first. */
|
|
4087
|
+
function mergeStraight(r, corners) {
|
|
4088
|
+
const cum = cumulative(r);
|
|
4089
|
+
const total = cum[cum.length - 1] + dist(r[r.length - 1], r[0]);
|
|
4090
|
+
const arc = (a, b) => b > a ? cum[b] - cum[a] : total - cum[a] + cum[b];
|
|
4091
|
+
const out = [...corners];
|
|
4092
|
+
while (out.length > 3) {
|
|
4093
|
+
let drop = -1;
|
|
4094
|
+
let straightest = MERGE_STRAIGHT;
|
|
4095
|
+
for (let k = 0; k < out.length; k++) {
|
|
4096
|
+
const p = out[(k - 1 + out.length) % out.length];
|
|
4097
|
+
const q = out[(k + 1) % out.length];
|
|
4098
|
+
const a = arc(p, q);
|
|
4099
|
+
const ratio = a === 0 ? 1 : dist(r[p], r[q]) / a;
|
|
4100
|
+
if (ratio > straightest) {
|
|
4101
|
+
straightest = ratio;
|
|
4102
|
+
drop = k;
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
if (drop < 0) break;
|
|
4106
|
+
out.splice(drop, 1);
|
|
4107
|
+
}
|
|
4108
|
+
return out;
|
|
4109
|
+
}
|
|
4110
|
+
/** Times the path winds round `c`, signed. */
|
|
4111
|
+
function winding(points, c) {
|
|
4112
|
+
let total = 0;
|
|
4113
|
+
for (let i = 0; i < points.length; i++) {
|
|
4114
|
+
const a = points[i];
|
|
4115
|
+
const b = points[(i + 1) % points.length];
|
|
4116
|
+
let d = Math.atan2(b.y - c.y, b.x - c.x) - Math.atan2(a.y - c.y, a.x - c.x);
|
|
4117
|
+
if (d > Math.PI) d -= 2 * Math.PI;
|
|
4118
|
+
if (d < -Math.PI) d += 2 * Math.PI;
|
|
4119
|
+
total += d;
|
|
4120
|
+
}
|
|
4121
|
+
return total / (2 * Math.PI);
|
|
4122
|
+
}
|
|
4123
|
+
/** Largest and mean distance of `points` from the line through `a` and `b`, over `|ab|`. */
|
|
4124
|
+
function deviation(points, a, b) {
|
|
4125
|
+
const len = dist(a, b);
|
|
4126
|
+
if (len === 0 || points.length === 0) return {
|
|
4127
|
+
max: Infinity,
|
|
4128
|
+
mean: Infinity
|
|
4129
|
+
};
|
|
4130
|
+
let max = 0;
|
|
4131
|
+
let sum = 0;
|
|
4132
|
+
for (const p of points) {
|
|
4133
|
+
const d = Math.abs((b.x - a.x) * (a.y - p.y) - (a.x - p.x) * (b.y - a.y)) / len;
|
|
4134
|
+
max = Math.max(max, d);
|
|
4135
|
+
sum += d;
|
|
4136
|
+
}
|
|
4137
|
+
return {
|
|
4138
|
+
max: max / len,
|
|
4139
|
+
mean: sum / points.length / len
|
|
4140
|
+
};
|
|
4141
|
+
}
|
|
4142
|
+
/** How straight a run from `a` to `b` is: 1 on the chord, falling as the path strays from it. */
|
|
4143
|
+
function straightness(points, a, b) {
|
|
4144
|
+
const dev = deviation(points, a, b);
|
|
4145
|
+
const bend = 1 - dist(a, b) / lengthOf(points);
|
|
4146
|
+
return clamp01(1 - LINE_MAX * dev.max - LINE_MEAN * dev.mean - 2 * bend);
|
|
4147
|
+
}
|
|
4148
|
+
function fitLine(r) {
|
|
4149
|
+
const a = r[0];
|
|
4150
|
+
const b = r[r.length - 1];
|
|
4151
|
+
if (dist(a, b) / lengthOf(r) < .8) return void 0;
|
|
4152
|
+
return {
|
|
4153
|
+
shape: {
|
|
4154
|
+
kind: "line",
|
|
4155
|
+
start: pt(a),
|
|
4156
|
+
end: pt(b)
|
|
4157
|
+
},
|
|
4158
|
+
score: straightness(r, a, b)
|
|
4159
|
+
};
|
|
4160
|
+
}
|
|
4161
|
+
/** An arrow whose head is drawn at the end of `r`: a straight shaft, then a short fold back. */
|
|
4162
|
+
function fitArrow(r) {
|
|
4163
|
+
const from = r.map((p) => dist(p, r[0]));
|
|
4164
|
+
const far = Math.max(...from);
|
|
4165
|
+
let tipAt = from.findIndex((d) => d >= .97 * far);
|
|
4166
|
+
while (tipAt + 1 < r.length && from[tipAt + 1] > from[tipAt]) tipAt++;
|
|
4167
|
+
const shaft = r.slice(0, tipAt + 1);
|
|
4168
|
+
const head = r.slice(tipAt + 1);
|
|
4169
|
+
const tail = r[0];
|
|
4170
|
+
const tip = r[tipAt];
|
|
4171
|
+
const shaftLength = lengthOf(shaft);
|
|
4172
|
+
const headLength = lengthOf([tip, ...head]);
|
|
4173
|
+
if (shaftLength < .6 * (shaftLength + headLength) || headLength < .06 * shaftLength) return void 0;
|
|
4174
|
+
const along = {
|
|
4175
|
+
x: (tip.x - tail.x) / shaftLength,
|
|
4176
|
+
y: (tip.y - tail.y) / shaftLength
|
|
4177
|
+
};
|
|
4178
|
+
const chord = dist(tail, tip);
|
|
4179
|
+
if (chord / shaftLength < STRAIGHT) return void 0;
|
|
4180
|
+
let off = 0;
|
|
4181
|
+
for (const p of head) {
|
|
4182
|
+
if (dist(p, tip) > .4 * chord) return void 0;
|
|
4183
|
+
if ((p.x - tip.x) * along.x + (p.y - tip.y) * along.y > .08 * chord) return void 0;
|
|
4184
|
+
off = Math.max(off, Math.abs((p.x - tip.x) * along.y - (p.y - tip.y) * along.x));
|
|
4185
|
+
}
|
|
4186
|
+
if (off < .04 * chord) return void 0;
|
|
4187
|
+
return {
|
|
4188
|
+
shape: {
|
|
4189
|
+
kind: "arrow",
|
|
4190
|
+
start: pt(tail),
|
|
4191
|
+
end: pt(tip)
|
|
4192
|
+
},
|
|
4193
|
+
score: straightness(shaft, tail, tip)
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
/**
|
|
4197
|
+
* The stroke as a loop, evenly spaced all the way round (the gap between its ends bridged), an
|
|
4198
|
+
* overshoot past its start trimmed; undefined when the stroke is open.
|
|
4199
|
+
*/
|
|
4200
|
+
function loopOf(r, size, spacing) {
|
|
4201
|
+
const n = r.length;
|
|
4202
|
+
if (n < 8) return void 0;
|
|
4203
|
+
let end = n - 1;
|
|
4204
|
+
for (let i = Math.floor(n / 2); i < n; i++) if (dist(r[i], r[0]) < dist(r[end], r[0])) end = i;
|
|
4205
|
+
if (dist(r[end], r[0]) > CLOSE_GAP * size || n - 1 - end > .25 * n) return void 0;
|
|
4206
|
+
const loop = resample([...r.slice(0, end + 1), r[0]], spacing);
|
|
4207
|
+
loop.pop();
|
|
4208
|
+
return loop;
|
|
4209
|
+
}
|
|
4210
|
+
function fitEllipse(loop, box) {
|
|
4211
|
+
const rx = box.width / 2;
|
|
4212
|
+
const ry = box.height / 2;
|
|
4213
|
+
if (rx === 0 || ry === 0) return void 0;
|
|
4214
|
+
const cx = box.x + rx;
|
|
4215
|
+
const cy = box.y + ry;
|
|
4216
|
+
let sum = 0;
|
|
4217
|
+
for (const p of loop) sum += Math.abs(Math.hypot((p.x - cx) / rx, (p.y - cy) / ry) - 1);
|
|
4218
|
+
const mean = sum / loop.length;
|
|
4219
|
+
return {
|
|
4220
|
+
shape: {
|
|
4221
|
+
kind: "ellipse",
|
|
4222
|
+
rect: {
|
|
4223
|
+
x: round(box.x),
|
|
4224
|
+
y: round(box.y),
|
|
4225
|
+
width: round(box.width),
|
|
4226
|
+
height: round(box.height)
|
|
4227
|
+
}
|
|
4228
|
+
},
|
|
4229
|
+
score: clamp01(1 - 3 * mean)
|
|
4230
|
+
};
|
|
4231
|
+
}
|
|
4232
|
+
/** How closely the loop follows the polygon through `vertices`: 1 on it, falling with distance. */
|
|
4233
|
+
function polygonFit(loop, vertices, size) {
|
|
4234
|
+
let sum = 0;
|
|
4235
|
+
for (const p of loop) {
|
|
4236
|
+
let best = Infinity;
|
|
4237
|
+
for (let i = 0; i < vertices.length; i++) best = Math.min(best, toSegment(p, vertices[i], vertices[(i + 1) % vertices.length]));
|
|
4238
|
+
sum += best;
|
|
4239
|
+
}
|
|
4240
|
+
return clamp01(1 - 12 * sum / loop.length / size);
|
|
4241
|
+
}
|
|
4242
|
+
/** Within `tolerance` radians of horizontal (0) or vertical (1); else -1. */
|
|
4243
|
+
function axisOf(a, b, tolerance) {
|
|
4244
|
+
const angle = Math.abs(Math.atan2(b.y - a.y, b.x - a.x)) % Math.PI;
|
|
4245
|
+
const fromH = Math.min(angle, Math.PI - angle);
|
|
4246
|
+
const fromV = Math.abs(angle - Math.PI / 2);
|
|
4247
|
+
if (fromH <= tolerance) return {
|
|
4248
|
+
axis: 0,
|
|
4249
|
+
off: fromH
|
|
4250
|
+
};
|
|
4251
|
+
if (fromV <= tolerance) return {
|
|
4252
|
+
axis: 1,
|
|
4253
|
+
off: fromV
|
|
4254
|
+
};
|
|
4255
|
+
return {
|
|
4256
|
+
axis: -1,
|
|
4257
|
+
off: Math.PI / 4
|
|
4258
|
+
};
|
|
4259
|
+
}
|
|
4260
|
+
function fitRectangle(vertices, fit) {
|
|
4261
|
+
if (vertices.length !== 4) return void 0;
|
|
4262
|
+
const tolerance = 20 * Math.PI / 180;
|
|
4263
|
+
let worst = 0;
|
|
4264
|
+
for (let i = 0; i < 4; i++) {
|
|
4265
|
+
const side = axisOf(vertices[i], vertices[(i + 1) % 4], tolerance);
|
|
4266
|
+
if (side.axis < 0 || side.axis !== (axisOf(vertices[0], vertices[1], tolerance).axis + i) % 2) return void 0;
|
|
4267
|
+
worst = Math.max(worst, side.off);
|
|
4268
|
+
}
|
|
4269
|
+
const xs = vertices.map((v) => v.x).sort((a, b) => a - b);
|
|
4270
|
+
const ys = vertices.map((v) => v.y).sort((a, b) => a - b);
|
|
4271
|
+
const left = (xs[0] + xs[1]) / 2;
|
|
4272
|
+
const right = (xs[2] + xs[3]) / 2;
|
|
4273
|
+
const bottom = (ys[0] + ys[1]) / 2;
|
|
4274
|
+
const top = (ys[2] + ys[3]) / 2;
|
|
4275
|
+
return {
|
|
4276
|
+
shape: {
|
|
4277
|
+
kind: "rectangle",
|
|
4278
|
+
rect: {
|
|
4279
|
+
x: round(left),
|
|
4280
|
+
y: round(bottom),
|
|
4281
|
+
width: round(right - left),
|
|
4282
|
+
height: round(top - bottom)
|
|
4283
|
+
}
|
|
4284
|
+
},
|
|
4285
|
+
score: clamp01(fit - worst / tolerance / 10)
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
/** The shape tool's star of `points` tips round `c`, tips at `radius`, the first at the top. */
|
|
4289
|
+
function regularStar(c, points, radius) {
|
|
4290
|
+
return starVertices({
|
|
4291
|
+
x: c.x - radius,
|
|
4292
|
+
y: c.y - radius,
|
|
4293
|
+
width: 2 * radius,
|
|
4294
|
+
height: 2 * radius
|
|
4295
|
+
}, points).map(pt);
|
|
4296
|
+
}
|
|
4297
|
+
const centroid = (points) => ({
|
|
4298
|
+
x: points.reduce((s, p) => s + p.x, 0) / points.length,
|
|
4299
|
+
y: points.reduce((s, p) => s + p.y, 0) / points.length
|
|
4300
|
+
});
|
|
4301
|
+
/** An outline star: an even number of corners alternating far from and near the centre. */
|
|
4302
|
+
function fitStar(vertices, fit) {
|
|
4303
|
+
const k = vertices.length;
|
|
4304
|
+
if (k % 2 !== 0 || k < 8 || k > 16) return void 0;
|
|
4305
|
+
const c = centroid(vertices);
|
|
4306
|
+
const radii = vertices.map((v) => dist(v, c));
|
|
4307
|
+
const even = radii.filter((_, i) => i % 2 === 0);
|
|
4308
|
+
const odd = radii.filter((_, i) => i % 2 === 1);
|
|
4309
|
+
const [outer, inner] = Math.min(...even) > Math.max(...odd) ? [even, odd] : [odd, even];
|
|
4310
|
+
if (Math.min(...outer) <= Math.max(...inner)) return void 0;
|
|
4311
|
+
const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
|
|
4312
|
+
if (mean(inner) / mean(outer) > .75) return void 0;
|
|
4313
|
+
const spread = (Math.max(...outer) - Math.min(...outer)) / mean(outer);
|
|
4314
|
+
return {
|
|
4315
|
+
shape: {
|
|
4316
|
+
kind: "star",
|
|
4317
|
+
vertices: regularStar(c, k / 2, mean(outer))
|
|
4318
|
+
},
|
|
4319
|
+
score: clamp01(fit - Math.max(0, spread - .2))
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
/** A pentagram: five straight strokes that wind twice round the centre. */
|
|
4323
|
+
function fitPentagram(vertices, loop, size) {
|
|
4324
|
+
if (vertices.length !== 5) return void 0;
|
|
4325
|
+
const c = centroid(vertices);
|
|
4326
|
+
const turns = Math.abs(winding(loop, c));
|
|
4327
|
+
if (turns < 1.6 || turns > 2.4) return void 0;
|
|
4328
|
+
const radii = vertices.map((v) => dist(v, c));
|
|
4329
|
+
const mean = radii.reduce((s, x) => s + x, 0) / radii.length;
|
|
4330
|
+
const spread = (Math.max(...radii) - Math.min(...radii)) / mean;
|
|
4331
|
+
return {
|
|
4332
|
+
shape: {
|
|
4333
|
+
kind: "star",
|
|
4334
|
+
vertices: regularStar(c, 5, mean)
|
|
4335
|
+
},
|
|
4336
|
+
score: clamp01(polygonFit(loop, vertices, size) - Math.max(0, spread - .2))
|
|
4337
|
+
};
|
|
4338
|
+
}
|
|
4339
|
+
/** True when two sides of the polygon through `v` cross. */
|
|
4340
|
+
function crosses(v) {
|
|
4341
|
+
const k = v.length;
|
|
4342
|
+
const side = (a, b, c) => Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
|
|
4343
|
+
for (let i = 0; i < k; i++) for (let j = i + 2; j < k; j++) {
|
|
4344
|
+
if (i === 0 && j === k - 1) continue;
|
|
4345
|
+
const [a, b, c, d] = [
|
|
4346
|
+
v[i],
|
|
4347
|
+
v[(i + 1) % k],
|
|
4348
|
+
v[j],
|
|
4349
|
+
v[(j + 1) % k]
|
|
4350
|
+
];
|
|
4351
|
+
if (side(a, b, c) * side(a, b, d) < 0 && side(c, d, a) * side(c, d, b) < 0) return true;
|
|
4352
|
+
}
|
|
4353
|
+
return false;
|
|
4354
|
+
}
|
|
4355
|
+
/** How straight the loop runs between consecutive corners: the worst side's score. */
|
|
4356
|
+
function sidesFit(loop, corners) {
|
|
4357
|
+
let worst = 1;
|
|
4358
|
+
for (let k = 0; k < corners.length; k++) {
|
|
4359
|
+
const from = corners[k];
|
|
4360
|
+
const to = corners[(k + 1) % corners.length];
|
|
4361
|
+
const side = to > from ? loop.slice(from, to + 1) : [...loop.slice(from), ...loop.slice(0, to + 1)];
|
|
4362
|
+
const a = side[0];
|
|
4363
|
+
const b = side[side.length - 1];
|
|
4364
|
+
const bend = 1 - dist(a, b) / lengthOf(side);
|
|
4365
|
+
worst = Math.min(worst, clamp01(1 - SIDE_MEAN * deviation(side, a, b).mean - 2 * bend));
|
|
4366
|
+
}
|
|
4367
|
+
return worst;
|
|
4368
|
+
}
|
|
4369
|
+
function fitClosed(loop, size) {
|
|
4370
|
+
const box = boxOf(loop);
|
|
4371
|
+
const fits = [];
|
|
4372
|
+
const corners = cornersOf(loop);
|
|
4373
|
+
const vertices = corners.map((i) => loop[i]);
|
|
4374
|
+
const pentagram = fitPentagram(vertices, loop, size);
|
|
4375
|
+
if (pentagram) fits.push(pentagram);
|
|
4376
|
+
const turns = Math.abs(winding(loop, {
|
|
4377
|
+
x: box.x + box.width / 2,
|
|
4378
|
+
y: box.y + box.height / 2
|
|
4379
|
+
}));
|
|
4380
|
+
if (turns < .8 || turns > 1.25) return fits;
|
|
4381
|
+
const ellipse = fitEllipse(loop, box);
|
|
4382
|
+
if (ellipse) fits.push(ellipse);
|
|
4383
|
+
if (vertices.length < 3 || crosses(vertices)) return fits;
|
|
4384
|
+
const fit = polygonFit(loop, vertices, size);
|
|
4385
|
+
const rectangle = fitRectangle(vertices, fit);
|
|
4386
|
+
if (rectangle) fits.push(rectangle);
|
|
4387
|
+
else if (vertices.length <= 8) fits.push({
|
|
4388
|
+
shape: {
|
|
4389
|
+
kind: "polygon",
|
|
4390
|
+
vertices: vertices.map(pt)
|
|
4391
|
+
},
|
|
4392
|
+
score: Math.min(fit, sidesFit(loop, corners))
|
|
4393
|
+
});
|
|
4394
|
+
const star = fitStar(vertices, fit);
|
|
4395
|
+
if (star) fits.push(star);
|
|
4396
|
+
return fits;
|
|
4397
|
+
}
|
|
4398
|
+
/**
|
|
4399
|
+
* The standard shape a single freehand stroke (user space points, in drawing order) is closest
|
|
4400
|
+
* to, and how well it fits, from 0 to 1. Tidy the stroke only when the score reaches
|
|
4401
|
+
* `RECOGNISE_THRESHOLD`. Undefined when no shape fits at all.
|
|
4402
|
+
*/
|
|
4403
|
+
function recogniseStroke(points) {
|
|
4404
|
+
const clean = points.filter((p, i) => Number.isFinite(p.x) && Number.isFinite(p.y) && (i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y));
|
|
4405
|
+
if (clean.length < 2) return void 0;
|
|
4406
|
+
const size = diagonal(boxOf(clean));
|
|
4407
|
+
if (size === 0) return void 0;
|
|
4408
|
+
const spacing = Math.max(size / DENSITY, lengthOf(clean) / 1e3);
|
|
4409
|
+
const r = resample(clean, spacing);
|
|
4410
|
+
if (r.length < 2) return void 0;
|
|
4411
|
+
const fits = [];
|
|
4412
|
+
const line = fitLine(r);
|
|
4413
|
+
if (line) fits.push(line);
|
|
4414
|
+
const loop = loopOf(r, size, spacing);
|
|
4415
|
+
if (loop) fits.push(...fitClosed(loop, size));
|
|
4416
|
+
else for (const f of [fitArrow(r), fitArrow([...r].reverse())]) if (f) fits.push(f);
|
|
4417
|
+
let best;
|
|
4418
|
+
for (const f of fits) if (!best || f.score > best.score) best = f;
|
|
4419
|
+
return best && {
|
|
4420
|
+
shape: best.shape,
|
|
4421
|
+
score: round(best.score)
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
//#endregion
|
|
4425
|
+
//#region src/model/signatures.ts
|
|
4426
|
+
/** How wide a placed signature is, in points. */
|
|
4427
|
+
const DEFAULT_SIGNATURE_WIDTH = 150;
|
|
4428
|
+
/** A placed ink signature's stroke width, in points. */
|
|
4429
|
+
const SIGNATURE_INK_WIDTH = 2;
|
|
4430
|
+
const Aspect = z.number().positive().finite();
|
|
4431
|
+
const TemplateSchema = z.discriminatedUnion("kind", [z.object({
|
|
4432
|
+
id: z.string().min(1),
|
|
4433
|
+
kind: z.literal("ink"),
|
|
4434
|
+
inklist: z.array(z.array(PointSchema).min(1)).min(1),
|
|
4435
|
+
pressure: z.array(z.array(z.number().min(0).max(1))).optional(),
|
|
4436
|
+
aspect: Aspect
|
|
4437
|
+
}), z.object({
|
|
4438
|
+
id: z.string().min(1),
|
|
4439
|
+
kind: z.literal("image"),
|
|
4440
|
+
imagedata: z.string().regex(IMAGE_DATA_URL),
|
|
4441
|
+
aspect: Aspect
|
|
4442
|
+
})]).refine((t) => t.kind !== "ink" || !t.pressure || t.pressure.length === t.inklist.length && t.pressure.every((p, i) => p.length === t.inklist[i]?.length), {
|
|
4443
|
+
message: "pressure must have the same shape as inklist",
|
|
4444
|
+
path: ["pressure"]
|
|
4445
|
+
});
|
|
4446
|
+
/** The items that are valid templates; the rest are skipped. */
|
|
4447
|
+
function validTemplates(items) {
|
|
4448
|
+
return items.flatMap((item) => {
|
|
4449
|
+
const t = TemplateSchema.safeParse(item);
|
|
4450
|
+
return t.success ? [t.data] : [];
|
|
4451
|
+
});
|
|
4452
|
+
}
|
|
4453
|
+
const copy = (t) => JSON.parse(JSON.stringify(t));
|
|
4454
|
+
function upsert(list, t) {
|
|
4455
|
+
const i = list.findIndex((x) => x.id === t.id);
|
|
4456
|
+
return i < 0 ? [...list, copy(t)] : list.map((x, j) => j === i ? copy(t) : x);
|
|
4457
|
+
}
|
|
4458
|
+
/** Templates in memory, gone on reload. The default when a host passes no store. */
|
|
4459
|
+
function createMemorySignatureStore(initial = []) {
|
|
4460
|
+
let items = initial.map(copy);
|
|
4461
|
+
return {
|
|
4462
|
+
list: () => Promise.resolve(items.map(copy)),
|
|
4463
|
+
save(t) {
|
|
4464
|
+
items = upsert(items, t);
|
|
4465
|
+
return Promise.resolve();
|
|
4466
|
+
},
|
|
4467
|
+
remove(id) {
|
|
4468
|
+
items = items.filter((x) => x.id !== id);
|
|
4469
|
+
return Promise.resolve();
|
|
4470
|
+
}
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
4473
|
+
/**
|
|
4474
|
+
* Templates in `localStorage` under `key`, as a JSON array. Where storage is blocked (it refuses
|
|
4475
|
+
* even a tiny test write, as in some private windows) the templates stay in memory for the
|
|
4476
|
+
* page's life, so signing still works. Where storage works, or is full but holds a saved list,
|
|
4477
|
+
* and has no room for a change, the change is undone and `save` or `remove` rejects, so the host can say so. Stored entries it
|
|
4478
|
+
* cannot read, such as a later version's templates, are left out of `list` and kept on write.
|
|
4479
|
+
*/
|
|
4480
|
+
function createLocalSignatureStore(key) {
|
|
4481
|
+
/** Usable storage, or undefined when it is missing or blocked. */
|
|
4482
|
+
const storage = (() => {
|
|
4483
|
+
try {
|
|
4484
|
+
const s = globalThis.localStorage ?? void 0;
|
|
4485
|
+
if (!s) return void 0;
|
|
4486
|
+
const probe = `${key}:probe`;
|
|
4487
|
+
try {
|
|
4488
|
+
s.setItem(probe, "1");
|
|
4489
|
+
s.removeItem(probe);
|
|
4490
|
+
} catch {
|
|
4491
|
+
return s.getItem(key) === null ? void 0 : s;
|
|
4492
|
+
}
|
|
4493
|
+
return s;
|
|
4494
|
+
} catch {
|
|
4495
|
+
return;
|
|
4496
|
+
}
|
|
4497
|
+
})();
|
|
4498
|
+
/** The stored list; undefined when storage cannot be read. */
|
|
4499
|
+
const read = () => {
|
|
4500
|
+
if (!storage) return void 0;
|
|
4501
|
+
try {
|
|
4502
|
+
const raw = storage.getItem(key);
|
|
4503
|
+
if (raw === null) return {
|
|
4504
|
+
items: [],
|
|
4505
|
+
unread: []
|
|
4506
|
+
};
|
|
4507
|
+
const parsed = JSON.parse(raw);
|
|
4508
|
+
if (!Array.isArray(parsed)) return {
|
|
4509
|
+
items: [],
|
|
4510
|
+
unread: []
|
|
4511
|
+
};
|
|
4512
|
+
return {
|
|
4513
|
+
items: validTemplates(parsed),
|
|
4514
|
+
unread: parsed.filter((x) => validTemplates([x]).length === 0)
|
|
4515
|
+
};
|
|
4516
|
+
} catch (e) {
|
|
4517
|
+
return e instanceof SyntaxError ? {
|
|
4518
|
+
items: [],
|
|
4519
|
+
unread: []
|
|
4520
|
+
} : void 0;
|
|
4521
|
+
}
|
|
4522
|
+
};
|
|
4523
|
+
let { items, unread } = read() ?? {
|
|
4524
|
+
items: [],
|
|
4525
|
+
unread: []
|
|
4526
|
+
};
|
|
4527
|
+
/** Picks up what another tab wrote. */
|
|
4528
|
+
const sync = () => {
|
|
4529
|
+
const stored = read();
|
|
4530
|
+
if (stored) ({items, unread} = stored);
|
|
4531
|
+
};
|
|
4532
|
+
/** The id of an entry that may not be a template, if it has one. */
|
|
4533
|
+
const idOf = (x) => typeof x === "object" && x !== null ? x.id : void 0;
|
|
4534
|
+
/**
|
|
4535
|
+
* Writes `next`, then adopts it; leaves everything as it was when storage refuses. Entries
|
|
4536
|
+
* it cannot read go back after the templates, less any whose id a template now has.
|
|
4537
|
+
*/
|
|
4538
|
+
const commit = (next) => {
|
|
4539
|
+
const ids = new Set(next.map((t) => t.id));
|
|
4540
|
+
const kept = unread.filter((x) => idOf(x) === void 0 || !ids.has(idOf(x)));
|
|
4541
|
+
if (storage) try {
|
|
4542
|
+
storage.setItem(key, JSON.stringify([...next, ...kept]));
|
|
4543
|
+
} catch (cause) {
|
|
4544
|
+
return Promise.reject(new Error("The signature could not be saved: browser storage is full", { cause }));
|
|
4545
|
+
}
|
|
4546
|
+
items = next;
|
|
4547
|
+
unread = kept;
|
|
4548
|
+
return Promise.resolve();
|
|
4549
|
+
};
|
|
4550
|
+
return {
|
|
4551
|
+
list() {
|
|
4552
|
+
sync();
|
|
4553
|
+
return Promise.resolve(items.map(copy));
|
|
4554
|
+
},
|
|
4555
|
+
save(t) {
|
|
4556
|
+
sync();
|
|
4557
|
+
return commit(upsert(items, t));
|
|
4558
|
+
},
|
|
4559
|
+
remove(id) {
|
|
4560
|
+
sync();
|
|
4561
|
+
return commit(items.filter((x) => x.id !== id));
|
|
4562
|
+
}
|
|
4563
|
+
};
|
|
4564
|
+
}
|
|
4565
|
+
/**
|
|
4566
|
+
* Fits strokes into a unit box, for an ink template. Points come in any y-up space; they go
|
|
4567
|
+
* out between 0 and 1 on both axes, and `aspect` is the box's width over its height. A flat
|
|
4568
|
+
* side is given 1% of the long side (a dot, a square), so the aspect stays finite, and the
|
|
4569
|
+
* strokes are centred across it.
|
|
4570
|
+
*/
|
|
4571
|
+
function normaliseInk(inklist) {
|
|
4572
|
+
const all = inklist.flat();
|
|
4573
|
+
const xs = all.map((p) => p.x);
|
|
4574
|
+
const ys = all.map((p) => p.y);
|
|
4575
|
+
const minX = Math.min(...xs);
|
|
4576
|
+
const minY = Math.min(...ys);
|
|
4577
|
+
const w = Math.max(...xs) - minX;
|
|
4578
|
+
const h = Math.max(...ys) - minY;
|
|
4579
|
+
const long = Math.max(w, h);
|
|
4580
|
+
const boxW = long === 0 ? 1 : Math.max(w, long * .01);
|
|
4581
|
+
const boxH = long === 0 ? 1 : Math.max(h, long * .01);
|
|
4582
|
+
const map = (p) => ({
|
|
4583
|
+
x: (p.x - minX + (boxW - w) / 2) / boxW,
|
|
4584
|
+
y: (p.y - minY + (boxH - h) / 2) / boxH
|
|
4585
|
+
});
|
|
4586
|
+
return {
|
|
4587
|
+
inklist: inklist.map((path) => path.map(map)),
|
|
4588
|
+
aspect: boxW / boxH
|
|
4589
|
+
};
|
|
4590
|
+
}
|
|
4591
|
+
/**
|
|
4592
|
+
* An ink template from strokes in any y-up space, with a new id. Throws a RangeError for no
|
|
4593
|
+
* strokes, an empty stroke, or pressure that does not have one value per point.
|
|
4594
|
+
*/
|
|
4595
|
+
function createInkTemplate(inklist, pressure) {
|
|
4596
|
+
if (inklist.length === 0 || inklist.some((path) => path.length === 0)) throw new RangeError("A signature needs at least one stroke, and no empty strokes");
|
|
4597
|
+
if (pressure && (pressure.length !== inklist.length || pressure.some((p, i) => p.length !== inklist[i].length))) throw new RangeError("pressure must have the same shape as inklist");
|
|
4598
|
+
const fitted = normaliseInk(inklist);
|
|
4599
|
+
return {
|
|
4600
|
+
id: v7(),
|
|
4601
|
+
kind: "ink",
|
|
4602
|
+
inklist: fitted.inklist,
|
|
4603
|
+
...pressure ? { pressure: pressure.map((p) => [...p]) } : {},
|
|
4604
|
+
aspect: fitted.aspect
|
|
4605
|
+
};
|
|
4606
|
+
}
|
|
4607
|
+
/** An image template, with a new id. Throws a TypeError unless `imagedata` is a PNG or JPEG data URL. */
|
|
4608
|
+
function createImageTemplate(imagedata, aspect) {
|
|
4609
|
+
if (!IMAGE_DATA_URL.test(imagedata)) throw new TypeError("A signature image must be a PNG or JPEG data URL");
|
|
4610
|
+
if (!(aspect > 0 && Number.isFinite(aspect))) throw new RangeError(`aspect must be positive, got ${aspect}`);
|
|
4611
|
+
return {
|
|
4612
|
+
id: v7(),
|
|
4613
|
+
kind: "image",
|
|
4614
|
+
imagedata,
|
|
4615
|
+
aspect
|
|
4616
|
+
};
|
|
4617
|
+
}
|
|
4618
|
+
/**
|
|
4619
|
+
* The annotation that places `t` on `page`, `width` points wide on screen and centred on
|
|
4620
|
+
* `centre` (user space). `rotation` is the page's /Rotate (clockwise): the signature is turned
|
|
4621
|
+
* back by as much, so it reads upright on the page as the file displays it. A viewer's view
|
|
4622
|
+
* rotation is left out; it turns the signature with the page like any annotation. Ink becomes `ink` with `subject: 'Signature'`, its strokes inset by half their
|
|
4623
|
+
* width; an image becomes a `stamp` with `subject: 'Signature'`.
|
|
4624
|
+
*/
|
|
4625
|
+
function placeSignature(t, page, centre, width = 150, rotation = 0) {
|
|
4626
|
+
const height = width / t.aspect;
|
|
4627
|
+
const sideways = rotation === 90 || rotation === 270;
|
|
4628
|
+
const rw = sideways ? height : width;
|
|
4629
|
+
const rh = sideways ? width : height;
|
|
4630
|
+
const rect = {
|
|
4631
|
+
x: centre.x - rw / 2,
|
|
4632
|
+
y: centre.y - rh / 2,
|
|
4633
|
+
width: rw,
|
|
4634
|
+
height: rh
|
|
4635
|
+
};
|
|
4636
|
+
if (t.kind === "image") return {
|
|
4637
|
+
type: "stamp",
|
|
4638
|
+
page,
|
|
4639
|
+
subject: "Signature",
|
|
4640
|
+
imagedata: t.imagedata,
|
|
4641
|
+
rect,
|
|
4642
|
+
...rotation === 0 ? {} : { rotation }
|
|
4643
|
+
};
|
|
4644
|
+
const innerW = Math.max(width - 2, 0);
|
|
4645
|
+
const innerH = Math.max(height - 2, 0);
|
|
4646
|
+
const rad = rotation * Math.PI / 180;
|
|
4647
|
+
const cos = Math.round(Math.cos(rad));
|
|
4648
|
+
const sin = Math.round(Math.sin(rad));
|
|
4649
|
+
const place = (p) => {
|
|
4650
|
+
const u = (p.x - .5) * innerW;
|
|
4651
|
+
const v = (p.y - .5) * innerH;
|
|
4652
|
+
return {
|
|
4653
|
+
x: centre.x + u * cos - v * sin,
|
|
4654
|
+
y: centre.y + u * sin + v * cos
|
|
4655
|
+
};
|
|
4656
|
+
};
|
|
4657
|
+
return {
|
|
4658
|
+
type: "ink",
|
|
4659
|
+
page,
|
|
4660
|
+
subject: "Signature",
|
|
4661
|
+
inklist: t.inklist.map((path) => path.map(place)),
|
|
4662
|
+
...t.pressure ? { pressure: t.pressure.map((p) => [...p]) } : {},
|
|
4663
|
+
width: 2,
|
|
4664
|
+
rect
|
|
4665
|
+
};
|
|
4666
|
+
}
|
|
4667
|
+
function createSignatureLibrary(store) {
|
|
4668
|
+
const templates = writable([]);
|
|
4669
|
+
const removeError = writable(void 0);
|
|
4670
|
+
/**
|
|
4671
|
+
* The newest load that succeeded writes, so a slow one cannot undo a later save, and a later
|
|
4672
|
+
* one that fails does not throw away a slow success.
|
|
4673
|
+
*/
|
|
4674
|
+
let loads = 0;
|
|
4675
|
+
let applied = 0;
|
|
4676
|
+
const refresh = async () => {
|
|
4677
|
+
const n = ++loads;
|
|
4678
|
+
try {
|
|
4679
|
+
const list = validTemplates(await store.list());
|
|
4680
|
+
if (n > applied) {
|
|
4681
|
+
applied = n;
|
|
4682
|
+
templates.set(list);
|
|
4683
|
+
}
|
|
4684
|
+
} catch {}
|
|
4685
|
+
};
|
|
4686
|
+
return {
|
|
4687
|
+
store,
|
|
4688
|
+
templates: readonly(templates),
|
|
4689
|
+
get: (id) => templates.get().find((t) => t.id === id),
|
|
4690
|
+
refresh,
|
|
4691
|
+
async save(t) {
|
|
4692
|
+
await store.save(t);
|
|
4693
|
+
await refresh();
|
|
4694
|
+
},
|
|
4695
|
+
async remove(id) {
|
|
4696
|
+
removeError.set(void 0);
|
|
4697
|
+
try {
|
|
4698
|
+
await store.remove(id);
|
|
4699
|
+
} catch (e) {
|
|
4700
|
+
removeError.set("The signature could not be removed. Try again.");
|
|
4701
|
+
throw e;
|
|
4702
|
+
}
|
|
4703
|
+
await refresh();
|
|
4704
|
+
},
|
|
4705
|
+
removeError: readonly(removeError),
|
|
4706
|
+
clearRemoveError: () => removeError.set(void 0)
|
|
4707
|
+
};
|
|
4708
|
+
}
|
|
4709
|
+
//#endregion
|
|
4710
|
+
export { importAnnotations as $, readonly as $t, styleGroupOf as A, RectSchema as At, isTextMarkup as B, withCalloutBounds as Bt, repliesTo as C, NoteIconSchema as Ct, STYLE_GROUPS as D, PolygonSchema as Dt, withoutReplies as E, PolySchema as Et, typesInGroup as F, TextMarkupSchema as Ft, resizedRect as G, isKnown as Gt, movedEndpoint as H, LINE_ENDINGS as Ht, hasCallout as I, FringeSchema as It, withBox as J, cssVariableDefaults as Jt, scaled as K, pdfNameSet as Kt, hasCornerHandles as L, calloutStroke as Lt, styleGroupsOf as M, ShapeSchema as Mt, supportedProps as N, SquareSchema as Nt, createToolStyle as O, PolylineSchema as Ot, toFields as P, StampSchema as Pt, exportAnnotations as Q, derived as Qt, hasRectGeometry as R, freeTextBox as Rt, isThreadRoot as S, MaskSchema as St, threadRoot as T, PointSchema as Tt, movedTip as U, NOTE_ICONS as Ut, keepsAspect as V, INTENTS as Vt, resizedBox as W, PDF_NAME$1 as Wt, createAnnotationStore as X, staticDefaults as Xt, openDocument as Y, normalizeColor as Yt, deriveRect as Z, mergeLineRects as Zt, defaultEnds as _, isInkError as _n, IMAGE_DATA_URL as _t, createLocalSignatureStore as a, pointsFromFlat as an, pdfColorToHex as at, isAbandonedNote as b, LineSchema as bt, normaliseInk as c, rectContainsPoint as cn, AnnotationSchema as ct, recogniseStroke as d, rectsIntersect as dn, FreeTextSchema as dt, writable as en, createHistory as et, DEFAULT_CORNER_RADIUS as f, translateQuad as fn, FreeTextStyleSchema as ft, constrain as g, InkError as gn, GLOSS_VERSION$1 as gt, SHAPE_KINDS as h, Emitter as hn, GLOSS_SCHEMA_ID as ht, createInkTemplate as i, multiplyMatrix as in, importedId as it, styleGroupOfType as j, RedactSchema as jt, fromAnnotation as k, QuadSchema as kt, placeSignature as l, rectFromPoints as ln, CircleSchema as lt, DEFAULT_SHAPE_SIZE as m, unionRects as mn, GLOSS_MEDIA_TYPE as mt, SIGNATURE_INK_WIDTH as n, approxEqual as nn, SUPPORTED_SUBTYPES as nt, createMemorySignatureStore as o, quadFromRect as on, summarise as ot, DEFAULT_SHAPE_OPTIONS as p, translateRect as pn, GLOSS_FORMAT$1 as pt, translated as q, parsePdfDate as qt, createImageTemplate as r, invertMatrix as rn, importEmbeddedPage as rt, createSignatureLibrary as s, quadsFromFlat as sn, AnnotationDocumentSchema as st, DEFAULT_SIGNATURE_WIDTH as t, applyMatrix as tn, isProduction as tt, RECOGNISE_THRESHOLD as u, rectFromQuad as un, ExportedAnnotationSchema as ut, isPointerOnlyKind as v, InkSchema as vt, threadOf as w, NoteSchema as wt, isReply as x, LoupeSchema as xt, shapeInput as y, LineEndingSchema as yt, isBubble as z, isStandardCallout as zt };
|
|
4711
|
+
|
|
4712
|
+
//# sourceMappingURL=signatures-CMapTKrC.mjs.map
|