@gmickel/gno 1.39.2 → 1.40.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.
@@ -1,4 +1,11 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type RefObject,
8
+ } from "react";
2
9
 
3
10
  import {
4
11
  classifyPdfError,
@@ -41,6 +48,13 @@ export type PageSlotState = {
41
48
  * mount canvas / TextLayer. Production PdfPageView `active` prop.
42
49
  */
43
50
  active: boolean;
51
+ /**
52
+ * Nonfatal geometry failure carried on this slot (R2): a later page whose
53
+ * `getPage` failed while page 1 succeeded. The slot keeps its placeholder
54
+ * size so the scroll model holds; the viewer shows the page error state in
55
+ * place of the page and every other slot stays mounted.
56
+ */
57
+ error: PdfFallbackReason | null;
44
58
  };
45
59
 
46
60
  export type UsePdfPagesOptions = {
@@ -54,6 +68,12 @@ export type UsePdfPagesOptions = {
54
68
  containerHeight: number;
55
69
  /** Bumped by viewer on every zoom/fit/scale commit. */
56
70
  genId: number;
71
+ /**
72
+ * Scrolling element that holds the page column. When the full geometry pass
73
+ * corrects placeholder heights, its `scrollTop` is adjusted by the height
74
+ * delta of the pages above the page in view so that page's top edge stays put.
75
+ */
76
+ scrollContainerRef?: RefObject<HTMLElement | null>;
57
77
  devicePixelRatio?: number;
58
78
  /**
59
79
  * Optional test doubles only (not a product API). Production omits these.
@@ -67,7 +87,12 @@ export type UsePdfPagesOptions = {
67
87
 
68
88
  export type UsePdfPagesResult = {
69
89
  slots: PageSlotState[];
70
- /** Geometry/page acquisition failure classified for the viewer state model. */
90
+ /**
91
+ * Fatal failure classified for the viewer state model: page 1 geometry (the
92
+ * document behind it) or a page-1 render-path page acquisition failure. A later
93
+ * page's geometry or render-path acquisition failure is not fatal — it rides
94
+ * on `PageSlotState.error`.
95
+ */
71
96
  error: PdfFallbackReason | null;
72
97
  liveCanvasCount: number;
73
98
  observePage: (pageNumber: number, el: HTMLElement | null) => void;
@@ -110,6 +135,141 @@ type PageCache = {
110
135
 
111
136
  type BasePageGeometry = { width: number; height: number };
112
137
 
138
+ type PageGeometryResult = {
139
+ geometry: BasePageGeometry | null;
140
+ /** Classified `getPage` failure for this page; `null` once measured. */
141
+ error: PdfFallbackReason | null;
142
+ };
143
+
144
+ /**
145
+ * One geometry pass over a document. Page 1 resolves alone first so the viewer
146
+ * can publish placeholder slots (every page at page 1's size) and paint page 1
147
+ * before the rest is measured; the bounded worker pass then continues from
148
+ * page 2 and `full` carries every page's real size or its classified failure.
149
+ */
150
+ type GeometryPass = {
151
+ firstPage: Promise<BasePageGeometry>;
152
+ full: Promise<PageGeometryResult[]>;
153
+ /** Result of `full` once it resolved; a later effect run skips the placeholder publish. */
154
+ settled: PageGeometryResult[] | null;
155
+ };
156
+
157
+ const GEOMETRY_WORKERS = 4;
158
+
159
+ function startGeometryPass(
160
+ doc: PDFDocumentProxy,
161
+ numPages: number
162
+ ): GeometryPass {
163
+ const measure = async (pageNumber: number): Promise<BasePageGeometry> => {
164
+ const page = await doc.getPage(pageNumber);
165
+ const viewport = page.getViewport({ scale: 1 });
166
+ return { width: viewport.width, height: viewport.height };
167
+ };
168
+ const firstPage = measure(1);
169
+ const full = (async (): Promise<PageGeometryResult[]> => {
170
+ const results = Array.from(
171
+ { length: numPages },
172
+ (): PageGeometryResult => ({ geometry: null, error: null })
173
+ );
174
+ // Page 1 is fatal: a rejection here rejects `full` as well.
175
+ results[0] = { geometry: await firstPage, error: null };
176
+ let nextPageNumber = 2;
177
+ const worker = async (): Promise<void> => {
178
+ while (nextPageNumber <= numPages) {
179
+ const pageNumber = nextPageNumber;
180
+ nextPageNumber += 1;
181
+ try {
182
+ results[pageNumber - 1] = {
183
+ geometry: await measure(pageNumber),
184
+ error: null,
185
+ };
186
+ } catch (error) {
187
+ // A later page's failure rides on its slot; the pass continues.
188
+ results[pageNumber - 1] = {
189
+ geometry: null,
190
+ error: classifyPdfError(error),
191
+ };
192
+ }
193
+ }
194
+ };
195
+ await Promise.all(
196
+ Array.from(
197
+ { length: Math.min(GEOMETRY_WORKERS, numPages - 1) },
198
+ async () => worker()
199
+ )
200
+ );
201
+ return results;
202
+ })();
203
+ const pass: GeometryPass = { firstPage, full, settled: null };
204
+ full.then(
205
+ (results) => {
206
+ pass.settled = results;
207
+ },
208
+ () => {
209
+ // Surfaced through `firstPage` by the effect that awaits it.
210
+ }
211
+ );
212
+ return pass;
213
+ }
214
+
215
+ function anchorPage(visible: ReadonlySet<number>): number | null {
216
+ let anchor: number | null = null;
217
+ for (const pageNumber of visible) {
218
+ if (anchor === null || pageNumber < anchor) {
219
+ anchor = pageNumber;
220
+ }
221
+ }
222
+ return anchor;
223
+ }
224
+
225
+ /**
226
+ * Page whose top edge straddles the viewport top, derived from laid-out boxes
227
+ * rather than the IntersectionObserver visible set (that set uses a 200px
228
+ * rootMargin and can include a page entirely above the viewport).
229
+ *
230
+ * Measures the DOM — including `.gno-pdf-page` 1rem margin and the column's
231
+ * padding — instead of reconstructing offsets from slot heights plus CSS
232
+ * constants. Returns null when the measurement is unusable: fewer than two
233
+ * registered pages, or offsets that are not strictly increasing (happy-dom
234
+ * and a not-yet-laid-out DOM return zero rects).
235
+ */
236
+ function anchorPageFromLayout(
237
+ container: HTMLElement,
238
+ elements: ReadonlyMap<number, HTMLElement>,
239
+ numPages: number
240
+ ): number | null {
241
+ const pages: number[] = [];
242
+ const offsets: number[] = [];
243
+ const containerTop = container.getBoundingClientRect().top;
244
+ for (let pageNumber = 1; pageNumber <= numPages; pageNumber += 1) {
245
+ const el = elements.get(pageNumber);
246
+ if (!el) {
247
+ continue;
248
+ }
249
+ pages.push(pageNumber);
250
+ offsets.push(
251
+ el.getBoundingClientRect().top - containerTop + container.scrollTop
252
+ );
253
+ }
254
+ if (pages.length < 2) {
255
+ return null;
256
+ }
257
+ for (let index = 1; index < offsets.length; index += 1) {
258
+ if (!(offsets[index]! > offsets[index - 1]!)) {
259
+ return null;
260
+ }
261
+ }
262
+ const edge = container.scrollTop + 0.5;
263
+ // No qualifying page means the reader is above page 1.
264
+ let anchor = 1;
265
+ for (let index = 0; index < pages.length; index += 1) {
266
+ if (offsets[index]! <= edge) {
267
+ anchor = pages[index]!;
268
+ }
269
+ }
270
+ return anchor;
271
+ }
272
+
113
273
  function setsEqual(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
114
274
  if (a.size !== b.size) {
115
275
  return false;
@@ -165,6 +325,7 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
165
325
  containerWidth,
166
326
  containerHeight,
167
327
  genId,
328
+ scrollContainerRef,
168
329
  devicePixelRatio = typeof window !== "undefined"
169
330
  ? window.devicePixelRatio || 1
170
331
  : 1,
@@ -189,9 +350,12 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
189
350
  const canvasRef = useRef<Map<number, HTMLCanvasElement>>(new Map());
190
351
  const reservationsRef = useRef<Map<number, symbol>>(new Map());
191
352
  const latestRequestRef = useRef<Map<number, symbol>>(new Map());
192
- const geometryCacheRef = useRef<
193
- WeakMap<PDFDocumentProxy, Promise<BasePageGeometry[]>>
194
- >(new WeakMap());
353
+ // Holds the full pass only: the placeholder slots derived from page 1 are a
354
+ // transient publish, never cached geometry. `firstPage` is that pass's own
355
+ // page-1 prefix, so a re-run during the pass can still publish early.
356
+ const geometryCacheRef = useRef<WeakMap<PDFDocumentProxy, GeometryPass>>(
357
+ new WeakMap()
358
+ );
195
359
  const observerRef = useRef<IntersectionObserver | null>(null);
196
360
  const settledTaskIdsRef = useRef<Set<string>>(new Set());
197
361
  const metrics = getPdfMetrics();
@@ -238,28 +402,37 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
238
402
  }
239
403
  }, []);
240
404
 
405
+ const openEpoch = (): void => {
406
+ epochSeqRef.current += 1;
407
+ epochBatchOpenRef.current = true;
408
+ epochAdmittedRef.current = 0;
409
+ pendingRef.current.clear();
410
+ reservationsRef.current.clear();
411
+ latestRequestRef.current.clear();
412
+ if (pendingTimerRef.current !== null) {
413
+ clearTimeout(pendingTimerRef.current);
414
+ pendingTimerRef.current = null;
415
+ }
416
+ };
417
+
241
418
  // Synchronous epoch bump (same pattern as genIdRef above): a doc or gen change
242
419
  // must open its exempt batch before any render path observes the new state.
243
420
  {
244
421
  const epochKey = `${docId ?? ""}:${genId}`;
245
422
  if (epochKeyRef.current !== epochKey) {
246
423
  epochKeyRef.current = epochKey;
247
- epochSeqRef.current += 1;
248
- epochBatchOpenRef.current = true;
249
- epochAdmittedRef.current = 0;
250
- pendingRef.current.clear();
251
- reservationsRef.current.clear();
252
- latestRequestRef.current.clear();
253
- if (pendingTimerRef.current !== null) {
254
- clearTimeout(pendingTimerRef.current);
255
- pendingTimerRef.current = null;
256
- }
424
+ openEpoch();
257
425
  }
258
426
  }
259
427
  const scaleRef = useRef(scale);
260
428
  scaleRef.current = scale;
261
429
  const visibleRef = useRef(visiblePages);
262
430
  visibleRef.current = visiblePages;
431
+ /** Slots as last rendered — the heights the DOM currently shows. */
432
+ const slotsRef = useRef(slots);
433
+ slotsRef.current = slots;
434
+ /** Scroll delta to apply once the corrected slot heights are in the DOM. */
435
+ const pendingScrollAdjustRef = useRef(0);
263
436
  const disposedRef = useRef(false);
264
437
  const disposePromiseRef = useRef<Promise<void> | null>(null);
265
438
 
@@ -287,9 +460,12 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
287
460
  [numPages]
288
461
  );
289
462
 
290
- // Resolve rotation-aware geometry with bounded concurrency before publishing
291
- // slots. Correct placeholders are part of the scroll model: copying page 1
292
- // makes mixed-size documents jump and makes fit modes overflow later pages.
463
+ // Rotation-aware geometry drives the scroll model. Slots publish as soon as
464
+ // page 1 is measured (every page at page 1's size, fit scale from page 1) so
465
+ // the first paint does not wait for the rest of the document; the full pass
466
+ // then corrects sizes and the fit scale in one commit, anchored so the page
467
+ // in view does not jump. Copying page 1 is only ever the placeholder:
468
+ // mixed-size documents and fit modes settle on the real per-page sizes.
293
469
  useEffect(() => {
294
470
  let cancelled = false;
295
471
  if (!doc || numPages === 0) {
@@ -299,75 +475,15 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
299
475
  return;
300
476
  }
301
477
 
302
- void (async () => {
303
- let geometryPromise = geometryCacheRef.current.get(doc);
304
- if (!geometryPromise) {
305
- geometryPromise = (async (): Promise<BasePageGeometry[]> => {
306
- const geometry = Array.from<BasePageGeometry | undefined>({
307
- length: numPages,
308
- });
309
- let nextPageNumber = 1;
310
- let failure: unknown = null;
311
- const worker = async (): Promise<void> => {
312
- while (failure === null) {
313
- const pageNumber = nextPageNumber;
314
- nextPageNumber += 1;
315
- if (pageNumber > numPages) {
316
- return;
317
- }
318
- try {
319
- const page = await doc.getPage(pageNumber);
320
- if (failure !== null) {
321
- return;
322
- }
323
- const viewport = page.getViewport({ scale: 1 });
324
- geometry[pageNumber - 1] = {
325
- width: viewport.width,
326
- height: viewport.height,
327
- };
328
- } catch (error) {
329
- failure = error;
330
- }
331
- }
332
- };
333
- await Promise.all(
334
- Array.from({ length: Math.min(4, numPages) }, async () => worker())
335
- );
336
- const resolved = geometry.filter(
337
- (entry): entry is BasePageGeometry => entry !== undefined
338
- );
339
- if (failure !== null) {
340
- throw failure;
341
- }
342
- if (resolved.length !== numPages) {
343
- throw new Error("PDF page geometry is incomplete");
344
- }
345
- return resolved;
346
- })();
347
- geometryCacheRef.current.set(doc, geometryPromise);
348
- }
349
-
350
- let resolvedGeometry: BasePageGeometry[];
351
- try {
352
- resolvedGeometry = await geometryPromise;
353
- } catch (error) {
354
- if (cancelled) {
355
- return;
356
- }
357
- setSlots([]);
358
- setPageError(classifyPdfError(error));
359
- return;
360
- }
361
- if (cancelled) {
362
- return;
363
- }
364
-
365
- const maxWidth = Math.max(
366
- ...resolvedGeometry.map((entry) => entry.width)
367
- );
368
- const maxHeight = Math.max(
369
- ...resolvedGeometry.map((entry) => entry.height)
370
- );
478
+ const commit = (
479
+ results: PageGeometryResult[],
480
+ placeholder: BasePageGeometry,
481
+ correction: boolean,
482
+ placeholderScale?: number
483
+ ): number => {
484
+ const sizes = results.map((entry) => entry.geometry ?? placeholder);
485
+ const maxWidth = Math.max(...sizes.map((entry) => entry.width));
486
+ const maxHeight = Math.max(...sizes.map((entry) => entry.height));
371
487
  let nextScale = zoom;
372
488
  if (fitMode === "width" && containerWidth > 0) {
373
489
  nextScale = containerWidth / maxWidth;
@@ -382,21 +498,114 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
382
498
  );
383
499
  }
384
500
  nextScale = Math.max(0.25, Math.min(4, nextScale));
385
- setScale(nextScale);
386
- setPageError(null);
387
501
 
388
502
  const visible = visibleRef.current;
389
503
  const active = computeActiveSet(visible, numPages);
390
- setSlots(
391
- resolvedGeometry.map((entry, index) => ({
504
+ const next = sizes.map(
505
+ (entry, index): PageSlotState => ({
392
506
  pageNumber: index + 1,
393
507
  width: entry.width * nextScale,
394
508
  height: entry.height * nextScale,
395
509
  rendered: false,
396
510
  visible: visible.has(index + 1),
397
511
  active: active.has(index + 1),
398
- }))
512
+ error: results[index]?.error ?? null,
513
+ })
399
514
  );
515
+
516
+ if (correction) {
517
+ // Keep the top edge of the page straddling the viewport top where it
518
+ // is: shift scrollTop by the height delta of pages strictly above that
519
+ // page, applied once the new heights are in the DOM. The anchor is
520
+ // read from laid-out boxes here only (DOM includes page margin and
521
+ // column padding); the observer set is the fallback when layout is
522
+ // unusable or the scroll container is missing.
523
+ const shown = slotsRef.current;
524
+ const container = scrollContainerRef?.current;
525
+ const layoutAnchor =
526
+ container == null
527
+ ? null
528
+ : anchorPageFromLayout(container, elementsRef.current, numPages);
529
+ const anchor = layoutAnchor ?? anchorPage(visible);
530
+ if (anchor !== null && shown.length === next.length) {
531
+ let delta = 0;
532
+ for (let index = 0; index < anchor - 1; index += 1) {
533
+ delta += next[index]!.height - shown[index]!.height;
534
+ }
535
+ pendingScrollAdjustRef.current += delta;
536
+ }
537
+ }
538
+
539
+ // Same scale as the placeholder publish: a page already drawn stays
540
+ // drawn. Compare against the scale that commit actually returned, not
541
+ // scaleRef — that ref is stale if the full pass settles before React
542
+ // re-rendered after the placeholder.
543
+ const keepRendered = nextScale === (placeholderScale ?? nextScale);
544
+ if (correction && !keepRendered) {
545
+ // The correction is a scale commit like a zoom, so it opens a new
546
+ // admission epoch and the exempt batch admits every active page immediately.
547
+ openEpoch();
548
+ }
549
+ setScale(nextScale);
550
+ if (!correction) {
551
+ // Geometry recovered: the placeholder commit clears any earlier
552
+ // geometry error. The correction never clears, so a fatal error raised
553
+ // by a page 1 render attempt during the placeholder window stays.
554
+ setPageError(null);
555
+ setSlots(next);
556
+ return nextScale;
557
+ }
558
+ setSlots((prev) =>
559
+ next.map((slot, index) =>
560
+ keepRendered && prev[index]?.pageNumber === slot.pageNumber
561
+ ? { ...slot, rendered: prev[index].rendered }
562
+ : slot
563
+ )
564
+ );
565
+ return nextScale;
566
+ };
567
+
568
+ void (async () => {
569
+ let pass = geometryCacheRef.current.get(doc);
570
+ if (!pass) {
571
+ pass = startGeometryPass(doc, numPages);
572
+ geometryCacheRef.current.set(doc, pass);
573
+ }
574
+
575
+ let firstPage: BasePageGeometry;
576
+ try {
577
+ firstPage = await pass.firstPage;
578
+ } catch (error) {
579
+ if (cancelled) {
580
+ return;
581
+ }
582
+ // Page 1 (or the document behind it) failed: fatal, existing fallback.
583
+ setSlots([]);
584
+ setPageError(classifyPdfError(error));
585
+ return;
586
+ }
587
+ if (cancelled) {
588
+ return;
589
+ }
590
+
591
+ const publishedPlaceholder = pass.settled === null;
592
+ let placeholderScale = 1;
593
+ if (publishedPlaceholder) {
594
+ placeholderScale = commit(
595
+ Array.from(
596
+ { length: numPages },
597
+ (): PageGeometryResult => ({ geometry: null, error: null })
598
+ ),
599
+ firstPage,
600
+ false
601
+ );
602
+ }
603
+
604
+ const results = await pass.full;
605
+ if (cancelled) {
606
+ return;
607
+ }
608
+ commit(results, firstPage, publishedPlaceholder, placeholderScale);
400
609
  })();
401
610
 
402
611
  return () => {
@@ -404,6 +613,19 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
404
613
  };
405
614
  }, [doc, numPages, zoom, fitMode, containerWidth, containerHeight]);
406
615
 
616
+ // Apply the anchored scroll shift in the same frame the corrected heights land.
617
+ useLayoutEffect(() => {
618
+ const delta = pendingScrollAdjustRef.current;
619
+ if (delta === 0) {
620
+ return;
621
+ }
622
+ pendingScrollAdjustRef.current = 0;
623
+ const container = scrollContainerRef?.current;
624
+ if (container) {
625
+ container.scrollTop += delta;
626
+ }
627
+ }, [slots, scrollContainerRef]);
628
+
407
629
  // IntersectionObserver drives visibility → slot.active/visible → PdfPageView.
408
630
  useEffect(() => {
409
631
  if (!IntersectionObserverImpl) {
@@ -837,6 +1059,16 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
837
1059
  page = await doc.getPage(pageNumber);
838
1060
  } catch (error) {
839
1061
  releaseReservation();
1062
+ if (pageNumber > 1) {
1063
+ setSlots((prev) =>
1064
+ prev.map((s) =>
1065
+ s.pageNumber === pageNumber
1066
+ ? { ...s, error: classifyPdfError(error), rendered: false }
1067
+ : s
1068
+ )
1069
+ );
1070
+ return;
1071
+ }
840
1072
  setPageError(classifyPdfError(error));
841
1073
  return;
842
1074
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * PDF transport tier constants (fn-136 R1).
3
+ *
4
+ * Kept free of any pdfjs-dist import so tooling that runs under plain Bun
5
+ * (for example the Playwright smoke in scripts/pdf-viewer-smoke.ts) can read
6
+ * the product bounds without evaluating the browser-only pdf.js build.
7
+ * `./pdf` re-exports both names, so app code keeps importing from the facade.
8
+ */
9
+
10
+ /** Files whose HEAD Content-Length is under this load in one GET. */
11
+ export const PDF_WHOLE_FILE_MAX_BYTES = 8 * 1024 * 1024;
12
+ /** Range chunk size for files at or above the whole-file bound. */
13
+ export const PDF_RANGE_CHUNK_BYTES = 1024 * 1024;
@@ -16,6 +16,11 @@ import {
16
16
  type PageViewport,
17
17
  } from "pdfjs-dist";
18
18
 
19
+ import {
20
+ PDF_RANGE_CHUNK_BYTES,
21
+ PDF_WHOLE_FILE_MAX_BYTES,
22
+ } from "./pdf-transport";
23
+
19
24
  // ── Worker / asset bootstrap ────────────────────────────────────────────────
20
25
  //
21
26
  // Browser (and Electrobun webview) production: same-origin /vendor/pdfjs/* routes.
@@ -124,12 +129,76 @@ export type PdfAnnotation = {
124
129
 
125
130
  // ── Document load wrapper ───────────────────────────────────────────────────
126
131
 
132
+ /**
133
+ * Transport tier for one document load (fn-136 R1).
134
+ * - `whole-file`: one GET, ranges disabled, body streamed as it arrives.
135
+ * - `ranged`: Range requests of PDF_RANGE_CHUNK_BYTES with background fetch.
136
+ */
137
+ export type PdfTransportHint = "whole-file" | "ranged";
138
+
127
139
  export type GnoGetDocumentParams = {
128
140
  url: string;
141
+ /** Omitted → `ranged`: chunked transport, safe for an unknown or large file. */
142
+ transport?: PdfTransportHint;
129
143
  // Intentionally NO caller-controlled document id — every load mints a fresh
130
144
  // opaque instance id internally (I2-6 / Sol rereview).
131
145
  };
132
146
 
147
+ /** pdf.js DocumentInitParameters subset that the transport tier controls. */
148
+ export type PdfTransportOptions = {
149
+ disableRange?: boolean;
150
+ rangeChunkSize?: number;
151
+ disableStream: boolean;
152
+ disableAutoFetch: boolean;
153
+ };
154
+
155
+ /**
156
+ * Map a transport hint to pdf.js transport options.
157
+ *
158
+ * whole-file: with `disableStream: true` pdf.js cancels the full-body reader
159
+ * as soon as the server advertises byte ranges, so the single-request tier
160
+ * must disable ranges explicitly or its first GET is thrown away.
161
+ *
162
+ * ranged: `disableStream: true` keeps the full-body GET from competing with
163
+ * Range requests; `disableAutoFetch: false` lets pdf.js pull the remaining
164
+ * chunks in the background instead of one round trip per parser miss. Range
165
+ * eligibility still needs Content-Length > 2×rangeChunkSize plus
166
+ * Accept-Ranges: bytes (emitted by GET /api/doc-asset).
167
+ */
168
+ export function transportOptionsFor(
169
+ hint: PdfTransportHint
170
+ ): PdfTransportOptions {
171
+ if (hint === "whole-file") {
172
+ return {
173
+ disableRange: true,
174
+ disableStream: false,
175
+ disableAutoFetch: false,
176
+ };
177
+ }
178
+ return {
179
+ rangeChunkSize: PDF_RANGE_CHUNK_BYTES,
180
+ disableStream: true,
181
+ disableAutoFetch: false,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Pick the transport tier from a HEAD-probed Content-Length.
187
+ * Unknown (null / non-finite / negative) sizes fall back to `ranged`.
188
+ */
189
+ export function transportHintForContentLength(
190
+ contentLength: number | null
191
+ ): PdfTransportHint {
192
+ if (
193
+ contentLength === null ||
194
+ !Number.isFinite(contentLength) ||
195
+ contentLength < 0
196
+ ) {
197
+ return "ranged";
198
+ }
199
+ return contentLength < PDF_WHOLE_FILE_MAX_BYTES ? "whole-file" : "ranged";
200
+ }
201
+
133
202
  /**
134
203
  * Loading task augmented with the opaque per-load document instance id.
135
204
  * `gnoDocId` is never derived from URL/path/URI/filename/title/content and is
@@ -180,16 +249,8 @@ export function getDocument(
180
249
  // hits standardFontDataUrl, breaking the offline standard-font contract.
181
250
  // Force pdfjs-dist standard_fonts/* over same-origin routes instead.
182
251
  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,
252
+ // Transport tier by file size (fn-136 R1); see transportOptionsFor.
253
+ ...transportOptionsFor(params.transport ?? "ranged"),
193
254
  // Never enable embedded PDF scripting.
194
255
  // enableScripting is intentionally omitted (defaults false).
195
256
  // pdfjs v5 removed the former eval-support flag; CSP enforces no unsafe-eval.
@@ -335,6 +396,12 @@ export function isRenderingCancelled(err: unknown): boolean {
335
396
  return msg.includes("rendering cancelled") || msg.includes("cancelled");
336
397
  }
337
398
 
399
+ // ── Transport tier constants (fn-136 R1) ────────────────────────────────────
400
+ // Defined in ./pdf-transport (no pdfjs-dist import) and re-exported here so
401
+ // the facade stays the single import surface for app code.
402
+
403
+ export { PDF_RANGE_CHUNK_BYTES, PDF_WHOLE_FILE_MAX_BYTES };
404
+
338
405
  // ── Zoom / fit / canvas cap math ────────────────────────────────────────────
339
406
 
340
407
  export const MIN_ZOOM = 0.25;
@@ -0,0 +1,19 @@
1
+ /** Shared client view of GET /api/capabilities. */
2
+
3
+ import { apiFetch } from "../hooks/use-api";
4
+
5
+ export interface ServerCapabilities {
6
+ bm25: boolean;
7
+ vector: boolean;
8
+ hybrid: boolean;
9
+ answer: boolean;
10
+ /** True only for a same-host client; a proxied or forwarded request is remote. */
11
+ localClient: boolean;
12
+ }
13
+
14
+ export function fetchServerCapabilities(): Promise<{
15
+ data: ServerCapabilities | null;
16
+ error: string | null;
17
+ }> {
18
+ return apiFetch<ServerCapabilities>("/api/capabilities");
19
+ }