@gmickel/gno 1.29.6 → 1.30.4
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 +15 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip.sha256 +1 -0
- package/browser-extension/dist/chunk-627emwpj.js +75 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/browser-extension/dist/preview.html +1 -1
- package/browser-extension/dist/service-worker.js +47 -22
- package/package.json +42 -39
- package/src/converters/adapters/officeparser/adapter.ts +7 -3
- package/src/core/network-boundary-inventory.ts +51 -0
- package/src/core/project-profile.ts +2 -2
- package/src/publish/encrypted-export.ts +1 -1
- package/src/serve/AGENTS.md +5 -1
- package/src/serve/CLAUDE.md +5 -1
- package/src/serve/fn112-routes.ts +232 -0
- package/src/serve/pdfjs-assets.ts +391 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +28 -10
- package/src/serve/public/components/pdf/PdfPageView.tsx +428 -0
- package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
- package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
- package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
- package/src/serve/public/globals.built.css +2 -2
- package/src/serve/public/globals.css +113 -0
- package/src/serve/public/hooks/use-pdf-document.ts +199 -0
- package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
- package/src/serve/public/lib/doc-asset-url.ts +57 -0
- package/src/serve/public/lib/math-sum-precise.ts +34 -0
- package/src/serve/public/lib/pdf.ts +772 -0
- package/src/serve/public/pages/DocView.tsx +295 -39
- package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
- package/src/serve/routes/api.ts +154 -14
- package/src/serve/server.ts +190 -37
- package/src/serve/spa-bundle-source.ts +99 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
- package/browser-extension/dist/chunk-b2zm0jjd.js +0 -50
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single pdfjs-dist import facade for the GNO native PDF viewer.
|
|
3
|
+
* Runtime and type imports of pdfjs-dist are allowed ONLY in this module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import "./math-sum-precise";
|
|
7
|
+
import {
|
|
8
|
+
getDocument as pdfjsGetDocument,
|
|
9
|
+
GlobalWorkerOptions,
|
|
10
|
+
PasswordResponses,
|
|
11
|
+
TextLayer,
|
|
12
|
+
type PDFDocumentLoadingTask,
|
|
13
|
+
type PDFDocumentProxy,
|
|
14
|
+
type PDFPageProxy,
|
|
15
|
+
type RenderTask,
|
|
16
|
+
type PageViewport,
|
|
17
|
+
} from "pdfjs-dist";
|
|
18
|
+
|
|
19
|
+
// ── Worker / asset bootstrap ────────────────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// Browser (and Electrobun webview) production: same-origin /vendor/pdfjs/* routes.
|
|
22
|
+
// Bun/Node unit tests: resolve assets from the installed pdfjs-dist package so
|
|
23
|
+
// real getDocument loads succeed without a running gno serve. Detection uses
|
|
24
|
+
// `typeof Bun` — never present in real browser bundles — so browser CSP/offline
|
|
25
|
+
// invariants stay intact.
|
|
26
|
+
|
|
27
|
+
function useLocalPdfjsPackageAssets(): boolean {
|
|
28
|
+
return typeof Bun !== "undefined";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function packageSiblingDirUrl(sampleSpecifier: string): string {
|
|
32
|
+
// import.meta.resolve returns a file:// URL in Bun; strip the filename.
|
|
33
|
+
const resolved = import.meta.resolve(sampleSpecifier);
|
|
34
|
+
return resolved.replace(/[^/]+$/u, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BROWSER_WORKER_SRC = "/vendor/pdfjs/pdf.worker.min.mjs";
|
|
38
|
+
const BROWSER_CMAP_URL = "/vendor/pdfjs/cmaps/";
|
|
39
|
+
const BROWSER_STANDARD_FONT_URL = "/vendor/pdfjs/standard_fonts/";
|
|
40
|
+
|
|
41
|
+
function resolveWorkerSrc(): string {
|
|
42
|
+
if (!useLocalPdfjsPackageAssets()) {
|
|
43
|
+
return BROWSER_WORKER_SRC;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return import.meta.resolve("pdfjs-dist/build/pdf.worker.min.mjs");
|
|
47
|
+
} catch {
|
|
48
|
+
return BROWSER_WORKER_SRC;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveCMapUrl(): string {
|
|
53
|
+
if (!useLocalPdfjsPackageAssets()) {
|
|
54
|
+
return BROWSER_CMAP_URL;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return packageSiblingDirUrl("pdfjs-dist/cmaps/UniJIS-UCS2-H.bcmap");
|
|
58
|
+
} catch {
|
|
59
|
+
return BROWSER_CMAP_URL;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveStandardFontDataUrl(): string {
|
|
64
|
+
if (!useLocalPdfjsPackageAssets()) {
|
|
65
|
+
return BROWSER_STANDARD_FONT_URL;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return packageSiblingDirUrl(
|
|
69
|
+
"pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf"
|
|
70
|
+
);
|
|
71
|
+
} catch {
|
|
72
|
+
return BROWSER_STANDARD_FONT_URL;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Same-origin worker in browsers; package-local worker under Bun tests. */
|
|
77
|
+
GlobalWorkerOptions.workerSrc = resolveWorkerSrc();
|
|
78
|
+
|
|
79
|
+
const CMAP_URL = resolveCMapUrl();
|
|
80
|
+
const STANDARD_FONT_DATA_URL = resolveStandardFontDataUrl();
|
|
81
|
+
|
|
82
|
+
// ── Public types ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export type PdfFallbackReason =
|
|
85
|
+
| "corrupt"
|
|
86
|
+
| "password"
|
|
87
|
+
| "network"
|
|
88
|
+
| "bootstrap";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compatible render-params shape for page.render().
|
|
92
|
+
* Not re-exported from the pdfjs-dist package root in v5.7.x — defined here so
|
|
93
|
+
* downstream modules never import pdfjs-dist directly for this type.
|
|
94
|
+
*/
|
|
95
|
+
export type RenderParameters = {
|
|
96
|
+
canvasContext: CanvasRenderingContext2D;
|
|
97
|
+
viewport: PageViewport;
|
|
98
|
+
canvas?: HTMLCanvasElement | null;
|
|
99
|
+
intent?: string;
|
|
100
|
+
background?: string | CanvasGradient | CanvasPattern | null;
|
|
101
|
+
transform?: number[] | null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type {
|
|
105
|
+
PDFDocumentProxy,
|
|
106
|
+
PDFPageProxy,
|
|
107
|
+
RenderTask,
|
|
108
|
+
PageViewport,
|
|
109
|
+
PDFDocumentLoadingTask,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export { TextLayer };
|
|
113
|
+
|
|
114
|
+
// Annotation shape used by the link layer (subset of pdfjs annotation dict).
|
|
115
|
+
export type PdfAnnotation = {
|
|
116
|
+
subtype?: string;
|
|
117
|
+
annotationType?: number;
|
|
118
|
+
url?: string;
|
|
119
|
+
unsafeUrl?: string;
|
|
120
|
+
dest?: unknown;
|
|
121
|
+
rect?: [number, number, number, number];
|
|
122
|
+
newWindow?: boolean;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ── Document load wrapper ───────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export type GnoGetDocumentParams = {
|
|
128
|
+
url: string;
|
|
129
|
+
// Intentionally NO caller-controlled document id — every load mints a fresh
|
|
130
|
+
// opaque instance id internally (I2-6 / Sol rereview).
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Loading task augmented with the opaque per-load document instance id.
|
|
135
|
+
* `gnoDocId` is never derived from URL/path/URI/filename/title/content and is
|
|
136
|
+
* never caller-supplied.
|
|
137
|
+
*/
|
|
138
|
+
export type GnoDocumentLoadingTask = PDFDocumentLoadingTask & {
|
|
139
|
+
readonly gnoDocId: string;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a pdfjs loading task and always mint a distinct opaque doc instance id.
|
|
144
|
+
* Two calls for the same URL always get different gnoDocId values.
|
|
145
|
+
*/
|
|
146
|
+
/**
|
|
147
|
+
* `globalThis.pdfjsWorker` is a single process-wide slot that every pdfjs-dist
|
|
148
|
+
* copy reads when setting up a fake (non-Worker) worker. Whichever copy imports
|
|
149
|
+
* its worker first wins for all of them — so a transitive dependency's older
|
|
150
|
+
* pdfjs-dist (e.g. `pdf-parse`'s 5.4.x) can install a worker that our pinned
|
|
151
|
+
* API then rejects with "API version does not match the Worker version".
|
|
152
|
+
*
|
|
153
|
+
* Clearing the slot before each load makes pdf.js import *our* `workerSrc`, so
|
|
154
|
+
* the worker always matches this module's API. The dynamic import is module
|
|
155
|
+
* cached, so this costs nothing after the first load. Guarded to the Bun path:
|
|
156
|
+
* browsers use a real Worker and never populate this global.
|
|
157
|
+
*/
|
|
158
|
+
function releaseForeignFakeWorkerGlobal(): void {
|
|
159
|
+
if (!useLocalPdfjsPackageAssets()) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const g = globalThis as { pdfjsWorker?: unknown };
|
|
163
|
+
if (g.pdfjsWorker !== undefined) {
|
|
164
|
+
g.pdfjsWorker = undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function getDocument(
|
|
169
|
+
params: GnoGetDocumentParams
|
|
170
|
+
): GnoDocumentLoadingTask {
|
|
171
|
+
releaseForeignFakeWorkerGlobal();
|
|
172
|
+
// Always mint — no override path exists on the public API.
|
|
173
|
+
const gnoDocId = getPdfMetrics().mintDocId();
|
|
174
|
+
const loadingTask = pdfjsGetDocument({
|
|
175
|
+
url: params.url,
|
|
176
|
+
cMapUrl: CMAP_URL,
|
|
177
|
+
cMapPacked: true,
|
|
178
|
+
standardFontDataUrl: STANDARD_FONT_DATA_URL,
|
|
179
|
+
// Browser default useSystemFonts=true substitutes OS Helvetica and never
|
|
180
|
+
// hits standardFontDataUrl, breaking the offline standard-font contract.
|
|
181
|
+
// Force pdfjs-dist standard_fonts/* over same-origin routes instead.
|
|
182
|
+
useSystemFonts: false,
|
|
183
|
+
// Range-mode loading (product policy, not a test accommodation).
|
|
184
|
+
// With disableStream, pdfjs-dist cancels the full-body reader as soon as
|
|
185
|
+
// response headers arrive when ranges are supported, so every subsequent
|
|
186
|
+
// byte is fetched as a discrete Range request (pdf.mjs PDFFetchStreamReader).
|
|
187
|
+
// disableAutoFetch keeps pdf.js from eagerly pulling the remaining chunks —
|
|
188
|
+
// correct for a windowed/virtualized viewer that only paints a bounded page
|
|
189
|
+
// window. Range eligibility still requires Content-Length > 2×rangeChunkSize
|
|
190
|
+
// plus Accept-Ranges: bytes (already emitted by GET /api/doc-asset).
|
|
191
|
+
disableStream: true,
|
|
192
|
+
disableAutoFetch: true,
|
|
193
|
+
// Never enable embedded PDF scripting.
|
|
194
|
+
// enableScripting is intentionally omitted (defaults false).
|
|
195
|
+
// pdfjs v5 removed the former eval-support flag; CSP enforces no unsafe-eval.
|
|
196
|
+
}) as GnoDocumentLoadingTask;
|
|
197
|
+
let rejectPassword!: (error: Error) => void;
|
|
198
|
+
const passwordFailure = new Promise<PDFDocumentProxy>((_resolve, reject) => {
|
|
199
|
+
rejectPassword = reject;
|
|
200
|
+
});
|
|
201
|
+
const productPromise = Promise.race([loadingTask.promise, passwordFailure]);
|
|
202
|
+
// PDFDocumentLoadingTask exposes promise as a prototype getter. Shadow it on
|
|
203
|
+
// this facade-owned instance so GNO can model password cancellation without
|
|
204
|
+
// reaching into pdf.js's private capability fields.
|
|
205
|
+
Object.defineProperty(loadingTask, "promise", {
|
|
206
|
+
value: productPromise,
|
|
207
|
+
writable: false,
|
|
208
|
+
enumerable: true,
|
|
209
|
+
configurable: false,
|
|
210
|
+
});
|
|
211
|
+
// GNO does not collect PDF passwords. Without an onPassword callback pdf.js
|
|
212
|
+
// intentionally leaves loadingTask.promise pending forever while it waits for
|
|
213
|
+
// UI input. Reject the facade promise so the existing password state/fallback
|
|
214
|
+
// renders deterministically; usePdfDocument then destroys the waiting task.
|
|
215
|
+
loadingTask.onPassword = (
|
|
216
|
+
updatePassword: (password: string | Error) => void,
|
|
217
|
+
reason: number
|
|
218
|
+
): void => {
|
|
219
|
+
const error = new Error(
|
|
220
|
+
`Password-protected PDF requires an external reader (${reason || PasswordResponses.NEED_PASSWORD})`
|
|
221
|
+
);
|
|
222
|
+
error.name = "PasswordException";
|
|
223
|
+
updatePassword(error);
|
|
224
|
+
rejectPassword(error);
|
|
225
|
+
};
|
|
226
|
+
Object.defineProperty(loadingTask, "gnoDocId", {
|
|
227
|
+
value: gnoDocId,
|
|
228
|
+
writable: false,
|
|
229
|
+
enumerable: true,
|
|
230
|
+
configurable: false,
|
|
231
|
+
});
|
|
232
|
+
return loadingTask;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Error classification ────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
function errorName(err: unknown): string {
|
|
238
|
+
if (err && typeof err === "object" && "name" in err) {
|
|
239
|
+
const name = (err as { name?: unknown }).name;
|
|
240
|
+
if (typeof name === "string") {
|
|
241
|
+
return name;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function errorMessage(err: unknown): string {
|
|
248
|
+
if (err instanceof Error) {
|
|
249
|
+
return err.message;
|
|
250
|
+
}
|
|
251
|
+
if (typeof err === "string") {
|
|
252
|
+
return err;
|
|
253
|
+
}
|
|
254
|
+
if (err == null) {
|
|
255
|
+
return "";
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return JSON.stringify(err);
|
|
259
|
+
} catch {
|
|
260
|
+
return "unknown error";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Classify a document-load rejection.
|
|
266
|
+
* `"bootstrap"` is only for worker startup / document-load bootstrap failures.
|
|
267
|
+
* Auxiliary cMap/standard-font 404s do not necessarily reject getDocument.
|
|
268
|
+
*/
|
|
269
|
+
export function classifyPdfError(err: unknown): PdfFallbackReason {
|
|
270
|
+
const name = errorName(err);
|
|
271
|
+
const msg = errorMessage(err).toLowerCase();
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
name === "PasswordException" ||
|
|
275
|
+
msg.includes("password") ||
|
|
276
|
+
msg.includes("needpassword")
|
|
277
|
+
) {
|
|
278
|
+
return "password";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
name === "InvalidPDFException" ||
|
|
283
|
+
msg.includes("invalid pdf") ||
|
|
284
|
+
msg.includes("invalidpdf") ||
|
|
285
|
+
msg.includes("missing pdf") ||
|
|
286
|
+
msg.includes("corrupted")
|
|
287
|
+
) {
|
|
288
|
+
return "corrupt";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Worker bootstrap / missing worker / module worker failure
|
|
292
|
+
if (
|
|
293
|
+
msg.includes("worker") ||
|
|
294
|
+
msg.includes("setting up fake worker") ||
|
|
295
|
+
msg.includes("failed to fetch dynamically imported module") ||
|
|
296
|
+
msg.includes("pdf.worker") ||
|
|
297
|
+
msg.includes("cannot load") ||
|
|
298
|
+
(name === "UnknownErrorException" && msg.includes("worker"))
|
|
299
|
+
) {
|
|
300
|
+
return "bootstrap";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Network / HTTP errors from doc-asset fetch
|
|
304
|
+
if (
|
|
305
|
+
name === "ResponseException" ||
|
|
306
|
+
msg.includes("network") ||
|
|
307
|
+
msg.includes("fetch") ||
|
|
308
|
+
msg.includes("failed to fetch") ||
|
|
309
|
+
msg.includes("http status") ||
|
|
310
|
+
msg.includes("status code") ||
|
|
311
|
+
/\b(4\d\d|5\d\d)\b/.test(msg)
|
|
312
|
+
) {
|
|
313
|
+
return "network";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Default: treat unknown load failures as corrupt for actionable UI
|
|
317
|
+
if (name === "InvalidPDFException") {
|
|
318
|
+
return "corrupt";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Missing worker often surfaces as generic Error after 404
|
|
322
|
+
if (msg.includes("unexpected server response") || msg.includes("404")) {
|
|
323
|
+
return "network";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return "corrupt";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function isRenderingCancelled(err: unknown): boolean {
|
|
330
|
+
const name = errorName(err);
|
|
331
|
+
if (name === "RenderingCancelledException") {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
const msg = errorMessage(err).toLowerCase();
|
|
335
|
+
return msg.includes("rendering cancelled") || msg.includes("cancelled");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Zoom / fit / canvas cap math ────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export const MIN_ZOOM = 0.25;
|
|
341
|
+
export const MAX_ZOOM = 4;
|
|
342
|
+
export const ZOOM_STEP = 0.1;
|
|
343
|
+
export const DEFAULT_ZOOM = 1;
|
|
344
|
+
/** Cap device pixel ratio contribution (pdfjs does not do this for us). */
|
|
345
|
+
export const MAX_DEVICE_PIXEL_RATIO = 2;
|
|
346
|
+
/**
|
|
347
|
+
* Max canvas pixel area (width*height). Guard against 8K-wide pages.
|
|
348
|
+
* ~16 megapixels is a safe desktop bound.
|
|
349
|
+
*/
|
|
350
|
+
export const MAX_CANVAS_PIXELS = 16_777_216;
|
|
351
|
+
|
|
352
|
+
export function clampZoom(zoom: number): number {
|
|
353
|
+
if (!Number.isFinite(zoom)) {
|
|
354
|
+
return DEFAULT_ZOOM;
|
|
355
|
+
}
|
|
356
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function stepZoom(zoom: number, direction: 1 | -1): number {
|
|
360
|
+
const next = zoom + direction * ZOOM_STEP;
|
|
361
|
+
// Snap to 1 decimal place to avoid float drift
|
|
362
|
+
return clampZoom(Math.round(next * 100) / 100);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export type ViewportDims = { width: number; height: number };
|
|
366
|
+
export type ContainerDims = { width: number; height: number };
|
|
367
|
+
|
|
368
|
+
/** Scale so page width fits the container (rotation-aware via viewport dims). */
|
|
369
|
+
export function fitWidthScale(
|
|
370
|
+
viewport: ViewportDims,
|
|
371
|
+
container: ContainerDims,
|
|
372
|
+
baseScale = 1
|
|
373
|
+
): number {
|
|
374
|
+
if (viewport.width <= 0 || container.width <= 0) {
|
|
375
|
+
return DEFAULT_ZOOM;
|
|
376
|
+
}
|
|
377
|
+
const atBase = viewport.width / baseScale;
|
|
378
|
+
return clampZoom(container.width / atBase);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Scale so the whole page fits in the container. */
|
|
382
|
+
export function fitPageScale(
|
|
383
|
+
viewport: ViewportDims,
|
|
384
|
+
container: ContainerDims,
|
|
385
|
+
baseScale = 1
|
|
386
|
+
): number {
|
|
387
|
+
if (
|
|
388
|
+
viewport.width <= 0 ||
|
|
389
|
+
viewport.height <= 0 ||
|
|
390
|
+
container.width <= 0 ||
|
|
391
|
+
container.height <= 0
|
|
392
|
+
) {
|
|
393
|
+
return DEFAULT_ZOOM;
|
|
394
|
+
}
|
|
395
|
+
const atBaseW = viewport.width / baseScale;
|
|
396
|
+
const atBaseH = viewport.height / baseScale;
|
|
397
|
+
const sx = container.width / atBaseW;
|
|
398
|
+
const sy = container.height / atBaseH;
|
|
399
|
+
return clampZoom(Math.min(sx, sy));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export type EffectiveScaleInput = {
|
|
403
|
+
zoom: number;
|
|
404
|
+
devicePixelRatio: number;
|
|
405
|
+
/** CSS-pixel page width at the logical zoom (viewport.width). */
|
|
406
|
+
cssWidth: number;
|
|
407
|
+
/** CSS-pixel page height at the logical zoom (viewport.height). */
|
|
408
|
+
cssHeight: number;
|
|
409
|
+
maxCanvasPixels?: number;
|
|
410
|
+
maxDevicePixelRatio?: number;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export type EffectiveScaleResult = {
|
|
414
|
+
/** Scale passed to pdfjs render (includes DPR and area clamp). */
|
|
415
|
+
renderScale: number;
|
|
416
|
+
/** Logical CSS scale (zoom only). */
|
|
417
|
+
cssScale: number;
|
|
418
|
+
canvasWidth: number;
|
|
419
|
+
canvasHeight: number;
|
|
420
|
+
dpr: number;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Effective render scale = min(dpr, 2) * zoom, then area-clamped so
|
|
425
|
+
* canvasWidth * canvasHeight <= maxCanvasPixels.
|
|
426
|
+
*/
|
|
427
|
+
export function computeEffectiveScale(
|
|
428
|
+
input: EffectiveScaleInput
|
|
429
|
+
): EffectiveScaleResult {
|
|
430
|
+
const zoom = clampZoom(input.zoom);
|
|
431
|
+
const maxDpr = input.maxDevicePixelRatio ?? MAX_DEVICE_PIXEL_RATIO;
|
|
432
|
+
const maxPixels = input.maxCanvasPixels ?? MAX_CANVAS_PIXELS;
|
|
433
|
+
const dpr = Math.min(Math.max(input.devicePixelRatio || 1, 1), maxDpr);
|
|
434
|
+
|
|
435
|
+
let renderScale = zoom * dpr;
|
|
436
|
+
let canvasWidth = Math.max(
|
|
437
|
+
1,
|
|
438
|
+
Math.floor(
|
|
439
|
+
input.cssWidth * (dpr / (input.zoom > 0 ? input.zoom / zoom : 1))
|
|
440
|
+
)
|
|
441
|
+
);
|
|
442
|
+
// Prefer deriving from css dims at logical zoom:
|
|
443
|
+
// cssWidth/cssHeight are already at `zoom` scale from getViewport({scale: zoom}).
|
|
444
|
+
canvasWidth = Math.max(1, Math.floor(input.cssWidth * (renderScale / zoom)));
|
|
445
|
+
let canvasHeight = Math.max(
|
|
446
|
+
1,
|
|
447
|
+
Math.floor(input.cssHeight * (renderScale / zoom))
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const area = canvasWidth * canvasHeight;
|
|
451
|
+
if (area > maxPixels) {
|
|
452
|
+
const factor = Math.sqrt(maxPixels / area);
|
|
453
|
+
renderScale = renderScale * factor;
|
|
454
|
+
canvasWidth = Math.max(
|
|
455
|
+
1,
|
|
456
|
+
Math.floor(input.cssWidth * (renderScale / zoom))
|
|
457
|
+
);
|
|
458
|
+
canvasHeight = Math.max(
|
|
459
|
+
1,
|
|
460
|
+
Math.floor(input.cssHeight * (renderScale / zoom))
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
renderScale,
|
|
466
|
+
cssScale: zoom,
|
|
467
|
+
canvasWidth,
|
|
468
|
+
canvasHeight,
|
|
469
|
+
dpr,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Link annotation sanitizer ───────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Allowlist external annotation URLs to http(s) only.
|
|
477
|
+
* Returns null for javascript:, file:, data:, relative, or empty.
|
|
478
|
+
*/
|
|
479
|
+
export function sanitizeAnnotationUrl(url: string): string | null {
|
|
480
|
+
if (typeof url !== "string") {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
const trimmed = url.trim();
|
|
484
|
+
if (!trimmed) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
// Absolute URLs only — relative schemes rejected
|
|
489
|
+
const parsed = new URL(trimmed);
|
|
490
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
491
|
+
return parsed.href;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Metrics channel (__gnoPdfMetrics) ───────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
export type PdfMetricKind =
|
|
502
|
+
| "renderStart"
|
|
503
|
+
| "renderCancel"
|
|
504
|
+
| "renderSettle"
|
|
505
|
+
| "pageCleanup"
|
|
506
|
+
| "documentDestroy";
|
|
507
|
+
|
|
508
|
+
export type PdfMetricOutcome = "completed" | "cancelled" | "failed";
|
|
509
|
+
|
|
510
|
+
export type PdfMetricEvent = {
|
|
511
|
+
seq: number;
|
|
512
|
+
t: number;
|
|
513
|
+
docId: string;
|
|
514
|
+
pageNumber: number | null;
|
|
515
|
+
taskId: string | null;
|
|
516
|
+
genId: number | null;
|
|
517
|
+
kind: PdfMetricKind;
|
|
518
|
+
outcome: PdfMetricOutcome | null;
|
|
519
|
+
scale: number | null;
|
|
520
|
+
canvasWidth: number | null;
|
|
521
|
+
canvasHeight: number | null;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
export type PdfMetricsSnapshotMeta = {
|
|
525
|
+
capacity: number;
|
|
526
|
+
dropped: number;
|
|
527
|
+
seqHigh: number;
|
|
528
|
+
t0Epoch: number;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
export type PdfMetricsSnapshot = PdfMetricsSnapshotMeta & {
|
|
532
|
+
events: readonly PdfMetricEvent[];
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const DEFAULT_METRICS_CAPACITY = 2000;
|
|
536
|
+
|
|
537
|
+
type MetricsState = {
|
|
538
|
+
capacity: number;
|
|
539
|
+
dropped: number;
|
|
540
|
+
seq: number;
|
|
541
|
+
t0Epoch: number;
|
|
542
|
+
t0Perf: number;
|
|
543
|
+
events: PdfMetricEvent[];
|
|
544
|
+
docCounter: number;
|
|
545
|
+
taskCounter: number;
|
|
546
|
+
genByDoc: Map<string, number>;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
function nowPerf(): number {
|
|
550
|
+
if (
|
|
551
|
+
typeof performance !== "undefined" &&
|
|
552
|
+
typeof performance.now === "function"
|
|
553
|
+
) {
|
|
554
|
+
return performance.now();
|
|
555
|
+
}
|
|
556
|
+
return Date.now();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function createMetricsState(capacity = DEFAULT_METRICS_CAPACITY): MetricsState {
|
|
560
|
+
return {
|
|
561
|
+
capacity: Math.max(1, capacity),
|
|
562
|
+
dropped: 0,
|
|
563
|
+
seq: 0,
|
|
564
|
+
t0Epoch: Date.now(),
|
|
565
|
+
t0Perf: nowPerf(),
|
|
566
|
+
events: [],
|
|
567
|
+
docCounter: 0,
|
|
568
|
+
taskCounter: 0,
|
|
569
|
+
genByDoc: new Map(),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function pushEvent(
|
|
574
|
+
state: MetricsState,
|
|
575
|
+
partial: Omit<PdfMetricEvent, "seq" | "t">
|
|
576
|
+
): PdfMetricEvent {
|
|
577
|
+
state.seq += 1;
|
|
578
|
+
// Spec: t is the direct monotonic performance.now() reading (ms).
|
|
579
|
+
// t0Epoch (wall clock at channel start/reset) + t0Perf enable wall mapping:
|
|
580
|
+
// wall ≈ t0Epoch + (t - t0Perf).
|
|
581
|
+
const event: PdfMetricEvent = {
|
|
582
|
+
seq: state.seq,
|
|
583
|
+
t: nowPerf(),
|
|
584
|
+
...partial,
|
|
585
|
+
};
|
|
586
|
+
state.events.push(event);
|
|
587
|
+
while (state.events.length > state.capacity) {
|
|
588
|
+
state.events.shift();
|
|
589
|
+
state.dropped += 1;
|
|
590
|
+
}
|
|
591
|
+
return event;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export type GnoPdfMetrics = {
|
|
595
|
+
reset: (opts?: { capacity?: number }) => void;
|
|
596
|
+
snapshot: () => PdfMetricsSnapshot;
|
|
597
|
+
export: () => PdfMetricsSnapshot;
|
|
598
|
+
mintDocId: () => string;
|
|
599
|
+
mintTaskId: () => string;
|
|
600
|
+
bumpGen: (docId: string) => number;
|
|
601
|
+
currentGen: (docId: string) => number;
|
|
602
|
+
recordRenderStart: (args: {
|
|
603
|
+
docId: string;
|
|
604
|
+
pageNumber: number;
|
|
605
|
+
taskId: string;
|
|
606
|
+
genId: number;
|
|
607
|
+
scale: number;
|
|
608
|
+
canvasWidth: number;
|
|
609
|
+
canvasHeight: number;
|
|
610
|
+
}) => PdfMetricEvent;
|
|
611
|
+
recordRenderCancel: (args: {
|
|
612
|
+
docId: string;
|
|
613
|
+
pageNumber: number;
|
|
614
|
+
taskId: string;
|
|
615
|
+
genId: number;
|
|
616
|
+
}) => PdfMetricEvent;
|
|
617
|
+
recordRenderSettle: (args: {
|
|
618
|
+
docId: string;
|
|
619
|
+
pageNumber: number;
|
|
620
|
+
taskId: string;
|
|
621
|
+
genId: number;
|
|
622
|
+
outcome: PdfMetricOutcome;
|
|
623
|
+
scale?: number | null;
|
|
624
|
+
}) => PdfMetricEvent;
|
|
625
|
+
recordPageCleanup: (args: {
|
|
626
|
+
docId: string;
|
|
627
|
+
pageNumber: number;
|
|
628
|
+
}) => PdfMetricEvent;
|
|
629
|
+
recordDocumentDestroy: (args: { docId: string }) => PdfMetricEvent;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
function createMetricsApi(state: MetricsState): GnoPdfMetrics {
|
|
633
|
+
return {
|
|
634
|
+
reset(opts) {
|
|
635
|
+
const nextCap = opts?.capacity ?? state.capacity;
|
|
636
|
+
state.capacity = Math.max(1, nextCap);
|
|
637
|
+
state.dropped = 0;
|
|
638
|
+
state.seq = 0;
|
|
639
|
+
state.t0Epoch = Date.now();
|
|
640
|
+
state.t0Perf = nowPerf();
|
|
641
|
+
state.events = [];
|
|
642
|
+
// Preserve opaque counters across reset so ids stay unique channel-wide
|
|
643
|
+
// within a process; gen map resets with the measurement window.
|
|
644
|
+
state.genByDoc = new Map();
|
|
645
|
+
},
|
|
646
|
+
snapshot() {
|
|
647
|
+
// Deep freeze: container, events array, and each event object.
|
|
648
|
+
// Structural clone so mutations cannot reach the live buffer.
|
|
649
|
+
const events = Object.freeze(
|
|
650
|
+
state.events.map((e) => Object.freeze({ ...e }))
|
|
651
|
+
);
|
|
652
|
+
return Object.freeze({
|
|
653
|
+
capacity: state.capacity,
|
|
654
|
+
dropped: state.dropped,
|
|
655
|
+
seqHigh: state.seq,
|
|
656
|
+
t0Epoch: state.t0Epoch,
|
|
657
|
+
events,
|
|
658
|
+
});
|
|
659
|
+
},
|
|
660
|
+
export() {
|
|
661
|
+
// JSON-serializable structural clone
|
|
662
|
+
return JSON.parse(JSON.stringify(this.snapshot())) as PdfMetricsSnapshot;
|
|
663
|
+
},
|
|
664
|
+
mintDocId() {
|
|
665
|
+
state.docCounter += 1;
|
|
666
|
+
return `d${state.docCounter}`;
|
|
667
|
+
},
|
|
668
|
+
mintTaskId() {
|
|
669
|
+
state.taskCounter += 1;
|
|
670
|
+
return `r${state.taskCounter}`;
|
|
671
|
+
},
|
|
672
|
+
bumpGen(docId: string) {
|
|
673
|
+
const next = (state.genByDoc.get(docId) ?? 0) + 1;
|
|
674
|
+
state.genByDoc.set(docId, next);
|
|
675
|
+
return next;
|
|
676
|
+
},
|
|
677
|
+
currentGen(docId: string) {
|
|
678
|
+
return state.genByDoc.get(docId) ?? 0;
|
|
679
|
+
},
|
|
680
|
+
recordRenderStart(args) {
|
|
681
|
+
return pushEvent(state, {
|
|
682
|
+
docId: args.docId,
|
|
683
|
+
pageNumber: args.pageNumber,
|
|
684
|
+
taskId: args.taskId,
|
|
685
|
+
genId: args.genId,
|
|
686
|
+
kind: "renderStart",
|
|
687
|
+
outcome: null,
|
|
688
|
+
scale: args.scale,
|
|
689
|
+
canvasWidth: args.canvasWidth,
|
|
690
|
+
canvasHeight: args.canvasHeight,
|
|
691
|
+
});
|
|
692
|
+
},
|
|
693
|
+
recordRenderCancel(args) {
|
|
694
|
+
return pushEvent(state, {
|
|
695
|
+
docId: args.docId,
|
|
696
|
+
pageNumber: args.pageNumber,
|
|
697
|
+
taskId: args.taskId,
|
|
698
|
+
genId: args.genId,
|
|
699
|
+
kind: "renderCancel",
|
|
700
|
+
outcome: null,
|
|
701
|
+
scale: null,
|
|
702
|
+
canvasWidth: null,
|
|
703
|
+
canvasHeight: null,
|
|
704
|
+
});
|
|
705
|
+
},
|
|
706
|
+
recordRenderSettle(args) {
|
|
707
|
+
return pushEvent(state, {
|
|
708
|
+
docId: args.docId,
|
|
709
|
+
pageNumber: args.pageNumber,
|
|
710
|
+
taskId: args.taskId,
|
|
711
|
+
genId: args.genId,
|
|
712
|
+
kind: "renderSettle",
|
|
713
|
+
outcome: args.outcome,
|
|
714
|
+
scale: args.scale ?? null,
|
|
715
|
+
canvasWidth: null,
|
|
716
|
+
canvasHeight: null,
|
|
717
|
+
});
|
|
718
|
+
},
|
|
719
|
+
recordPageCleanup(args) {
|
|
720
|
+
return pushEvent(state, {
|
|
721
|
+
docId: args.docId,
|
|
722
|
+
pageNumber: args.pageNumber,
|
|
723
|
+
taskId: null,
|
|
724
|
+
genId: null,
|
|
725
|
+
kind: "pageCleanup",
|
|
726
|
+
outcome: null,
|
|
727
|
+
scale: null,
|
|
728
|
+
canvasWidth: null,
|
|
729
|
+
canvasHeight: null,
|
|
730
|
+
});
|
|
731
|
+
},
|
|
732
|
+
recordDocumentDestroy(args) {
|
|
733
|
+
return pushEvent(state, {
|
|
734
|
+
docId: args.docId,
|
|
735
|
+
pageNumber: null,
|
|
736
|
+
taskId: null,
|
|
737
|
+
genId: null,
|
|
738
|
+
kind: "documentDestroy",
|
|
739
|
+
outcome: null,
|
|
740
|
+
scale: null,
|
|
741
|
+
canvasWidth: null,
|
|
742
|
+
canvasHeight: null,
|
|
743
|
+
});
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
declare global {
|
|
749
|
+
// eslint-disable-next-line no-var
|
|
750
|
+
var __gnoPdfMetrics: GnoPdfMetrics | undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const metricsState = createMetricsState();
|
|
754
|
+
const metricsApi = createMetricsApi(metricsState);
|
|
755
|
+
|
|
756
|
+
/** Attach once to globalThis so the channel survives React unmount. */
|
|
757
|
+
function attachMetrics(): GnoPdfMetrics {
|
|
758
|
+
const g = globalThis as typeof globalThis & {
|
|
759
|
+
__gnoPdfMetrics?: GnoPdfMetrics;
|
|
760
|
+
};
|
|
761
|
+
if (!g.__gnoPdfMetrics) {
|
|
762
|
+
g.__gnoPdfMetrics = metricsApi;
|
|
763
|
+
}
|
|
764
|
+
return g.__gnoPdfMetrics;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export const pdfMetrics: GnoPdfMetrics = attachMetrics();
|
|
768
|
+
|
|
769
|
+
// Convenience re-export of the channel for hooks
|
|
770
|
+
export function getPdfMetrics(): GnoPdfMetrics {
|
|
771
|
+
return attachMetrics();
|
|
772
|
+
}
|