vomebook commited on
Commit
42e8f31
·
1 Parent(s): 5dae2c4

fix(reader): PDF.js for large hybrid books, prioritized image fallback, stable page geometry, hybrid outline

Browse files

- Range-transport PDFs up to 1 GiB (complete bodies keep 512 MiB) on client and proxy
- A failed primary leaves hybrid mode; image pages use a priority-ordered pool with preemption
- Visible-page image fallback waits for this book's measured PDF draw time
- Image and canvas geometry changes preserve the reading anchor; first geometry seeds shells
- PDF outline fills a missing manifest TOC after the first page

backend/app.py CHANGED
@@ -1907,6 +1907,9 @@ READER_ISOLATION_HEADERS = {
1907
  "X-Content-Type-Options": "nosniff",
1908
  }
1909
  READER_DOCUMENT_MAX_BYTES = 512 * 1024 * 1024
 
 
 
1910
  READER_RESOURCE_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
1911
  READER_RESOURCE_MAX_BYTES = 64 * 1024 * 1024
1912
  READER_BUCKET_CACHE_TTL_SECONDS = 3600
@@ -2810,6 +2813,11 @@ async def _stream_reader_document(
2810
  if name in request.headers
2811
  }
2812
  range_header = forwarded_headers.get("Range")
 
 
 
 
 
2813
  # Conditional requests must reach the origin: a cached partial body cannot
2814
  # represent a 200/304 decision made against a different validator.
2815
  has_conditionals = any(
@@ -2907,11 +2915,11 @@ async def _stream_reader_document(
2907
  if (range_start > range_end or range_end >= total_length
2908
  or (length is not None and length != range_end - range_start + 1)):
2909
  return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502)
2910
- if max_bytes is not None and total_length > max_bytes:
2911
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2912
  elif status == 416:
2913
  match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or ""))
2914
- if match and max_bytes is not None and int(match.group(1)) > max_bytes:
2915
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2916
  if request.method == "HEAD" or status == 304:
2917
  return Response(status_code=status, headers=headers)
 
1907
  "X-Content-Type-Options": "nosniff",
1908
  }
1909
  READER_DOCUMENT_MAX_BYTES = 512 * 1024 * 1024
1910
+ # Total size accepted for a PDF whose bounded Range reads are proxied. Each
1911
+ # response body still obeys READER_DOCUMENT_MAX_BYTES; complete bodies keep it.
1912
+ READER_PDF_RANGE_MAX_BYTES = 1024 * 1024 * 1024
1913
  READER_RESOURCE_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
1914
  READER_RESOURCE_MAX_BYTES = 64 * 1024 * 1024
1915
  READER_BUCKET_CACHE_TTL_SECONDS = 3600
 
2813
  if name in request.headers
2814
  }
2815
  range_header = forwarded_headers.get("Range")
2816
+ bounded_range = re.fullmatch(r"bytes=([0-9]{1,20})-([0-9]{1,20})", range_header or "")
2817
+ total_limit = max_bytes
2818
+ if (max_bytes is not None and bounded_range and not is_media_stream
2819
+ and (urlparse(target_url).path.lower().endswith(".pdf") or media_type == "application/pdf")):
2820
+ total_limit = max(max_bytes, READER_PDF_RANGE_MAX_BYTES)
2821
  # Conditional requests must reach the origin: a cached partial body cannot
2822
  # represent a 200/304 decision made against a different validator.
2823
  has_conditionals = any(
 
2915
  if (range_start > range_end or range_end >= total_length
2916
  or (length is not None and length != range_end - range_start + 1)):
2917
  return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502)
2918
+ if total_limit is not None and total_length > total_limit:
2919
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2920
  elif status == 416:
2921
  match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or ""))
2922
+ if match and total_limit is not None and int(match.group(1)) > total_limit:
2923
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2924
  if request.method == "HEAD" or status == 304:
2925
  return Response(status_code=status, headers=headers)
static/reader.js CHANGED
@@ -83,6 +83,17 @@ const readerContext = {
83
  get THEME_SUN_ICON() { return THEME_SUN_ICON; },
84
  get TOC_VIRTUALIZATION_THRESHOLD() { return TOC_VIRTUALIZATION_THRESHOLD; },
85
  get TOC_VIRTUAL_ROW_HEIGHT() { return TOC_VIRTUAL_ROW_HEIGHT; },
 
 
 
 
 
 
 
 
 
 
 
86
  get acquirePdfRenderSlot() { return acquirePdfRenderSlot; }, set acquirePdfRenderSlot(value) { acquirePdfRenderSlot = value; },
87
  get acquirePdfTextSlot() { return acquirePdfTextSlot; }, set acquirePdfTextSlot(value) { acquirePdfTextSlot = value; },
88
  get activateFoliateTocEntry() { return activateFoliateTocEntry; }, set activateFoliateTocEntry(value) { activateFoliateTocEntry = value; },
@@ -243,7 +254,6 @@ const readerContext = {
243
  get pdfFirstPagePreloadPage() { return pdfFirstPagePreloadPage; }, set pdfFirstPagePreloadPage(value) { pdfFirstPagePreloadPage = value; },
244
  get pdfHybridDocumentPromise() { return pdfHybridDocumentPromise; }, set pdfHybridDocumentPromise(value) { pdfHybridDocumentPromise = value; },
245
  get pdfHybridSearchUnavailable() { return pdfHybridSearchUnavailable; }, set pdfHybridSearchUnavailable(value) { pdfHybridSearchUnavailable = value; },
246
- get pdfHybridPreviewLoads() { return pdfHybridPreviewLoads; }, set pdfHybridPreviewLoads(value) { pdfHybridPreviewLoads = value; },
247
  get pdfHybridUrl() { return pdfHybridUrl; }, set pdfHybridUrl(value) { pdfHybridUrl = value; },
248
  get pdfLastScrollTop() { return pdfLastScrollTop; }, set pdfLastScrollTop(value) { pdfLastScrollTop = value; },
249
  get pdfManifestPrefetchActive() { return pdfManifestPrefetchActive; },
@@ -587,7 +597,7 @@ let {
587
  stopPdfManifestPrefetch, trimPdfManifestPrefetches, pdfPixelBudget, pdfSurfacePixels,
588
  pdfRetainedPixels, trimPdfSurfaces, clearPdfManifestPrefetches, takePdfManifestPrefetch,
589
  queuePdfManifestPrefetch, prioritizePdfManifestPrefetch, schedulePdfManifestPrefetch, trimPdfManifestImages,
590
- cancelSpeculativePdfRenders, promotePdfRenderWaiter, acquirePdfRenderSlot, releasePdfRenderSlot,
591
  acquirePdfTextSlot, drainPdfTextWaiters, releasePdfTextSlot, trimPdfCanvases,
592
  rerenderVisiblePdfPages, refreshNearbyPdfPages, scheduleVisiblePdfRenderReconciliation,
593
  notePdfViewportMotion, pdfScrollActivity
@@ -636,8 +646,8 @@ let {
636
 
637
  let {
638
  preloadPdfFirstPage, pdfManifestSource, pdfPageEntry, appendShellsBatched,
639
- pdfSelectionIntersects, correctPdfCrossPageSelection, createPdfShellWindow, renderPdfPages,
640
- renderImagePages, renderPdf
641
  } = createReaderPdfPageWindow(readerContext);
642
 
643
  let {
@@ -657,7 +667,8 @@ let {
657
  let {
658
  setPdfPageLoadingState, isPdfPageVisible, pdfPagePriority, normalizePdfPriority,
659
  renderPdfInBackground, pdfPrimarySource, renderPdfManifestShell, renderHybridPdfCanvas,
660
- renderPdfImageShell, renderPdfShell
 
661
  } = createReaderPdfRenderer(readerContext);
662
 
663
  let {
@@ -740,7 +751,6 @@ let pdfEnginePreload = null;
740
  let pdfHybridUrl = "";
741
  let pdfHybridDocumentPromise = null;
742
  let pdfHybridSearchUnavailable = false;
743
- let pdfHybridPreviewLoads = 0;
744
  let pdfWorker = null;
745
  let releasePdfWorker = null;
746
  readerBindings.applySourceSessionAndCachedMetadata();
 
83
  get THEME_SUN_ICON() { return THEME_SUN_ICON; },
84
  get TOC_VIRTUALIZATION_THRESHOLD() { return TOC_VIRTUALIZATION_THRESHOLD; },
85
  get TOC_VIRTUAL_ROW_HEIGHT() { return TOC_VIRTUAL_ROW_HEIGHT; },
86
+ get acquirePdfPreviewSlot() { return acquirePdfPreviewSlot; }, set acquirePdfPreviewSlot(value) { acquirePdfPreviewSlot = value; },
87
+ get releasePdfPreviewSlot() { return releasePdfPreviewSlot; }, set releasePdfPreviewSlot(value) { releasePdfPreviewSlot = value; },
88
+ get pdfPreviewLoads() { return pdfPreviewLoads; }, set pdfPreviewLoads(value) { pdfPreviewLoads = value; },
89
+ get resetPdfLatencySamples() { return resetPdfLatencySamples; }, set resetPdfLatencySamples(value) { resetPdfLatencySamples = value; },
90
+ get notePdfLatency() { return notePdfLatency; }, set notePdfLatency(value) { notePdfLatency = value; },
91
+ get pdfHybridPreviewGrace() { return pdfHybridPreviewGrace; }, set pdfHybridPreviewGrace(value) { pdfHybridPreviewGrace = value; },
92
+ get applyPdfShellGeometry() { return applyPdfShellGeometry; }, set applyPdfShellGeometry(value) { applyPdfShellGeometry = value; },
93
+ get startPdfHybridDocument() { return startPdfHybridDocument; }, set startPdfHybridDocument(value) { startPdfHybridDocument = value; },
94
+ get leavePdfHybridMode() { return leavePdfHybridMode; }, set leavePdfHybridMode(value) { leavePdfHybridMode = value; },
95
+ get loadPdfOutline() { return loadPdfOutline; }, set loadPdfOutline(value) { loadPdfOutline = value; },
96
+ get seedPdfPageGeometry() { return seedPdfPageGeometry; }, set seedPdfPageGeometry(value) { seedPdfPageGeometry = value; },
97
  get acquirePdfRenderSlot() { return acquirePdfRenderSlot; }, set acquirePdfRenderSlot(value) { acquirePdfRenderSlot = value; },
98
  get acquirePdfTextSlot() { return acquirePdfTextSlot; }, set acquirePdfTextSlot(value) { acquirePdfTextSlot = value; },
99
  get activateFoliateTocEntry() { return activateFoliateTocEntry; }, set activateFoliateTocEntry(value) { activateFoliateTocEntry = value; },
 
254
  get pdfFirstPagePreloadPage() { return pdfFirstPagePreloadPage; }, set pdfFirstPagePreloadPage(value) { pdfFirstPagePreloadPage = value; },
255
  get pdfHybridDocumentPromise() { return pdfHybridDocumentPromise; }, set pdfHybridDocumentPromise(value) { pdfHybridDocumentPromise = value; },
256
  get pdfHybridSearchUnavailable() { return pdfHybridSearchUnavailable; }, set pdfHybridSearchUnavailable(value) { pdfHybridSearchUnavailable = value; },
 
257
  get pdfHybridUrl() { return pdfHybridUrl; }, set pdfHybridUrl(value) { pdfHybridUrl = value; },
258
  get pdfLastScrollTop() { return pdfLastScrollTop; }, set pdfLastScrollTop(value) { pdfLastScrollTop = value; },
259
  get pdfManifestPrefetchActive() { return pdfManifestPrefetchActive; },
 
597
  stopPdfManifestPrefetch, trimPdfManifestPrefetches, pdfPixelBudget, pdfSurfacePixels,
598
  pdfRetainedPixels, trimPdfSurfaces, clearPdfManifestPrefetches, takePdfManifestPrefetch,
599
  queuePdfManifestPrefetch, prioritizePdfManifestPrefetch, schedulePdfManifestPrefetch, trimPdfManifestImages,
600
+ cancelSpeculativePdfRenders, promotePdfRenderWaiter, acquirePdfPreviewSlot, releasePdfPreviewSlot, pdfPreviewLoads, acquirePdfRenderSlot, releasePdfRenderSlot,
601
  acquirePdfTextSlot, drainPdfTextWaiters, releasePdfTextSlot, trimPdfCanvases,
602
  rerenderVisiblePdfPages, refreshNearbyPdfPages, scheduleVisiblePdfRenderReconciliation,
603
  notePdfViewportMotion, pdfScrollActivity
 
646
 
647
  let {
648
  preloadPdfFirstPage, pdfManifestSource, pdfPageEntry, appendShellsBatched,
649
+ pdfSelectionIntersects, correctPdfCrossPageSelection, createPdfShellWindow, startPdfHybridDocument,
650
+ leavePdfHybridMode, loadPdfOutline, seedPdfPageGeometry, renderPdfPages, renderImagePages, renderPdf
651
  } = createReaderPdfPageWindow(readerContext);
652
 
653
  let {
 
667
  let {
668
  setPdfPageLoadingState, isPdfPageVisible, pdfPagePriority, normalizePdfPriority,
669
  renderPdfInBackground, pdfPrimarySource, renderPdfManifestShell, renderHybridPdfCanvas,
670
+ renderPdfImageShell, renderPdfShell, resetPdfLatencySamples, notePdfLatency, pdfHybridPreviewGrace,
671
+ applyPdfShellGeometry
672
  } = createReaderPdfRenderer(readerContext);
673
 
674
  let {
 
751
  let pdfHybridUrl = "";
752
  let pdfHybridDocumentPromise = null;
753
  let pdfHybridSearchUnavailable = false;
 
754
  let pdfWorker = null;
755
  let releasePdfWorker = null;
756
  readerBindings.applySourceSessionAndCachedMetadata();
static/reader/core/reader-security.js CHANGED
@@ -2,6 +2,8 @@
2
  "use strict";
3
  const LIMITS = Object.freeze({
4
  documentBytes: 512 * 1024 * 1024,
 
 
5
  archiveCompressedBytes: 512 * 1024 * 1024,
6
  archiveExpandedBytes: 1536 * 1024 * 1024,
7
  archiveEntries: 20000,
 
2
  "use strict";
3
  const LIMITS = Object.freeze({
4
  documentBytes: 512 * 1024 * 1024,
5
+ // PDF.js range transport only: demanded chunks are read, not the body.
6
+ pdfRangeDocumentBytes: 1024 * 1024 * 1024,
7
  archiveCompressedBytes: 512 * 1024 * 1024,
8
  archiveExpandedBytes: 1536 * 1024 * 1024,
9
  archiveEntries: 20000,
static/reader/pdf/reader-pdf-engine.mjs CHANGED
@@ -25,13 +25,20 @@ export function createReaderPdfEngine(readerContext) {
25
  readerContext.openPdfRangeTask(pdfjs, options(url), {
26
  signal:owner.signal, readBytes:VoiceOfMLReaderSecurity.readBytes, initial,
27
  limit:VoiceOfMLReaderSecurity.LIMITS.documentBytes,
 
28
  canPrefetchOpening: () => !!readerContext.pdfV3Manifest && readerContext.readerRestorationLoaded &&
29
  (!readerContext.readerRestorationEntry || Number(readerContext.readerRestorationEntry.page) <= 1),
30
  canPrefetchFirstPage: () => readerContext.capability?.mode === "pdf" &&
31
  readerContext.readerRestorationLoaded && !readerContext.restorationFailed &&
32
  (!readerContext.readerRestorationEntry || Number(readerContext.readerRestorationEntry.page) === 1),
33
- onError() {
34
- if (!documentReady || readerContext.readerAbortController.signal.aborted || readerContext.capability.mode !== "pdf") return;
 
 
 
 
 
 
35
  // Page retries cannot revive a destroyed PDF.js document. Capture an
36
  // eligible position before failure stops work; reload owns recovery.
37
  readerContext.saveProgress();
@@ -191,6 +198,7 @@ export function createReaderPdfEngine(readerContext) {
191
  let initial = readerContext.preparePdfInitialRange({url:proxyUrl, rangeChunkSize:262144}, {
192
  signal:AbortSignal.any([owner.signal, readerContext.readerAbortController.signal]),
193
  readBytes:VoiceOfMLReaderSecurity.readBytes, limit:VoiceOfMLReaderSecurity.LIMITS.documentBytes,
 
194
  canPrefetchFirstPage: () => pageOneKnown && !owner.signal.aborted &&
195
  navigation === readerContext.readerRuntime?.currentGeneration("navigation")
196
  });
 
25
  readerContext.openPdfRangeTask(pdfjs, options(url), {
26
  signal:owner.signal, readBytes:VoiceOfMLReaderSecurity.readBytes, initial,
27
  limit:VoiceOfMLReaderSecurity.LIMITS.documentBytes,
28
+ rangeLimit:VoiceOfMLReaderSecurity.LIMITS.pdfRangeDocumentBytes,
29
  canPrefetchOpening: () => !!readerContext.pdfV3Manifest && readerContext.readerRestorationLoaded &&
30
  (!readerContext.readerRestorationEntry || Number(readerContext.readerRestorationEntry.page) <= 1),
31
  canPrefetchFirstPage: () => readerContext.capability?.mode === "pdf" &&
32
  readerContext.readerRestorationLoaded && !readerContext.restorationFailed &&
33
  (!readerContext.readerRestorationEntry || Number(readerContext.readerRestorationEntry.page) === 1),
34
+ onError(error) {
35
+ if (!documentReady || readerContext.readerAbortController.signal.aborted) return;
36
+ // A hybrid PDF-pages book keeps its image stream as the only surface.
37
+ if (readerContext.capability.mode === "pdf-pages" && readerContext.pdfHybridUrl) {
38
+ readerContext.leavePdfHybridMode(undefined, error);
39
+ return;
40
+ }
41
+ if (readerContext.capability.mode !== "pdf") return;
42
  // Page retries cannot revive a destroyed PDF.js document. Capture an
43
  // eligible position before failure stops work; reload owns recovery.
44
  readerContext.saveProgress();
 
198
  let initial = readerContext.preparePdfInitialRange({url:proxyUrl, rangeChunkSize:262144}, {
199
  signal:AbortSignal.any([owner.signal, readerContext.readerAbortController.signal]),
200
  readBytes:VoiceOfMLReaderSecurity.readBytes, limit:VoiceOfMLReaderSecurity.LIMITS.documentBytes,
201
+ rangeLimit:VoiceOfMLReaderSecurity.LIMITS.pdfRangeDocumentBytes,
202
  canPrefetchFirstPage: () => pageOneKnown && !owner.signal.aborted &&
203
  navigation === readerContext.readerRuntime?.currentGeneration("navigation")
204
  });
static/reader/pdf/reader-pdf-network.mjs CHANGED
@@ -80,7 +80,10 @@ function abortable(promise, signal) {
80
  });
81
  }
82
 
83
- async function readPdfRange(options, begin, end, {signal, readBytes, limit, rangeCache}, expected = null, onHeaders = null, partialOnly = false) {
 
 
 
84
  signal.throwIfAborted();
85
  const key = new URL(options.url, globalThis.location?.href).href;
86
  let cached = null;
@@ -102,7 +105,8 @@ async function readPdfRange(options, begin, end, {signal, readBytes, limit, rang
102
  if (response.status !== 206 || !match || match.slice(1).some(value=>!Number.isSafeInteger(Number(value))))
103
  throw invalid();
104
  const [start, last, total] = match.slice(1).map(Number);
105
- if (total > limit) throw Object.assign(new Error("READER_RESOURCE_LIMIT"), {code:"READER_RESOURCE_LIMIT"});
 
106
  if (start !== begin || last !== Math.min(end,total)-1 || total <= start) throw invalid();
107
  const etag = response.headers.get("ETag"), encoding = response.headers.get("Content-Encoding");
108
  if (encoding && encoding !== "identity") throw invalid();
@@ -269,7 +273,7 @@ export async function preparePdfInitialRange(options, hooks) {
269
  }
270
  }
271
 
272
- export async function openPdfRangeTask(pdfjs, options, {signal, readBytes, limit, onError, initial = null,
273
  rangeCache = sharedPdfRangeCache(), canPrefetchFirstPage = () => false, canPrefetchOpening = () => false}) {
274
  if (typeof pdfjs.PDFDataRangeTransport !== "function") return pdfjs.getDocument(options);
275
  const chunkSize = options.rangeChunkSize;
@@ -288,10 +292,10 @@ export async function openPdfRangeTask(pdfjs, options, {signal, readBytes, limit
288
  const cancelled = () => stop(signal.reason);
289
  signal.addEventListener("abort", cancelled, {once:true});
290
  const read = (begin, end, expected = null) => readPdfRange(options, begin, end,
291
- {signal:active, readBytes, limit, rangeCache}, expected);
292
  try {
293
  const first = prepared = await (initial || preparePdfInitialRange(options,
294
- {signal:active, readBytes, limit, rangeCache, canPrefetchFirstPage}));
295
  active.throwIfAborted();
296
  if (!first) {
297
  stop();
@@ -310,7 +314,7 @@ export async function openPdfRangeTask(pdfjs, options, {signal, readBytes, limit
310
  let tail = first.tail || null;
311
  if (!tail && first.total > chunkSize * 4) {
312
  const begin = Math.floor((first.total - 1) / chunkSize) * chunkSize - chunkSize;
313
- tail = preparePdfRangeHint(options, begin, first.total, {signal:active, readBytes, limit, rangeCache}, first);
314
  }
315
  // A conservative read-ahead hint, not a PDF parser: only a direct Length
316
  // before the first stream, in the bounded opening header, can qualify.
 
80
  });
81
  }
82
 
83
+ // Complete bodies retain `limit`. A validated range-transport document may be
84
+ // larger (`rangeLimit`): PDF.js reserves one length-sized buffer, but only
85
+ // demanded chunks are fetched or written. See the dated large-PDF report.
86
+ async function readPdfRange(options, begin, end, {signal, readBytes, limit, rangeLimit = limit, rangeCache}, expected = null, onHeaders = null, partialOnly = false) {
87
  signal.throwIfAborted();
88
  const key = new URL(options.url, globalThis.location?.href).href;
89
  let cached = null;
 
105
  if (response.status !== 206 || !match || match.slice(1).some(value=>!Number.isSafeInteger(Number(value))))
106
  throw invalid();
107
  const [start, last, total] = match.slice(1).map(Number);
108
+ if (total > (total === last + 1 - start ? limit : rangeLimit))
109
+ throw Object.assign(new Error("READER_RESOURCE_LIMIT"), {code:"READER_RESOURCE_LIMIT"});
110
  if (start !== begin || last !== Math.min(end,total)-1 || total <= start) throw invalid();
111
  const etag = response.headers.get("ETag"), encoding = response.headers.get("Content-Encoding");
112
  if (encoding && encoding !== "identity") throw invalid();
 
273
  }
274
  }
275
 
276
+ export async function openPdfRangeTask(pdfjs, options, {signal, readBytes, limit, rangeLimit = limit, onError, initial = null,
277
  rangeCache = sharedPdfRangeCache(), canPrefetchFirstPage = () => false, canPrefetchOpening = () => false}) {
278
  if (typeof pdfjs.PDFDataRangeTransport !== "function") return pdfjs.getDocument(options);
279
  const chunkSize = options.rangeChunkSize;
 
292
  const cancelled = () => stop(signal.reason);
293
  signal.addEventListener("abort", cancelled, {once:true});
294
  const read = (begin, end, expected = null) => readPdfRange(options, begin, end,
295
+ {signal:active, readBytes, limit, rangeLimit, rangeCache}, expected);
296
  try {
297
  const first = prepared = await (initial || preparePdfInitialRange(options,
298
+ {signal:active, readBytes, limit, rangeLimit, rangeCache, canPrefetchFirstPage}));
299
  active.throwIfAborted();
300
  if (!first) {
301
  stop();
 
314
  let tail = first.tail || null;
315
  if (!tail && first.total > chunkSize * 4) {
316
  const begin = Math.floor((first.total - 1) / chunkSize) * chunkSize - chunkSize;
317
+ tail = preparePdfRangeHint(options, begin, first.total, {signal:active, readBytes, limit, rangeLimit, rangeCache}, first);
318
  }
319
  // A conservative read-ahead hint, not a PDF parser: only a direct Length
320
  // before the first stream, in the bounded opening header, can qualify.
static/reader/pdf/reader-pdf-page-window.mjs CHANGED
@@ -45,16 +45,27 @@ export function createReaderPdfPageWindow(readerContext) {
45
  return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry;
46
  }
47
 
 
 
 
48
  function startPdfHybridDocument(totalPages = readerContext.documentState.pageCount) {
 
 
49
  readerContext.pdfHybridUrl = readerContext.pdfPrimarySource();
50
- if (!readerContext.pdfHybridUrl || readerContext.pdfHybridDocumentPromise) return;
 
51
  const documentPromise = readerContext.loadPdfDocument(readerContext.pdfHybridUrl).then((pdf) => {
52
  readerContext.assertReaderActive();
53
- if (pdf.numPages !== totalPages) throw new Error("PDF_PRIMARY_PAGE_COUNT_MISMATCH");
 
 
 
 
54
  readerContext.pdfDocument = pdf;
55
  readerContext.clearPdfManifestPrefetches();
56
  readerContext.cancelSpeculativePdfRenders(readerContext.pageAtMarker?.());
57
  readerContext.syncCapabilityControls();
 
58
  return pdf;
59
  });
60
  readerContext.pdfHybridDocumentPromise = documentPromise;
@@ -62,12 +73,95 @@ export function createReaderPdfPageWindow(readerContext) {
62
  if (error?.name === "AbortError" || readerContext.readerLifecycle.disposed ||
63
  readerContext.pdfHybridDocumentPromise !== documentPromise) return;
64
  console.warn("Primary PDF unavailable; page stream retained", error);
65
- // The page stream stays readable, but without OCR it has no text layer.
66
- readerContext.pdfHybridSearchUnavailable = true;
67
- readerContext.syncCapabilityControls();
68
  });
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  async function appendShellsBatched(createShell, total, batchSize) {
72
  for (let start = 2; start <= total; start += batchSize) {
73
  readerContext.assertReaderActive();
@@ -142,6 +236,7 @@ export function createReaderPdfPageWindow(readerContext) {
142
  }
143
 
144
  function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) {
 
145
  if (total <= 256) return null;
146
  const chunkSize = 32, chunks = [], ratios = new Map();
147
  let frame = 0, disposed = false;
@@ -255,6 +350,13 @@ export function createReaderPdfPageWindow(readerContext) {
255
  const bounds = shell.getBoundingClientRect();
256
  if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width);
257
  },
 
 
 
 
 
 
 
258
  schedule,
259
  resize,
260
  dispose() {
@@ -271,6 +373,9 @@ export function createReaderPdfPageWindow(readerContext) {
271
  }
272
 
273
  async function renderPdfPages(prepared) {
 
 
 
274
  const response = await prepared;
275
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
276
  const decoded = JSON.parse(await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes));
@@ -304,6 +409,7 @@ export function createReaderPdfPageWindow(readerContext) {
304
  const source = readerContext.pdfManifestSource();
305
  const totalPages = manifest.page_count;
306
  if (!source) throw new Error("PDF_MANIFEST_INVALID");
 
307
  readerContext.pdfPageManifest = { ...source, pageCount: totalPages };
308
  if (readerContext.pdfV3Manifest) readerContext.pdfPageManifest.pageUrl = () => "";
309
  startPdfHybridDocument(totalPages);
@@ -375,7 +481,7 @@ export function createReaderPdfPageWindow(readerContext) {
375
  shell.className = "reader-page";
376
  shell.dataset.page = String(page);
377
  if (readerContext.pdfHybridUrl) shell.dataset.pdfHybrid = "1";
378
- shell.style.aspectRatio = "1 / 1.414";
379
  if (readerContext.pdfV3Map) {
380
  const geometry = readerContext.pdfV3Map.pages[page - 1];
381
  shell.style.aspectRatio = `${geometry.width} / ${geometry.height}`;
@@ -409,7 +515,7 @@ export function createReaderPdfPageWindow(readerContext) {
409
  readerContext.pdfShellWindow = readerContext.createPdfShellWindow(totalPages, firstShell, createShell, (shell) => {
410
  observer.unobserve(shell);
411
  visibleObserver.unobserve(shell);
412
- }, 1.414);
413
  readerContext.pdfShellsReady = readerContext.pdfShellWindow ? Promise.resolve() : readerContext.appendShellsBatched(createShell, totalPages, 250);
414
  readerContext.pdfShellsReady.catch(() => {});
415
  try {
@@ -424,6 +530,8 @@ export function createReaderPdfPageWindow(readerContext) {
424
  } finally {
425
  releaseInitialRenderGate();
426
  }
 
 
427
  }
428
 
429
  async function renderImagePages(prepared) {
@@ -558,42 +666,9 @@ export function createReaderPdfPageWindow(readerContext) {
558
  } finally {
559
  releaseInitialRenderGate();
560
  }
561
- const outlineReady = (async () => {
562
- if (typeof pdf.getOutline !== "function") return;
563
- try {
564
- const outline = await readerContext.awaitReader(pdf.getOutline()),
565
- entries = [];
566
- const cleanLabel = (value) =>
567
- String(value || "未命名章节")
568
- .replace(/[\u2610-\u2612\u25a1]\s*[xX×]?\s*$/u, "")
569
- .trim() || "未命名章节";
570
- const append = async (items, depth = 0) => {
571
- for (const item of items || []) {
572
- readerContext.assertReaderActive();
573
- let destination = item.dest,
574
- page = 0;
575
- if (typeof destination === "string") destination = await pdf.getDestination(destination);
576
- if (destination && destination[0]) {
577
- try {
578
- page = (await pdf.getPageIndex(destination[0])) + 1;
579
- } catch (_) {}
580
- }
581
- entries.push({
582
- label: page ? `${cleanLabel(item.title)} · 第 ${page} 页` : cleanLabel(item.title),
583
- depth,
584
- activate: (generation) => (page ? readerContext.goToPage(page, generation) : false)
585
- });
586
- await append(item.items, depth + 1);
587
- }
588
- };
589
- await append(outline);
590
- readerContext.assertReaderActive();
591
- if (entries.length) readerContext.setToc(entries);
592
- } catch (error) {
593
- if (!readerContext.readerAbortController.signal.aborted)
594
- console.warn("PDF outline could not be loaded", error);
595
- }
596
- })();
597
  outlineReady.catch(() => {});
598
  await readerContext.pdfShellsReady;
599
  readerContext.assertReaderActive();
@@ -607,6 +682,10 @@ export function createReaderPdfPageWindow(readerContext) {
607
  pdfSelectionIntersects,
608
  correctPdfCrossPageSelection,
609
  createPdfShellWindow,
 
 
 
 
610
  renderPdfPages,
611
  renderImagePages,
612
  renderPdf
 
45
  return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry;
46
  }
47
 
48
+ // Per-book PDF-pages state; renderPdfPages resets it.
49
+ let pdfPagesBook = { hybridFailed: false, manifestToc: false, initialShown: false, outlineStarted: false, seedRatio: 0 };
50
+
51
  function startPdfHybridDocument(totalPages = readerContext.documentState.pageCount) {
52
+ // A failed primary leaves hybrid mode for the rest of this book.
53
+ if (pdfPagesBook.hybridFailed || readerContext.pdfHybridDocumentPromise) return;
54
  readerContext.pdfHybridUrl = readerContext.pdfPrimarySource();
55
+ if (!readerContext.pdfHybridUrl) return;
56
+ const book = pdfPagesBook;
57
  const documentPromise = readerContext.loadPdfDocument(readerContext.pdfHybridUrl).then((pdf) => {
58
  readerContext.assertReaderActive();
59
+ if (book.hybridFailed) throw new Error("PDF_PRIMARY_TRANSPORT");
60
+ if (pdf.numPages !== totalPages) {
61
+ pdf.destroy?.()?.catch?.(() => {});
62
+ throw new Error("PDF_PRIMARY_PAGE_COUNT_MISMATCH");
63
+ }
64
  readerContext.pdfDocument = pdf;
65
  readerContext.clearPdfManifestPrefetches();
66
  readerContext.cancelSpeculativePdfRenders(readerContext.pageAtMarker?.());
67
  readerContext.syncCapabilityControls();
68
+ maybeLoadHybridOutline(book);
69
  return pdf;
70
  });
71
  readerContext.pdfHybridDocumentPromise = documentPromise;
 
73
  if (error?.name === "AbortError" || readerContext.readerLifecycle.disposed ||
74
  readerContext.pdfHybridDocumentPromise !== documentPromise) return;
75
  console.warn("Primary PDF unavailable; page stream retained", error);
76
+ leavePdfHybridMode(book);
 
 
77
  });
78
  }
79
 
80
+ // The image stream becomes the only surface: ordinary prioritized image
81
+ // admission and manifest prefetch replace the PDF-preview race.
82
+ function leavePdfHybridMode(book = pdfPagesBook, error = null) {
83
+ if (book !== pdfPagesBook || book.hybridFailed || readerContext.readerLifecycle.disposed) return;
84
+ if (error) console.warn("Primary PDF failed after opening; page stream retained", error);
85
+ book.hybridFailed = true;
86
+ const pdf = readerContext.pdfDocument;
87
+ readerContext.pdfDocument = null;
88
+ readerContext.pdfHybridUrl = "";
89
+ // The page stream stays readable, but without OCR it has no text layer.
90
+ readerContext.pdfHybridSearchUnavailable = true;
91
+ if (pdf && error) pdf.destroy?.()?.catch?.(() => {});
92
+ for (const shell of readerContext.content.querySelectorAll(".reader-page[data-pdf-hybrid]")) {
93
+ delete shell.dataset.pdfHybrid;
94
+ if (shell.dataset.renderState !== "rendered") shell._pdfRenderNeedsRequeue = true;
95
+ }
96
+ readerContext.syncCapabilityControls();
97
+ readerContext.scheduleVisiblePdfRenderReconciliation(true);
98
+ const page = readerContext.documentState.page;
99
+ if (page) readerContext.schedulePdfManifestPrefetch(page);
100
+ }
101
+
102
+ async function loadPdfOutline(pdf) {
103
+ if (typeof pdf?.getOutline !== "function") return;
104
+ try {
105
+ const outline = await readerContext.awaitReader(pdf.getOutline()),
106
+ entries = [];
107
+ const cleanLabel = (value) =>
108
+ String(value || "未命名章节")
109
+ .replace(/[\u2610-\u2612\u25a1]\s*[xX×]?\s*$/u, "")
110
+ .trim() || "未命名章节";
111
+ const append = async (items, depth = 0) => {
112
+ for (const item of items || []) {
113
+ readerContext.assertReaderActive();
114
+ let destination = item.dest,
115
+ page = 0;
116
+ if (typeof destination === "string") destination = await pdf.getDestination(destination);
117
+ if (destination && destination[0]) {
118
+ try {
119
+ page = (await pdf.getPageIndex(destination[0])) + 1;
120
+ } catch (_) {}
121
+ }
122
+ entries.push({
123
+ label: page ? `${cleanLabel(item.title)} · 第 ${page} 页` : cleanLabel(item.title),
124
+ depth,
125
+ activate: (generation) => (page ? readerContext.goToPage(page, generation) : false)
126
+ });
127
+ await append(item.items, depth + 1);
128
+ }
129
+ };
130
+ await append(outline);
131
+ readerContext.assertReaderActive();
132
+ return entries;
133
+ } catch (error) {
134
+ if (!readerContext.readerAbortController.signal.aborted)
135
+ console.warn("PDF outline could not be loaded", error);
136
+ }
137
+ }
138
+
139
+ // A page manifest without its own TOC takes the primary PDF outline, after
140
+ // the opening page is shown so outline reads never delay the first screen.
141
+ function maybeLoadHybridOutline(book = pdfPagesBook) {
142
+ if (book !== pdfPagesBook || book.manifestToc || book.outlineStarted || !book.initialShown) return;
143
+ const pdf = readerContext.pdfDocument;
144
+ if (!pdf || !readerContext.pdfHybridUrl) return;
145
+ book.outlineStarted = true;
146
+ loadPdfOutline(pdf).then((entries) => {
147
+ if (book !== pdfPagesBook || book.manifestToc || !entries?.length || readerContext.readerLifecycle.disposed) return;
148
+ readerContext.setToc(entries);
149
+ }).catch(() => {});
150
+ }
151
+
152
+ // The first measured page geometry replaces the A4 estimate for unrendered
153
+ // shells and virtual chunk heights. Callers run inside applyPdfShellGeometry,
154
+ // which keeps the reading anchor in place.
155
+ function seedPdfPageGeometry(width, height) {
156
+ if (pdfPagesBook.seedRatio || readerContext.pdfV3Map || !(width > 0 && height > 0)) return;
157
+ const ratio = height / width;
158
+ pdfPagesBook.seedRatio = ratio;
159
+ if (Math.abs(ratio / 1.414 - 1) < .001) return;
160
+ for (const shell of readerContext.content.querySelectorAll(".reader-page:not([data-page-geometry])"))
161
+ shell.style.aspectRatio = `1 / ${ratio}`;
162
+ readerContext.pdfShellWindow?.setDefaultRatio(ratio);
163
+ }
164
+
165
  async function appendShellsBatched(createShell, total, batchSize) {
166
  for (let start = 2; start <= total; start += batchSize) {
167
  readerContext.assertReaderActive();
 
236
  }
237
 
238
  function createPdfShellWindow(total, firstShell, createShell, unobserve, defaultRatio) {
239
+ // `defaultRatio` may be replaced once a real page geometry is known.
240
  if (total <= 256) return null;
241
  const chunkSize = 32, chunks = [], ratios = new Map();
242
  let frame = 0, disposed = false;
 
350
  const bounds = shell.getBoundingClientRect();
351
  if (bounds.width && bounds.height) ratios.set(Number(shell.dataset.page), bounds.height / bounds.width);
352
  },
353
+ // The caller owns anchor compensation (see seedPdfPageGeometry).
354
+ setDefaultRatio(ratio) {
355
+ if (disposed || !(ratio > 0)) return;
356
+ defaultRatio = ratio;
357
+ const width = readWidth();
358
+ for (const chunk of chunks) if (!chunk.mounted) chunk.element.style.height = `${chunkHeight(chunk, width)}px`;
359
+ },
360
  schedule,
361
  resize,
362
  dispose() {
 
373
  }
374
 
375
  async function renderPdfPages(prepared) {
376
+ pdfPagesBook = { hybridFailed: false, manifestToc: false, initialShown: false, outlineStarted: false, seedRatio: 0 };
377
+ const book = pdfPagesBook;
378
+ readerContext.resetPdfLatencySamples();
379
  const response = await prepared;
380
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
381
  const decoded = JSON.parse(await VoiceOfMLReaderSecurity.readText(response, VoiceOfMLReaderSecurity.LIMITS.manifestBytes));
 
409
  const source = readerContext.pdfManifestSource();
410
  const totalPages = manifest.page_count;
411
  if (!source) throw new Error("PDF_MANIFEST_INVALID");
412
+ book.manifestToc = Array.isArray(manifest.toc) && manifest.toc.length > 0;
413
  readerContext.pdfPageManifest = { ...source, pageCount: totalPages };
414
  if (readerContext.pdfV3Manifest) readerContext.pdfPageManifest.pageUrl = () => "";
415
  startPdfHybridDocument(totalPages);
 
481
  shell.className = "reader-page";
482
  shell.dataset.page = String(page);
483
  if (readerContext.pdfHybridUrl) shell.dataset.pdfHybrid = "1";
484
+ shell.style.aspectRatio = `1 / ${book.seedRatio || 1.414}`;
485
  if (readerContext.pdfV3Map) {
486
  const geometry = readerContext.pdfV3Map.pages[page - 1];
487
  shell.style.aspectRatio = `${geometry.width} / ${geometry.height}`;
 
515
  readerContext.pdfShellWindow = readerContext.createPdfShellWindow(totalPages, firstShell, createShell, (shell) => {
516
  observer.unobserve(shell);
517
  visibleObserver.unobserve(shell);
518
+ }, book.seedRatio || 1.414);
519
  readerContext.pdfShellsReady = readerContext.pdfShellWindow ? Promise.resolve() : readerContext.appendShellsBatched(createShell, totalPages, 250);
520
  readerContext.pdfShellsReady.catch(() => {});
521
  try {
 
530
  } finally {
531
  releaseInitialRenderGate();
532
  }
533
+ book.initialShown = true;
534
+ maybeLoadHybridOutline(book);
535
  }
536
 
537
  async function renderImagePages(prepared) {
 
666
  } finally {
667
  releaseInitialRenderGate();
668
  }
669
+ const outlineReady = loadPdfOutline(pdf).then((entries) => {
670
+ if (entries?.length) readerContext.setToc(entries);
671
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
  outlineReady.catch(() => {});
673
  await readerContext.pdfShellsReady;
674
  readerContext.assertReaderActive();
 
682
  pdfSelectionIntersects,
683
  correctPdfCrossPageSelection,
684
  createPdfShellWindow,
685
+ startPdfHybridDocument,
686
+ leavePdfHybridMode,
687
+ loadPdfOutline,
688
+ seedPdfPageGeometry,
689
  renderPdfPages,
690
  renderImagePages,
691
  renderPdf
static/reader/pdf/reader-pdf-renderer.mjs CHANGED
@@ -127,6 +127,51 @@ export function createReaderPdfRenderer(readerContext) {
127
  } catch (_) { return ""; }
128
  }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  function renderPdfManifestShell(shell, force = false, priority = false) {
131
  if (!readerContext.pdfHybridUrl) return readerContext.renderPdfImageShell(shell, force, priority);
132
  if (!force && shell.querySelector("canvas.ready")) return Promise.resolve();
@@ -170,18 +215,24 @@ export function createReaderPdfRenderer(readerContext) {
170
  // fallback: scrolling may promote it while its getPage/draw is still slow.
171
  let pdfSettled = false;
172
  pdfTask.then(() => { pdfSettled = true; }, () => { pdfSettled = true; });
173
- while (!readerContext.isPdfPageVisible(shell) && !pdfSettled) {
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
175
- await readerContext.waitForReader(100);
 
176
  }
177
- if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
178
- const winner = await Promise.race([
179
- pdfTask.then(() => "pdf", () => "preview"),
180
- readerContext.waitForReader(readerContext.pdfScrollActivity?.(true) ? 0 : 150).then(() => "preview")
181
- ]);
182
- if (winner === "pdf") return pdfTask;
183
- if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
184
- if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask;
185
  const preview = await readerContext.renderPdfImageShell(shell, false, priority);
186
  if (controller.signal.aborted) throw readerContext.readerAbortError();
187
  return preview;
@@ -213,6 +264,7 @@ export function createReaderPdfRenderer(readerContext) {
213
  signal.addEventListener("abort", cancel, { once: true });
214
  try {
215
  const pdf = await readerContext.awaitReader(readerContext.pdfHybridDocumentPromise, signal);
 
216
  do {
217
  const generation = readerContext.readerRuntime.currentGeneration("pdf");
218
  if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
@@ -220,6 +272,7 @@ export function createReaderPdfRenderer(readerContext) {
220
  // Admit page reads too: getPage can demand ranges before canvas rendering.
221
  await readerContext.measurePdf(readerContext.acquirePdfRenderSlot(priority, shell, signal), "queue", Number(shell.dataset.page));
222
  acquired = true;
 
223
  shell._pdfRenderOwnsSlot = true;
224
  }
225
  if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
@@ -247,18 +300,20 @@ export function createReaderPdfRenderer(readerContext) {
247
  canvas.width = canvas.height = 0;
248
  continue;
249
  }
250
- const anchor = readerContext.content.querySelector(`.reader-page[data-page="${readerContext.documentState.page}"]`);
251
- const oldTop = anchor?.getBoundingClientRect().top;
252
- const oldHeight = anchor?.getBoundingClientRect().height || 1;
253
- const fraction = anchor ? Math.max(0, (readerContext.viewport.getBoundingClientRect().top - oldTop) / oldHeight) : 0;
254
- const previous = shell.querySelector("canvas");
255
- canvas.classList.add("ready");
256
- if (previous) { previous.replaceWith(canvas); previous.width = previous.height = 0; }
257
- else shell.prepend(canvas);
258
- shell.style.aspectRatio = `${base.width} / ${base.height}`;
259
- shell._renderCancel?.();
260
- shell.querySelector("img")?.remove();
261
- shell._v3PreviewRelease?.();
 
 
262
  const pageState = shell.querySelector(".reader-page-state");
263
  if (pageState) pageState.hidden = true;
264
  shell.dataset.pdfPresented = "1";
@@ -267,10 +322,6 @@ export function createReaderPdfRenderer(readerContext) {
267
  delete shell.dataset.renderRetries;
268
  delete shell.dataset.renderFailed;
269
  shell.dataset.renderUsedAt = String(Date.now());
270
- if (anchor) {
271
- const current = anchor.getBoundingClientRect();
272
- readerContext.viewport.scrollTop += current.top - oldTop + fraction * (current.height - oldHeight);
273
- }
274
  readerContext.pdfShellWindow?.remember(shell);
275
  readerContext.trimPdfCanvases(shell);
276
  (readerContext.ocrManifestUrl ? readerContext.renderPdfOcrText(shell) : readerContext.renderPdfText(page, shell, signal)).catch(() => {});
@@ -293,7 +344,6 @@ export function createReaderPdfRenderer(readerContext) {
293
  if (!force && shell.dataset.renderState === "rendered") return Promise.resolve();
294
  shell.dataset.renderState = "rendering";
295
  const renderController = new AbortController();
296
- const hybridPreview = !!readerContext.pdfHybridUrl;
297
  shell._renderCancel = () => renderController.abort();
298
  shell._renderStarted = false;
299
  priority = readerContext.normalizePdfPriority(priority || readerContext.pdfPagePriority(shell));
@@ -301,15 +351,11 @@ export function createReaderPdfRenderer(readerContext) {
301
  const task = (async () => {
302
  let acquired = false;
303
  try {
304
- if (hybridPreview) {
305
- while (readerContext.pdfHybridPreviewLoads >= 2) {
306
- await readerContext.waitForReader(25);
307
- if (renderController.signal.aborted) throw readerContext.readerAbortError();
308
- }
309
- if (renderController.signal.aborted) throw readerContext.readerAbortError();
310
- readerContext.pdfHybridPreviewLoads++;
311
- } else await readerContext.acquirePdfRenderSlot(priority, shell, renderController.signal);
312
  acquired = true;
 
313
  shell._renderStarted = true;
314
  readerContext.assertReaderActive();
315
  const entry = readerContext.pdfPageEntry(Number(shell.dataset.page));
@@ -352,13 +398,20 @@ export function createReaderPdfRenderer(readerContext) {
352
  Math.abs((image.naturalWidth / image.naturalHeight) / (geometry.width / geometry.height) - 1) > .01)
353
  throw new Error("PDF_V3_PREVIEW_GEOMETRY_MISMATCH");
354
  }
355
- shell.style.aspectRatio = `${image.naturalWidth || 1} / ${image.naturalHeight || 1}`;
356
- if (priority >= 2 && readerContext.ocrManifestUrl && !navigator.connection?.saveData)
357
- loadText ||= readerContext.renderPdfOcrText(shell);
 
 
 
 
 
 
358
  loadText?.catch(() => {});
359
- image.classList.add("ready");
360
- if (pageState) pageState.hidden = true;
361
- shell.dataset.renderState = "rendered";
 
362
  shell.dataset.renderUsedAt = String(Date.now());
363
  readerContext.pdfShellWindow?.remember(shell);
364
  if (readerContext.isPdfPageVisible(shell) || !readerContext.pdfManifestPrefetchOrigin) readerContext.schedulePdfManifestPrefetch(entry.page);
@@ -376,10 +429,7 @@ export function createReaderPdfRenderer(readerContext) {
376
  shell.dataset.renderState = "idle";
377
  throw error;
378
  } finally {
379
- if (acquired) {
380
- if (hybridPreview) readerContext.pdfHybridPreviewLoads--;
381
- else readerContext.releasePdfRenderSlot();
382
- }
383
  }
384
  })().finally(() => {
385
  if (shell._renderPromise === task) delete shell._renderPromise;
@@ -489,6 +539,10 @@ export function createReaderPdfRenderer(readerContext) {
489
  renderPdfManifestShell,
490
  renderHybridPdfCanvas,
491
  renderPdfImageShell,
492
- renderPdfShell
 
 
 
 
493
  };
494
  }
 
127
  } catch (_) { return ""; }
128
  }
129
 
130
+ // Recent per-page latencies for this book: PDF.js admission-to-draw and
131
+ // page-image admission-to-decode. They choose the visible-page image grace.
132
+ const pdfLatencySamples = { pdf: [], preview: [] };
133
+
134
+ function resetPdfLatencySamples() {
135
+ pdfLatencySamples.pdf.length = pdfLatencySamples.preview.length = 0;
136
+ }
137
+
138
+ function notePdfLatency(kind, milliseconds) {
139
+ const samples = pdfLatencySamples[kind];
140
+ if (!samples || !Number.isFinite(milliseconds) || milliseconds < 0) return;
141
+ samples.push(milliseconds);
142
+ if (samples.length > 12) samples.shift();
143
+ }
144
+
145
+ function pdfHybridPreviewGrace() {
146
+ // The image stream is a fallback once PDF.js is ready. Start it only when a
147
+ // draw is slower than this book's typical draw, not on every short wait:
148
+ // a duplicate image download competes with the PDF and swaps surfaces.
149
+ const median = (values, fallback) => {
150
+ if (!values.length) return fallback;
151
+ const sorted = [...values].sort((a, b) => a - b);
152
+ return sorted[sorted.length >> 1];
153
+ };
154
+ const pdf = median(pdfLatencySamples.pdf, 800), preview = median(pdfLatencySamples.preview, 2400);
155
+ // When this book's images are measurably faster than its PDF draws (small
156
+ // v3 previews, very heavy pages), keep the short fallback grace.
157
+ if (pdfLatencySamples.pdf.length >= 2 && pdfLatencySamples.preview.length >= 2 && preview < pdf * .8) return 150;
158
+ return Math.round(Math.max(400, Math.min(3000, pdf * 2)));
159
+ }
160
+
161
+ // Applies a page-geometry change (aspect ratio, surface swap) while keeping
162
+ // the reading anchor at the same visual position.
163
+ function applyPdfShellGeometry(change) {
164
+ const anchor = readerContext.content.querySelector(`.reader-page[data-page="${readerContext.documentState.page}"]`);
165
+ const viewportTop = readerContext.viewport.getBoundingClientRect().top;
166
+ const before = anchor?.getBoundingClientRect();
167
+ const fraction = before ? Math.max(0, (viewportTop - before.top) / Math.max(1, before.height)) : 0;
168
+ change();
169
+ if (!before || !anchor.isConnected) return;
170
+ const after = anchor.getBoundingClientRect();
171
+ const delta = after.top - before.top + fraction * (after.height - before.height);
172
+ if (Math.abs(delta) >= .5) readerContext.viewport.scrollTop += delta;
173
+ }
174
+
175
  function renderPdfManifestShell(shell, force = false, priority = false) {
176
  if (!readerContext.pdfHybridUrl) return readerContext.renderPdfImageShell(shell, force, priority);
177
  if (!force && shell.querySelector("canvas.ready")) return Promise.resolve();
 
215
  // fallback: scrolling may promote it while its getPage/draw is still slow.
216
  let pdfSettled = false;
217
  pdfTask.then(() => { pdfSettled = true; }, () => { pdfSettled = true; });
218
+ const pdfOutcome = pdfTask.then(() => "pdf", () => "preview");
219
+ while (true) {
220
+ while (!readerContext.isPdfPageVisible(shell) && !pdfSettled) {
221
+ if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
222
+ await readerContext.waitForReader(100);
223
+ }
224
+ if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
225
+ // The grace counts from visibility. A page flown past during fast
226
+ // scrolling therefore starts no image download of its own.
227
+ const winner = await Promise.race([
228
+ pdfOutcome,
229
+ readerContext.waitForReader(readerContext.pdfHybridPreviewGrace()).then(() => "preview")
230
+ ]);
231
+ if (winner === "pdf") return pdfTask;
232
  if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
233
+ if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask;
234
+ if (pdfSettled || readerContext.isPdfPageVisible(shell)) break;
235
  }
 
 
 
 
 
 
 
 
236
  const preview = await readerContext.renderPdfImageShell(shell, false, priority);
237
  if (controller.signal.aborted) throw readerContext.readerAbortError();
238
  return preview;
 
264
  signal.addEventListener("abort", cancel, { once: true });
265
  try {
266
  const pdf = await readerContext.awaitReader(readerContext.pdfHybridDocumentPromise, signal);
267
+ let admittedAt = 0;
268
  do {
269
  const generation = readerContext.readerRuntime.currentGeneration("pdf");
270
  if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
 
272
  // Admit page reads too: getPage can demand ranges before canvas rendering.
273
  await readerContext.measurePdf(readerContext.acquirePdfRenderSlot(priority, shell, signal), "queue", Number(shell.dataset.page));
274
  acquired = true;
275
+ admittedAt = performance.now();
276
  shell._pdfRenderOwnsSlot = true;
277
  }
278
  if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
 
300
  canvas.width = canvas.height = 0;
301
  continue;
302
  }
303
+ const ready = canvas;
304
+ readerContext.applyPdfShellGeometry(() => {
305
+ const previous = shell.querySelector("canvas");
306
+ ready.classList.add("ready");
307
+ if (previous) { previous.replaceWith(ready); previous.width = previous.height = 0; }
308
+ else shell.prepend(ready);
309
+ shell.style.aspectRatio = `${base.width} / ${base.height}`;
310
+ shell.dataset.pageGeometry = "1";
311
+ shell._renderCancel?.();
312
+ shell.querySelector("img")?.remove();
313
+ shell._v3PreviewRelease?.();
314
+ readerContext.seedPdfPageGeometry?.(base.width, base.height);
315
+ });
316
+ if (admittedAt) readerContext.notePdfLatency("pdf", performance.now() - admittedAt);
317
  const pageState = shell.querySelector(".reader-page-state");
318
  if (pageState) pageState.hidden = true;
319
  shell.dataset.pdfPresented = "1";
 
322
  delete shell.dataset.renderRetries;
323
  delete shell.dataset.renderFailed;
324
  shell.dataset.renderUsedAt = String(Date.now());
 
 
 
 
325
  readerContext.pdfShellWindow?.remember(shell);
326
  readerContext.trimPdfCanvases(shell);
327
  (readerContext.ocrManifestUrl ? readerContext.renderPdfOcrText(shell) : readerContext.renderPdfText(page, shell, signal)).catch(() => {});
 
344
  if (!force && shell.dataset.renderState === "rendered") return Promise.resolve();
345
  shell.dataset.renderState = "rendering";
346
  const renderController = new AbortController();
 
347
  shell._renderCancel = () => renderController.abort();
348
  shell._renderStarted = false;
349
  priority = readerContext.normalizePdfPriority(priority || readerContext.pdfPagePriority(shell));
 
351
  const task = (async () => {
352
  let acquired = false;
353
  try {
354
+ // Page images are network-bound, not canvas work: they use their own
355
+ // priority-ordered pool, where visible demand preempts speculation.
356
+ await readerContext.measurePdf(readerContext.acquirePdfPreviewSlot(priority, shell, renderController.signal), "queue", Number(shell.dataset.page));
 
 
 
 
 
357
  acquired = true;
358
+ const admittedAt = performance.now();
359
  shell._renderStarted = true;
360
  readerContext.assertReaderActive();
361
  const entry = readerContext.pdfPageEntry(Number(shell.dataset.page));
 
398
  Math.abs((image.naturalWidth / image.naturalHeight) / (geometry.width / geometry.height) - 1) > .01)
399
  throw new Error("PDF_V3_PREVIEW_GEOMETRY_MISMATCH");
400
  }
401
+ readerContext.notePdfLatency("preview", performance.now() - admittedAt);
402
+ readerContext.applyPdfShellGeometry(() => {
403
+ shell.style.aspectRatio = `${image.naturalWidth || 1} / ${image.naturalHeight || 1}`;
404
+ shell.dataset.pageGeometry = "1";
405
+ image.classList.add("ready");
406
+ readerContext.seedPdfPageGeometry?.(image.naturalWidth, image.naturalHeight);
407
+ });
408
+ if (priority >= 2 && readerContext.ocrManifestUrl && !navigator.connection?.saveData)
409
+ loadText ||= readerContext.renderPdfOcrText(shell);
410
  loadText?.catch(() => {});
411
+ if (pageState) pageState.hidden = true;
412
+ shell.dataset.renderState = "rendered";
413
+ delete shell.dataset.renderRetries;
414
+ delete shell.dataset.renderFailed;
415
  shell.dataset.renderUsedAt = String(Date.now());
416
  readerContext.pdfShellWindow?.remember(shell);
417
  if (readerContext.isPdfPageVisible(shell) || !readerContext.pdfManifestPrefetchOrigin) readerContext.schedulePdfManifestPrefetch(entry.page);
 
429
  shell.dataset.renderState = "idle";
430
  throw error;
431
  } finally {
432
+ if (acquired) readerContext.releasePdfPreviewSlot(shell);
 
 
 
433
  }
434
  })().finally(() => {
435
  if (shell._renderPromise === task) delete shell._renderPromise;
 
539
  renderPdfManifestShell,
540
  renderHybridPdfCanvas,
541
  renderPdfImageShell,
542
+ renderPdfShell,
543
+ resetPdfLatencySamples,
544
+ notePdfLatency,
545
+ pdfHybridPreviewGrace,
546
+ applyPdfShellGeometry
547
  };
548
  }
static/reader/pdf/reader-pdf-scheduler.mjs CHANGED
@@ -1,5 +1,9 @@
1
  export function createReaderPdfScheduler(readerContext) {
2
  let pdfVisibleReconcileFrame = 0;
 
 
 
 
3
  let pdfMotionAt = 0, pdfFastUntil = 0;
4
 
5
  function notePdfViewportMotion() {
@@ -153,7 +157,8 @@ export function createReaderPdfScheduler(readerContext) {
153
  if (readerContext.readerAbortController.signal.aborted || document.hidden || !navigator.onLine ||
154
  readerContext.pdfManifestPrefetchActive.size >= readerContext.pdfManifestPrefetchConcurrency() ||
155
  readerContext.pdfManifestPrefetchCount >= readerContext.pdfManifestPrefetchMaxPages() ||
156
- (readerContext.pdfActiveRenders && readerContext.content.querySelector('.reader-page[data-render-visible="1"][data-render-state="rendering"]'))) return;
 
157
  while (readerContext.pdfManifestPrefetchActive.size < readerContext.pdfManifestPrefetchConcurrency()) {
158
  if (readerContext.pdfManifestPrefetchCount >= readerContext.pdfManifestPrefetchMaxPages()) return;
159
  if (readerContext.pdfManifestPrefetchWrapped && readerContext.pdfManifestPrefetchNext >= readerContext.pdfManifestPrefetchOrigin) return;
@@ -240,6 +245,93 @@ export function createReaderPdfScheduler(readerContext) {
240
  for (const waiter of readerContext.pdfRenderWaiters) {
241
  if (waiter.shell === shell) waiter.priority = Math.max(waiter.priority, readerContext.pdfPagePriority(shell));
242
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  }
244
 
245
  function acquirePdfRenderSlot(priority = false, shell = null, signal = null) {
@@ -291,19 +383,7 @@ export function createReaderPdfScheduler(readerContext) {
291
  function releasePdfRenderSlot() {
292
  readerContext.pdfActiveRenders = Math.max(0, readerContext.pdfActiveRenders - 1);
293
  if (readerContext.readerAbortController.signal.aborted) return;
294
- let index = -1, best = -1, nearest = Infinity;
295
- for (let i = 0; i < readerContext.pdfRenderWaiters.length; i++) {
296
- const waiter = readerContext.pdfRenderWaiters[i], value = waiter.shell ? readerContext.pdfPagePriority(waiter.shell) : waiter.priority;
297
- const rect = waiter.shell?.getBoundingClientRect(), bounds = readerContext.viewport.getBoundingClientRect();
298
- const gap = rect ? Math.max(bounds.top - rect.bottom, rect.top - bounds.bottom, 0) : Infinity;
299
- const behind = rect && (readerContext.pdfScrollDirection < 0 ? rect.top >= bounds.bottom : rect.bottom <= bounds.top);
300
- const distance = gap * (behind ? 2 : 1);
301
- if (value > best || (value === best && distance < nearest)) {
302
- best = value; nearest = distance; index = i;
303
- }
304
- }
305
- if (index < 0) index = 0;
306
- const waiter = readerContext.pdfRenderWaiters.splice(index, 1)[0];
307
  if (waiter) waiter.resolve();
308
  scheduleVisiblePdfRenderReconciliation();
309
  }
@@ -413,6 +493,9 @@ export function createReaderPdfScheduler(readerContext) {
413
  promotePdfRenderWaiter,
414
  acquirePdfRenderSlot,
415
  releasePdfRenderSlot,
 
 
 
416
  acquirePdfTextSlot,
417
  drainPdfTextWaiters,
418
  releasePdfTextSlot,
 
1
  export function createReaderPdfScheduler(readerContext) {
2
  let pdfVisibleReconcileFrame = 0;
3
+ // Page-image admission (PDF-pages previews and image streams). Separate from
4
+ // canvas render slots so an image download never delays a PDF.js draw.
5
+ const PDF_PREVIEW_SLOTS = 2;
6
+ const pdfPreviewActive = new Map(), pdfPreviewWaiters = [];
7
  let pdfMotionAt = 0, pdfFastUntil = 0;
8
 
9
  function notePdfViewportMotion() {
 
157
  if (readerContext.readerAbortController.signal.aborted || document.hidden || !navigator.onLine ||
158
  readerContext.pdfManifestPrefetchActive.size >= readerContext.pdfManifestPrefetchConcurrency() ||
159
  readerContext.pdfManifestPrefetchCount >= readerContext.pdfManifestPrefetchMaxPages() ||
160
+ ((readerContext.pdfActiveRenders || pdfPreviewActive.size) &&
161
+ readerContext.content.querySelector('.reader-page[data-render-visible="1"][data-render-state="rendering"]'))) return;
162
  while (readerContext.pdfManifestPrefetchActive.size < readerContext.pdfManifestPrefetchConcurrency()) {
163
  if (readerContext.pdfManifestPrefetchCount >= readerContext.pdfManifestPrefetchMaxPages()) return;
164
  if (readerContext.pdfManifestPrefetchWrapped && readerContext.pdfManifestPrefetchNext >= readerContext.pdfManifestPrefetchOrigin) return;
 
245
  for (const waiter of readerContext.pdfRenderWaiters) {
246
  if (waiter.shell === shell) waiter.priority = Math.max(waiter.priority, readerContext.pdfPagePriority(shell));
247
  }
248
+ for (const waiter of pdfPreviewWaiters) {
249
+ if (waiter.shell !== shell) continue;
250
+ waiter.priority = Math.max(waiter.priority, readerContext.pdfPagePriority(shell));
251
+ preemptPdfPreview(waiter.priority, shell);
252
+ }
253
+ }
254
+
255
+ // Index of the waiter to admit next: live priority first, then the nearest
256
+ // page, with pages behind the scroll direction counted at twice the distance.
257
+ function nextPdfWaiterIndex(waiters) {
258
+ let index = -1, best = -1, nearest = Infinity;
259
+ const bounds = readerContext.viewport.getBoundingClientRect();
260
+ for (let i = 0; i < waiters.length; i++) {
261
+ const waiter = waiters[i], value = waiter.shell ? readerContext.pdfPagePriority(waiter.shell) : waiter.priority;
262
+ const rect = waiter.shell?.getBoundingClientRect();
263
+ const gap = rect ? Math.max(bounds.top - rect.bottom, rect.top - bounds.bottom, 0) : Infinity;
264
+ const behind = rect && (readerContext.pdfScrollDirection < 0 ? rect.top >= bounds.bottom : rect.bottom <= bounds.top);
265
+ const distance = gap * (behind ? 2 : 1);
266
+ if (value > best || (value === best && distance < nearest)) {
267
+ best = value; nearest = distance; index = i;
268
+ }
269
+ }
270
+ return index < 0 ? 0 : index;
271
+ }
272
+
273
+ // Visible demand must not wait behind speculative image downloads: cancel
274
+ // the least useful offscreen holder; its page is requeued if it is needed.
275
+ function preemptPdfPreview(priority, shell) {
276
+ if (priority < 2 || pdfPreviewActive.size < PDF_PREVIEW_SLOTS) return false;
277
+ let victim = null, lowest = Infinity;
278
+ for (const [holder, entry] of pdfPreviewActive) {
279
+ if (holder === shell || readerContext.isPdfPageVisible(holder) || readerContext.pdfSelectionIntersects?.(holder)) continue;
280
+ const value = readerContext.pdfPagePriority(holder);
281
+ if (value < lowest) { lowest = value; victim = entry; }
282
+ }
283
+ if (!victim || lowest >= 2) return false;
284
+ victim.shell._pdfRenderCancelled = true;
285
+ victim.shell._pdfRenderNeedsRequeue = true;
286
+ victim.cancel();
287
+ return true;
288
+ }
289
+
290
+ function acquirePdfPreviewSlot(priority = false, shell = null, signal = null) {
291
+ if (readerContext.readerAbortController.signal.aborted || signal?.aborted)
292
+ return Promise.reject(readerContext.readerAbortError());
293
+ priority = readerContext.normalizePdfPriority(priority);
294
+ const admit = () => {
295
+ pdfPreviewActive.set(shell, { shell, cancel: () => shell?._renderCancel?.() });
296
+ };
297
+ if (pdfPreviewActive.size < PDF_PREVIEW_SLOTS && !pdfPreviewWaiters.length) {
298
+ admit();
299
+ return Promise.resolve();
300
+ }
301
+ return new Promise((resolve, reject) => {
302
+ const lifetime = signal ? AbortSignal.any([signal, readerContext.readerAbortController.signal])
303
+ : readerContext.readerAbortController.signal;
304
+ const waiter = {
305
+ shell, priority,
306
+ resolve: () => { lifetime.removeEventListener("abort", abort); admit(); resolve(); }
307
+ };
308
+ function abort() {
309
+ const index = pdfPreviewWaiters.indexOf(waiter);
310
+ if (index >= 0) pdfPreviewWaiters.splice(index, 1);
311
+ reject(readerContext.readerAbortError());
312
+ drainPdfPreviewWaiters();
313
+ }
314
+ lifetime.addEventListener("abort", abort, { once: true });
315
+ pdfPreviewWaiters.push(waiter);
316
+ if (!preemptPdfPreview(priority, shell)) drainPdfPreviewWaiters();
317
+ });
318
+ }
319
+
320
+ function drainPdfPreviewWaiters() {
321
+ // Disposal rejects each waiter through its own abort listener.
322
+ if (readerContext.readerAbortController.signal.aborted) return;
323
+ while (pdfPreviewActive.size < PDF_PREVIEW_SLOTS && pdfPreviewWaiters.length)
324
+ pdfPreviewWaiters.splice(nextPdfWaiterIndex(pdfPreviewWaiters), 1)[0].resolve();
325
+ }
326
+
327
+ function releasePdfPreviewSlot(shell) {
328
+ pdfPreviewActive.delete(shell);
329
+ drainPdfPreviewWaiters();
330
+ scheduleVisiblePdfRenderReconciliation();
331
+ }
332
+
333
+ function pdfPreviewLoads() {
334
+ return pdfPreviewActive.size;
335
  }
336
 
337
  function acquirePdfRenderSlot(priority = false, shell = null, signal = null) {
 
383
  function releasePdfRenderSlot() {
384
  readerContext.pdfActiveRenders = Math.max(0, readerContext.pdfActiveRenders - 1);
385
  if (readerContext.readerAbortController.signal.aborted) return;
386
+ const waiter = readerContext.pdfRenderWaiters.splice(nextPdfWaiterIndex(readerContext.pdfRenderWaiters), 1)[0];
 
 
 
 
 
 
 
 
 
 
 
 
387
  if (waiter) waiter.resolve();
388
  scheduleVisiblePdfRenderReconciliation();
389
  }
 
493
  promotePdfRenderWaiter,
494
  acquirePdfRenderSlot,
495
  releasePdfRenderSlot,
496
+ acquirePdfPreviewSlot,
497
+ releasePdfPreviewSlot,
498
+ pdfPreviewLoads,
499
  acquirePdfTextSlot,
500
  drainPdfTextWaiters,
501
  releasePdfTextSlot,
tests/test_async_proxy.py CHANGED
@@ -643,6 +643,46 @@ class AsyncProxyTests(unittest.IsolatedAsyncioTestCase):
643
  await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={}))
644
  self.assertEqual(semaphore._value, 1)
645
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  async def test_bucket_pdf_redirect_cannot_switch_to_another_hf_source(self):
647
  path = "derived/test/" + "a" * 32 + "/document.pdf"
648
  redirect = FakeResponse(302, {"Location": "https://huggingface.co/datasets/Other/Private/resolve/main/book.pdf"})
 
643
  await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={}))
644
  self.assertEqual(semaphore._value, 1)
645
 
646
+ async def test_large_pdf_admits_bounded_ranges_but_not_complete_bodies(self):
647
+ # PDF.js range transport reads demanded chunks of a large original PDF;
648
+ # complete or open-ended bodies keep the document bound.
649
+ large = app.READER_DOCUMENT_MAX_BYTES + 100
650
+ over = app.READER_PDF_RANGE_MAX_BYTES + 1
651
+ url = "https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/large.pdf"
652
+ for forwarded, status, headers, expected in (
653
+ ({"Range": "bytes=0-1"}, 206, {"Content-Length": "2", "Content-Range": f"bytes 0-1/{large}"}, 206),
654
+ ({"Range": f"bytes={large - 2}-{large - 1}"}, 206,
655
+ {"Content-Length": "2", "Content-Range": f"bytes {large - 2}-{large - 1}/{large}"}, 206),
656
+ ({"Range": "bytes=0-1"}, 206, {"Content-Length": "2", "Content-Range": f"bytes 0-1/{over}"}, 413),
657
+ ({"Range": "bytes=0-"}, 206, {"Content-Length": "2", "Content-Range": f"bytes 0-1/{large}"}, 413),
658
+ ({}, 200, {"Content-Length": str(large)}, 413),
659
+ ):
660
+ for source in ("bucket", "original"):
661
+ with self.subTest(range=forwarded, total=headers.get("Content-Range"), source=source):
662
+ upstream = FakeResponse(status, headers, chunks=(b"ab",))
663
+ app.app.state.http_session = FakeSession([upstream])
664
+ semaphore = app.app.state.upstream_semaphore = asyncio.Semaphore(1)
665
+ app.app.state.reader_range_cache = OrderedDict()
666
+ app.app.state.reader_range_cache_bytes = 0
667
+ request = SimpleNamespace(method="GET", headers=forwarded)
668
+ if source == "bucket":
669
+ response = await app.api_reader_bucket_resource("derived/test/" + "a" * 32 + "/document.pdf", request)
670
+ else:
671
+ response = await app.api_reader_content(request, url=url, id=None)
672
+ self.assertEqual(response.status_code, expected)
673
+ if expected == 206:
674
+ self.assertEqual(b"".join([chunk async for chunk in response.body_iterator]), b"ab")
675
+ self.assertEqual(response.headers["Cache-Control"], "no-store")
676
+ self.assertTrue(upstream.released)
677
+ self.assertEqual(semaphore._value, 1)
678
+ # Non-PDF documents retain the ordinary total bound for ranges too.
679
+ upstream = FakeResponse(206, {"Content-Length": "2", "Content-Range": f"bytes 0-1/{large}"}, chunks=(b"ab",))
680
+ app.app.state.http_session = FakeSession([upstream])
681
+ app.app.state.upstream_semaphore = asyncio.Semaphore(1)
682
+ response = await app.api_reader_content(SimpleNamespace(method="GET", headers={"Range": "bytes=0-1"}),
683
+ url="https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/large.txt", id=None)
684
+ self.assertEqual(response.status_code, 413)
685
+
686
  async def test_bucket_pdf_redirect_cannot_switch_to_another_hf_source(self):
687
  path = "derived/test/" + "a" * 32 + "/document.pdf"
688
  redirect = FakeResponse(302, {"Location": "https://huggingface.co/datasets/Other/Private/resolve/main/book.pdf"})
tests/test_reader_pdf_hybrid.py CHANGED
@@ -27,7 +27,10 @@ export function getDocument(options) {
27
  stream: options.disableStream, auto: options.disableAutoFetch};
28
  let finish;
29
  const promise = new Promise(resolve => { finish = resolve; });
30
- const pdf = { numPages: window.__wrongCount ? 79 : 80, getOutline: async () => null,
 
 
 
31
  getPage: async number => ({
32
  getViewport: ({scale}) => ({width: 600*scale, height: 800*scale}),
33
  getTextContent: async () => ({items: [{str:'hybrid searchable page '+number,
@@ -90,8 +93,10 @@ class HybridPdfTests(unittest.TestCase):
90
  def asset(self, route):
91
  path = parse_qs(urlparse(route.request.url).query)["path"][0]
92
  if path.endswith("page-manifest.json"):
93
- route.fulfill(content_type="application/json", body=json.dumps(
94
- {"version": 2, "kind": "pdf-pages", "page_count": self.page_count}))
 
 
95
  else:
96
  self.images.append(int(path.split("page-")[-1].split(".")[0]))
97
  if self.svg_preview:
@@ -251,6 +256,47 @@ class HybridPdfTests(unittest.TestCase):
251
  self.assertEqual(self.page.locator(".reader-error").count(), 0)
252
  self.assertEqual(self.errors, [])
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  def test_disposal_cancels_pending_pdf_without_late_canvas(self):
255
  shell = self.open()
256
  self.page.evaluate("window.dispatchEvent(new Event('pagehide'))")
 
27
  stream: options.disableStream, auto: options.disableAutoFetch};
28
  let finish;
29
  const promise = new Promise(resolve => { finish = resolve; });
30
+ const pdf = { numPages: window.__wrongCount ? 79 : 80,
31
+ getOutline: async () => { window.__outlineReads = (window.__outlineReads || 0) + 1; return window.__outline || null; },
32
+ getDestination: async name => [{num: Number(name.slice(1))}],
33
+ getPageIndex: async ref => ref.num - 1,
34
  getPage: async number => ({
35
  getViewport: ({scale}) => ({width: 600*scale, height: 800*scale}),
36
  getTextContent: async () => ({items: [{str:'hybrid searchable page '+number,
 
93
  def asset(self, route):
94
  path = parse_qs(urlparse(route.request.url).query)["path"][0]
95
  if path.endswith("page-manifest.json"):
96
+ manifest = {"version": 2, "kind": "pdf-pages", "page_count": self.page_count}
97
+ if getattr(self, "manifest_toc", None):
98
+ manifest["toc"] = self.manifest_toc
99
+ route.fulfill(content_type="application/json", body=json.dumps(manifest))
100
  else:
101
  self.images.append(int(path.split("page-")[-1].split(".")[0]))
102
  if self.svg_preview:
 
256
  self.assertEqual(self.page.locator(".reader-error").count(), 0)
257
  self.assertEqual(self.errors, [])
258
 
259
+ def test_failed_primary_leaves_hybrid_mode_for_prioritized_image_pages(self):
260
+ self.page.add_init_script("window.__wrongCount=true")
261
+ self.open()
262
+ self.page.evaluate("window.__releaseDocument()")
263
+ self.page.wait_for_function("() => !document.querySelector('.reader-page[data-pdf-hybrid]')")
264
+ self.images.clear()
265
+ self.jump(60)
266
+ shell = self.page.locator('.reader-page[data-page="60"]')
267
+ # The visible page is an ordinary image page: no hybrid grace/limiter.
268
+ shell.locator("img.ready").wait_for(timeout=3000)
269
+ self.assertIn(60, self.images)
270
+ # Ordinary manifest prefetch resumes once hybrid mode is left.
271
+ self.page.wait_for_function("() => document.querySelectorAll('.reader-page img').length > 1")
272
+ self.assertEqual(self.page.locator(".reader-page canvas.ready").count(), 0)
273
+ self.assertEqual(self.page.evaluate("window.__renders.length"), 0)
274
+ self.assertEqual(self.page.locator(".reader-error").count(), 0)
275
+ self.assertEqual(self.errors, [])
276
+
277
+ def test_hybrid_outline_fills_missing_manifest_toc_after_first_page(self):
278
+ self.page.add_init_script("window.__outline=[{title:'第一章', dest:'p3', items:[{title:'第一节', dest:[{num:4}], items:[]}]},{title:'第二章', dest:'p40', items:[]}]")
279
+ self.open()
280
+ self.assertEqual(self.page.evaluate("window.__outlineReads || 0"), 0)
281
+ self.page.evaluate("window.__releaseDocument()")
282
+ self.page.wait_for_function("() => document.querySelectorAll('#toc-list .toc-item').length === 3")
283
+ labels = self.page.locator("#toc-list .toc-item .panel-item-main").all_inner_texts()
284
+ self.assertEqual([label.strip() for label in labels], ["第一章 · 第 3 页", "第一节 · 第 4 页", "第二章 · 第 40 页"])
285
+ self.page.locator("#toc-list .toc-item").nth(2).locator(".panel-item-main").click()
286
+ self.page.wait_for_function("() => document.querySelector('#page-number').value === '40'")
287
+ self.assertEqual(self.errors, [])
288
+
289
+ def test_manifest_toc_is_not_replaced_by_pdf_outline(self):
290
+ self.manifest_toc = [{"title": "清单目录", "page": 2, "depth": 0}]
291
+ self.page.add_init_script("window.__outline=[{title:'PDF 书签', dest:'p3', items:[]}]")
292
+ self.open()
293
+ self.page.evaluate("window.__releaseDocument()")
294
+ self.page.locator('.reader-page[data-page="1"] canvas.ready').wait_for()
295
+ self.page.wait_for_timeout(200)
296
+ self.assertEqual(self.page.evaluate("window.__outlineReads || 0"), 0)
297
+ self.assertEqual([t.strip() for t in self.page.locator("#toc-list .toc-item .panel-item-main").all_inner_texts()], ["清单目录 · 第 2 页"])
298
+ self.assertEqual(self.errors, [])
299
+
300
  def test_disposal_cancels_pending_pdf_without_late_canvas(self):
301
  shell = self.open()
302
  self.page.evaluate("window.dispatchEvent(new Event('pagehide'))")
tests/test_reader_pdf_range_transport.mjs CHANGED
@@ -219,6 +219,24 @@ try {
219
  }
220
  await assert.rejects(open(async()=>response([1,2,3,4],[0,3,128])),/READER_RESOURCE_LIMIT/);
221
  await assert.rejects(open(async()=>new Response(new Uint8Array(65))),/READER_RESOURCE_LIMIT/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  await assert.rejects(open(async()=>new Response('missing',{status:404})),error=>error.status===404);
223
 
224
  const failures=[];
 
219
  }
220
  await assert.rejects(open(async()=>response([1,2,3,4],[0,3,128])),/READER_RESOURCE_LIMIT/);
221
  await assert.rejects(open(async()=>new Response(new Uint8Array(65))),/READER_RESOURCE_LIMIT/);
222
+ {
223
+ // Range transport may admit a larger validated total (demanded chunks
224
+ // only); complete bodies, totals beyond rangeLimit and the default stay bounded.
225
+ const ranged=async(total)=>{
226
+ calls.length=0;globalThis.fetch=async()=>response([1,2,3,4],[0,3,total]);
227
+ const task=await openPdfRangeTask(pdfjs,options,{signal:new AbortController().signal,readBytes,limit:64,rangeLimit:128,rangeCache:null});
228
+ return task.promise;
229
+ };
230
+ const large=await ranged(100);
231
+ assert.equal(large.range.length,100,'a range-read total above the body limit opens');
232
+ await assert.rejects(ranged(129),/READER_RESOURCE_LIMIT/);
233
+ await assert.rejects(open(async()=>response([1,2,3,4],[0,3,100])),/READER_RESOURCE_LIMIT/,'without rangeLimit the body limit applies');
234
+ globalThis.fetch=async()=>new Response(new Uint8Array(65));
235
+ await assert.rejects(openPdfRangeTask(pdfjs,options,{signal:new AbortController().signal,readBytes,limit:64,rangeLimit:128,rangeCache:null}),
236
+ /READER_RESOURCE_LIMIT/,'a complete body keeps the body limit');
237
+ assert.equal(globalThis.VoiceOfMLReaderSecurity.LIMITS.documentBytes,512*1024*1024);
238
+ assert.equal(globalThis.VoiceOfMLReaderSecurity.LIMITS.pdfRangeDocumentBytes,1024*1024*1024);
239
+ }
240
  await assert.rejects(open(async()=>new Response('missing',{status:404})),error=>error.status===404);
241
 
242
  const failures=[];
tests/test_reader_pdf_scroll_scheduling.mjs CHANGED
@@ -9,6 +9,7 @@ const load = name => import(pathToFileURL(path.join(root, "static/reader", name)
9
  const { createReaderPdfScheduler } = await load("pdf/reader-pdf-scheduler.mjs");
10
  const { createReaderPdfRenderer } = await load("pdf/reader-pdf-renderer.mjs");
11
  const { createReaderLifecycle } = await load("core/reader-lifecycle.mjs");
 
12
  const tick = () => new Promise(resolve => setImmediate(resolve));
13
  const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; };
14
  globalThis.window = { devicePixelRatio: 1 };
@@ -88,6 +89,7 @@ for (const outcome of ["promoted", "pdf", "cancelled"]) {
88
  reader.pdfDocument = {};
89
  reader.pdfHybridDocumentPromise = Promise.resolve(reader.pdfDocument);
90
  reader.PDF_HYBRID_DOCUMENT_GRACE_MS = 5000;
 
91
  reader.renderHybridPdfCanvas = () => pdf.promise;
92
  let previews = 0;
93
  reader.renderPdfImageShell = async () => { previews++; };
@@ -114,33 +116,125 @@ for (const outcome of ["promoted", "pdf", "cancelled"]) {
114
  assert.equal(reader.readerResources.size, 0);
115
  }
116
  {
117
- const nativePerformance=globalThis.performance;
118
- let clock=1000;
119
- Object.defineProperty(globalThis,'performance',{configurable:true,value:{now:()=>clock}});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  try {
121
- const reader=context(true);
122
- reader.capability={mode:'pdf-pages'};reader.documentState.restorationReady=true;
123
- reader.viewport.clientHeight=800;reader.pdfLastScrollTop=0;reader.viewport.scrollTop=260;
124
- reader.notePdfViewportMotion();
125
- assert.equal(reader.pdfScrollActivity(),true);
126
- assert.equal(reader.pdfScrollActivity(true),false,'ordinary movement must keep normal draw grace');
127
- clock+=35;reader.pdfLastScrollTop=260;reader.viewport.scrollTop=2060;
128
- reader.notePdfViewportMotion();
129
- assert.equal(reader.pdfScrollActivity(true),true,'a genuine fast viewport movement enables immediate visible fallback');
130
- for (const [delta,expected] of [[0,0],[161,150]]) {
131
- clock+=delta;
132
- const hold=deferred(),target=shell(2,0),waits=[];
133
- reader.pdfHybridUrl='https://fixture.test/document.pdf';reader.pdfDocument={};
134
- reader.pdfHybridDocumentPromise=Promise.resolve(reader.pdfDocument);
135
- reader.renderHybridPdfCanvas=()=>hold.promise;
136
- reader.renderPdfImageShell=async()=>{};
137
- reader.waitForReader=async ms=>{waits.push(ms);};
138
- const task=reader.renderPdfManifestShell(target,false,3);
139
- await task;
140
- assert.equal(waits.at(-1),expected,'only recent fast motion may shorten the visible-page grace');
141
- hold.resolve();await target._hybridRenderPromise;
142
  }
143
- } finally {Object.defineProperty(globalThis,'performance',{configurable:true,value:nativePerformance});}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  }
145
  {
146
  // DjVu primary: the manifest validates, its primary is never handed to PDF.js,
@@ -191,4 +285,4 @@ for (const outcome of ["promoted", "pdf", "cancelled"]) {
191
  assert.match(reader.pdfPrimarySource(), /document\.pdf$/);
192
  delete globalThis.VoiceOfMLReader;
193
  }
194
- console.log("PDF scroll scheduling: bounded reads, live demand/fallback promotion, motion grace, cancellation and directional neighbors passed", root);
 
9
  const { createReaderPdfScheduler } = await load("pdf/reader-pdf-scheduler.mjs");
10
  const { createReaderPdfRenderer } = await load("pdf/reader-pdf-renderer.mjs");
11
  const { createReaderLifecycle } = await load("core/reader-lifecycle.mjs");
12
+ const { createReaderPdfPageWindow } = await load("pdf/reader-pdf-page-window.mjs");
13
  const tick = () => new Promise(resolve => setImmediate(resolve));
14
  const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; };
15
  globalThis.window = { devicePixelRatio: 1 };
 
89
  reader.pdfDocument = {};
90
  reader.pdfHybridDocumentPromise = Promise.resolve(reader.pdfDocument);
91
  reader.PDF_HYBRID_DOCUMENT_GRACE_MS = 5000;
92
+ reader.pdfHybridPreviewGrace = () => 150;
93
  reader.renderHybridPdfCanvas = () => pdf.promise;
94
  let previews = 0;
95
  reader.renderPdfImageShell = async () => { previews++; };
 
116
  assert.equal(reader.readerResources.size, 0);
117
  }
118
  {
119
+ // After document readiness the visible-page image is a fallback for a
120
+ // genuinely slow draw: the grace follows this book's measured latencies and
121
+ // starts only once the page is visible (fast fly-past starts no image).
122
+ const reader=context(true);
123
+ assert.equal(reader.pdfHybridPreviewGrace(),1600,'unmeasured books wait twice the default PDF draw');
124
+ for (const ms of [500,700,600]) reader.notePdfLatency('pdf',ms);
125
+ assert.equal(reader.pdfHybridPreviewGrace(),1200);
126
+ for (const ms of [2400,2300]) reader.notePdfLatency('preview',ms);
127
+ assert.equal(reader.pdfHybridPreviewGrace(),1200,'slower images keep the PDF-first grace');
128
+ reader.resetPdfLatencySamples();
129
+ for (const ms of [2600,2400]) reader.notePdfLatency('pdf',ms);
130
+ for (const ms of [300,250]) reader.notePdfLatency('preview',ms);
131
+ assert.equal(reader.pdfHybridPreviewGrace(),150,'measurably faster images keep the short fallback');
132
+ reader.resetPdfLatencySamples();
133
+ for (const ms of [9000,9000,9000]) reader.notePdfLatency('pdf',ms);
134
+ assert.equal(reader.pdfHybridPreviewGrace(),3000,'the grace is bounded');
135
+ const target=shell(2,5000),hold=deferred(),waits=[];let previews=0;
136
+ reader.pdfHybridUrl='https://fixture.test/document.pdf';reader.pdfDocument={};
137
+ reader.pdfHybridDocumentPromise=Promise.resolve(reader.pdfDocument);
138
+ reader.renderHybridPdfCanvas=()=>hold.promise;
139
+ reader.renderPdfImageShell=async()=>{previews++;};
140
+ reader.waitForReader=ms=>{waits.push(ms);return new Promise(resolve=>setTimeout(resolve,1));};
141
+ reader.pdfHybridPreviewGrace=()=>777;
142
+ const task=reader.renderPdfManifestShell(target,false,1);
143
+ await new Promise(resolve=>setTimeout(resolve,30));
144
+ assert.equal(waits.includes(777),false,'the grace does not run while the page is offscreen');
145
+ target.getBoundingClientRect=()=>({top:0,bottom:200});
146
+ await task;
147
+ assert.equal(waits.includes(777),true);
148
+ assert.equal(previews,1);
149
+ hold.resolve();await target._hybridRenderPromise;
150
+ }
151
+ {
152
+ // Page images use a priority-ordered pool: visible demand is admitted first
153
+ // and preempts speculative holders instead of polling behind them.
154
+ const reader=context(false),admitted=[],cancelled=[];
155
+ const make=(page,top)=>{const s=shell(page,top);s._renderCancel=()=>{cancelled.push(page);};return s;};
156
+ const far1=make(40,6000),far2=make(41,6200),near=make(3,900),visible=make(2,100);
157
+ await reader.acquirePdfPreviewSlot(1,far1);await reader.acquirePdfPreviewSlot(1,far2);
158
+ assert.equal(reader.pdfPreviewLoads(),2);
159
+ const queuedNear=reader.acquirePdfPreviewSlot(1,near).then(()=>admitted.push(3));
160
+ const queuedVisible=reader.acquirePdfPreviewSlot(3,visible).then(()=>admitted.push(2));
161
+ await tick();
162
+ assert.deepEqual(cancelled,[40],'visible demand cancels one speculative image download');
163
+ assert.equal(far1._pdfRenderNeedsRequeue,true,'a preempted page is requeued when needed');
164
+ reader.releasePdfPreviewSlot(far1);await tick();
165
+ assert.deepEqual(admitted,[2],'the visible page is admitted before an earlier speculative waiter');
166
+ reader.releasePdfPreviewSlot(far2);await tick();
167
+ assert.deepEqual(admitted,[2,3]);
168
+ const abort=new AbortController();
169
+ const dropped=reader.acquirePdfPreviewSlot(1,make(50,9000),abort.signal);
170
+ abort.abort();await assert.rejects(dropped,{name:'AbortError'});
171
+ reader.releasePdfPreviewSlot(visible);reader.releasePdfPreviewSlot(near);
172
+ assert.equal(reader.pdfPreviewLoads(),0);
173
+ // Disposal rejects waiters.
174
+ await reader.acquirePdfPreviewSlot(1,far1);await reader.acquirePdfPreviewSlot(1,far2);
175
+ const pending=reader.acquirePdfPreviewSlot(1,make(60,9000));
176
+ reader.readerAbortController.abort();
177
+ await assert.rejects(pending,{name:'AbortError'});
178
+ }
179
+ {
180
+ // Geometry changes (image natural size, canvas swap) keep the reading anchor.
181
+ const reader=context(false);
182
+ let height=566,top=-200;
183
+ const anchor={isConnected:true,getBoundingClientRect:()=>({top,height,bottom:top+height})};
184
+ reader.documentState.page=7;
185
+ reader.content={querySelector:selector=>selector.includes('"7"')?anchor:null};
186
+ reader.viewport.scrollTop=5000;
187
+ reader.applyPdfShellGeometry(()=>{top+=12;height=569;});
188
+ assert.ok(Math.abs(reader.viewport.scrollTop-(5000+12+(200/566)*3))<1e-9,'shift above and growth of the anchor are compensated');
189
+ reader.viewport.scrollTop=5000;
190
+ reader.applyPdfShellGeometry(()=>{});
191
+ assert.equal(reader.viewport.scrollTop,5000,'no geometry change means no scroll write');
192
+ }
193
+ for (const late of [false, true]) {
194
+ // A failed primary PDF leaves hybrid mode cleanly: no PDF-preview race or
195
+ // hybrid limiter remains, shells requeue as ordinary prioritized image pages,
196
+ // manifest prefetch resumes and the failed document is never reopened.
197
+ const reader=context(false),loads=[],reconciled=[],prefetched=[],destroyed=[];
198
+ const shells=[1,2].map(n=>({...shell(n,0),dataset:{page:String(n),pdfHybrid:'1',renderState:n===1?'rendered':'idle'}}));
199
+ Object.assign(reader,createReaderPdfPageWindow(reader),{
200
+ capability:{mode:'pdf-pages'},readerLifecycle:{disposed:false},pdfV3Manifest:null,pdfV3Map:null,
201
+ content:{querySelector:()=>null,querySelectorAll:()=>shells},
202
+ pdfPrimarySource:()=> 'https://fixture.test/primary.pdf',
203
+ syncCapabilityControls(){},clearPdfManifestPrefetches(){},cancelSpeculativePdfRenders(){},
204
+ scheduleVisiblePdfRenderReconciliation:force=>reconciled.push(force),
205
+ schedulePdfManifestPrefetch:page=>prefetched.push(page),
206
+ documentState:{page:2},
207
+ loadPdfDocument:url=>{loads.push(url);return late
208
+ ? Promise.resolve({numPages:2,destroy:()=>{destroyed.push(1);return Promise.resolve();}})
209
+ : Promise.reject(new Error('READER_RESOURCE_LIMIT'));}
210
+ });
211
+ const warn=console.warn;console.warn=()=>{};
212
  try {
213
+ reader.startPdfHybridDocument(2);
214
+ assert.equal(reader.pdfHybridUrl,'https://fixture.test/primary.pdf');
215
+ await reader.pdfHybridDocumentPromise.catch(()=>{});await tick();
216
+ if (late) {
217
+ assert.ok(reader.pdfDocument,'a ready primary is used');
218
+ reader.leavePdfHybridMode(undefined,new Error('PDF_RANGE_INVALID'));
219
+ assert.deepEqual(destroyed,[1],'a late transport failure releases the document');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  }
221
+ assert.equal(reader.pdfHybridUrl,'','failure leaves hybrid mode');
222
+ assert.equal(reader.pdfDocument,null);
223
+ assert.equal(reader.pdfHybridSearchUnavailable,true);
224
+ assert.equal(shells.some(s=>s.dataset.pdfHybrid),false);
225
+ assert.equal(shells[1]._pdfRenderNeedsRequeue,true,'unrendered pages requeue through the image pool');
226
+ assert.equal(shells[0]._pdfRenderNeedsRequeue,undefined);
227
+ assert.deepEqual(reconciled,[true]);
228
+ assert.deepEqual(prefetched,[2],'ordinary manifest prefetch resumes');
229
+ reader.pdfHybridDocumentPromise=null;
230
+ reader.startPdfHybridDocument(2);
231
+ assert.deepEqual(loads,['https://fixture.test/primary.pdf'],'a failed primary is not reopened for this book');
232
+ assert.equal(reader.pdfHybridUrl,'');
233
+ // The image path no longer enters the PDF race.
234
+ let images=0;reader.renderPdfImageShell=async()=>{images++;};
235
+ await reader.renderPdfManifestShell(shells[1],false,3);
236
+ assert.equal(images,1);
237
+ } finally {console.warn=warn;}
238
  }
239
  {
240
  // DjVu primary: the manifest validates, its primary is never handed to PDF.js,
 
285
  assert.match(reader.pdfPrimarySource(), /document\.pdf$/);
286
  delete globalThis.VoiceOfMLReader;
287
  }
288
+ console.log("PDF scroll scheduling: bounded reads, live demand/fallback promotion, adaptive grace, visible-priority image admission, anchor-preserving geometry, hybrid fallback, cancellation and directional neighbors passed", root);