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 +10 -2
- static/reader.js +16 -6
- static/reader/core/reader-security.js +2 -0
- static/reader/pdf/reader-pdf-engine.mjs +10 -2
- static/reader/pdf/reader-pdf-network.mjs +10 -6
- static/reader/pdf/reader-pdf-page-window.mjs +122 -43
- static/reader/pdf/reader-pdf-renderer.mjs +100 -46
- static/reader/pdf/reader-pdf-scheduler.mjs +97 -14
- tests/test_async_proxy.py +40 -0
- tests/test_reader_pdf_hybrid.py +49 -3
- tests/test_reader_pdf_range_transport.mjs +18 -0
- tests/test_reader_pdf_scroll_scheduling.mjs +120 -26
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
|
| 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
|
| 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,
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 >
|
|
|
|
| 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
|
|
|
|
| 51 |
const documentPromise = readerContext.loadPdfDocument(readerContext.pdfHybridUrl).then((pdf) => {
|
| 52 |
readerContext.assertReaderActive();
|
| 53 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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 = (
|
| 562 |
-
if (
|
| 563 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
|
| 175 |
-
|
|
|
|
| 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
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 305 |
-
|
| 306 |
-
|
| 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 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
loadText?.catch(() => {});
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
try {
|
| 121 |
-
|
| 122 |
-
reader.
|
| 123 |
-
reader.
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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);
|