perf(reader): prioritize hybrid PDF over speculative streams
Browse files
static/reader.js
CHANGED
|
@@ -71,6 +71,7 @@ const readerContext = {
|
|
| 71 |
get PDF_MANIFEST_PREFETCH_CONCURRENCY() { return PDF_MANIFEST_PREFETCH_CONCURRENCY; },
|
| 72 |
get PDF_MANIFEST_PREFETCH_LIMIT() { return PDF_MANIFEST_PREFETCH_LIMIT; },
|
| 73 |
get PDF_MANIFEST_PREFETCH_MAX_PAGES() { return PDF_MANIFEST_PREFETCH_MAX_PAGES; },
|
|
|
|
| 74 |
get PDF_OCR_PAGE_CACHE_BYTES() { return PDF_OCR_PAGE_CACHE_BYTES; },
|
| 75 |
get PDF_PREFETCH_MARGIN() { return PDF_PREFETCH_MARGIN; },
|
| 76 |
get PDF_SEARCH_TEXT_CACHE_BYTES() { return PDF_SEARCH_TEXT_CACHE_BYTES; },
|
|
@@ -845,6 +846,9 @@ const PDF_MANIFEST_PREFETCH_LIMIT = 12;
|
|
| 845 |
const PDF_MANIFEST_PREFETCH_CONCURRENCY = 3;
|
| 846 |
// Keep the 12-page render cache; background prefetch stays a nearby window.
|
| 847 |
const PDF_MANIFEST_PREFETCH_MAX_PAGES = 48;
|
|
|
|
|
|
|
|
|
|
| 848 |
let pdfManifestPrefetchTimer = 0;
|
| 849 |
let pdfManifestPrefetchNext = 0;
|
| 850 |
let pdfManifestPrefetchOrigin = 0;
|
|
|
|
| 71 |
get PDF_MANIFEST_PREFETCH_CONCURRENCY() { return PDF_MANIFEST_PREFETCH_CONCURRENCY; },
|
| 72 |
get PDF_MANIFEST_PREFETCH_LIMIT() { return PDF_MANIFEST_PREFETCH_LIMIT; },
|
| 73 |
get PDF_MANIFEST_PREFETCH_MAX_PAGES() { return PDF_MANIFEST_PREFETCH_MAX_PAGES; },
|
| 74 |
+
get PDF_HYBRID_DOCUMENT_GRACE_MS() { return PDF_HYBRID_DOCUMENT_GRACE_MS; },
|
| 75 |
get PDF_OCR_PAGE_CACHE_BYTES() { return PDF_OCR_PAGE_CACHE_BYTES; },
|
| 76 |
get PDF_PREFETCH_MARGIN() { return PDF_PREFETCH_MARGIN; },
|
| 77 |
get PDF_SEARCH_TEXT_CACHE_BYTES() { return PDF_SEARCH_TEXT_CACHE_BYTES; },
|
|
|
|
| 846 |
const PDF_MANIFEST_PREFETCH_CONCURRENCY = 3;
|
| 847 |
// Keep the 12-page render cache; background prefetch stays a nearby window.
|
| 848 |
const PDF_MANIFEST_PREFETCH_MAX_PAGES = 48;
|
| 849 |
+
// Let a primary PDF win speculative pages before starting image-stream I/O.
|
| 850 |
+
// The visible page keeps its immediate image fallback.
|
| 851 |
+
const PDF_HYBRID_DOCUMENT_GRACE_MS = 750;
|
| 852 |
let pdfManifestPrefetchTimer = 0;
|
| 853 |
let pdfManifestPrefetchNext = 0;
|
| 854 |
let pdfManifestPrefetchOrigin = 0;
|
static/reader/pdf/reader-pdf-page-window.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
export function createReaderPdfPageWindow(readerContext) {
|
| 2 |
async function preloadPdfFirstPage(total) {
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
await readerContext.getInitialReaderRestoration();
|
| 5 |
try {
|
| 6 |
const source = readerContext.pdfManifestSource();
|
|
@@ -43,6 +45,24 @@ export function createReaderPdfPageWindow(readerContext) {
|
|
| 43 |
return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry;
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
async function appendShellsBatched(createShell, total, batchSize) {
|
| 47 |
for (let start = 2; start <= total; start += batchSize) {
|
| 48 |
readerContext.assertReaderActive();
|
|
@@ -258,6 +278,11 @@ export function createReaderPdfPageWindow(readerContext) {
|
|
| 258 |
throw new Error("PDF_V3_SOURCE_MISMATCH");
|
| 259 |
const repository = readerContext.pdfV3Repository =
|
| 260 |
readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
const pageMap = repository.read(manifest.page_map);
|
| 262 |
// The opening preview partition depends only on the validated reading
|
| 263 |
// manifest, so fetch it alongside the page map; preview() still
|
|
@@ -269,9 +294,6 @@ export function createReaderPdfPageWindow(readerContext) {
|
|
| 269 |
if (part) return repository.read(part.resource);
|
| 270 |
}).catch(() => {});
|
| 271 |
readerContext.pdfV3Map = readerContext.validatePageMap(await pageMap, manifest);
|
| 272 |
-
readerContext.pdfV3Manifest = manifest;
|
| 273 |
-
readerContext.pdfOcrManifest = manifest.text_layer ? manifest : null;
|
| 274 |
-
readerContext.ocrManifestUrl = manifest.text_layer ? readerContext.sourceUrl : "";
|
| 275 |
}
|
| 276 |
readerContext.assertReaderActive();
|
| 277 |
const source = readerContext.pdfManifestSource();
|
|
@@ -279,21 +301,7 @@ export function createReaderPdfPageWindow(readerContext) {
|
|
| 279 |
if (!source) throw new Error("PDF_MANIFEST_INVALID");
|
| 280 |
readerContext.pdfPageManifest = { ...source, pageCount: totalPages };
|
| 281 |
if (readerContext.pdfV3Manifest) readerContext.pdfPageManifest.pageUrl = () => "";
|
| 282 |
-
|
| 283 |
-
if (readerContext.pdfHybridUrl) {
|
| 284 |
-
readerContext.pdfHybridDocumentPromise = readerContext.loadPdfDocument(readerContext.pdfHybridUrl).then((pdf) => {
|
| 285 |
-
readerContext.assertReaderActive();
|
| 286 |
-
if (pdf.numPages !== totalPages) throw new Error("PDF_PRIMARY_PAGE_COUNT_MISMATCH");
|
| 287 |
-
readerContext.pdfDocument = pdf;
|
| 288 |
-
readerContext.clearPdfManifestPrefetches();
|
| 289 |
-
readerContext.syncCapabilityControls();
|
| 290 |
-
return pdf;
|
| 291 |
-
});
|
| 292 |
-
readerContext.pdfHybridDocumentPromise.catch((error) => {
|
| 293 |
-
if (error?.name !== "AbortError" && !readerContext.readerLifecycle.disposed)
|
| 294 |
-
console.warn("Primary PDF unavailable; page stream retained", error);
|
| 295 |
-
});
|
| 296 |
-
}
|
| 297 |
readerContext.updateDocumentState({ pageCount: totalPages });
|
| 298 |
readerContext.pageInput.max = String(totalPages);
|
| 299 |
document.querySelector("#page-total").textContent = `/ ${totalPages}`;
|
|
|
|
| 1 |
export function createReaderPdfPageWindow(readerContext) {
|
| 2 |
async function preloadPdfFirstPage(total) {
|
| 3 |
+
// Hybrid PDFs use the primary-document race below. A standalone preload
|
| 4 |
+
// would guarantee preview traffic before a warm PDF can win.
|
| 5 |
+
if (readerContext.pdfV3Manifest || readerContext.pdfHybridUrl) return;
|
| 6 |
await readerContext.getInitialReaderRestoration();
|
| 7 |
try {
|
| 8 |
const source = readerContext.pdfManifestSource();
|
|
|
|
| 45 |
return ocrEntry ? { ...pageEntry, ...ocrEntry, page } : pageEntry;
|
| 46 |
}
|
| 47 |
|
| 48 |
+
function startPdfHybridDocument(totalPages = readerContext.documentState.pageCount) {
|
| 49 |
+
readerContext.pdfHybridUrl = readerContext.pdfPrimarySource();
|
| 50 |
+
if (!readerContext.pdfHybridUrl || readerContext.pdfHybridDocumentPromise) return;
|
| 51 |
+
readerContext.pdfHybridDocumentPromise = readerContext.loadPdfDocument(readerContext.pdfHybridUrl).then((pdf) => {
|
| 52 |
+
readerContext.assertReaderActive();
|
| 53 |
+
if (pdf.numPages !== totalPages) throw new Error("PDF_PRIMARY_PAGE_COUNT_MISMATCH");
|
| 54 |
+
readerContext.pdfDocument = pdf;
|
| 55 |
+
readerContext.clearPdfManifestPrefetches();
|
| 56 |
+
readerContext.cancelSpeculativePdfRenders(readerContext.pageAtMarker?.());
|
| 57 |
+
readerContext.syncCapabilityControls();
|
| 58 |
+
return pdf;
|
| 59 |
+
});
|
| 60 |
+
readerContext.pdfHybridDocumentPromise.catch((error) => {
|
| 61 |
+
if (error?.name !== "AbortError" && !readerContext.readerLifecycle.disposed)
|
| 62 |
+
console.warn("Primary PDF unavailable; page stream retained", error);
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
async function appendShellsBatched(createShell, total, batchSize) {
|
| 67 |
for (let start = 2; start <= total; start += batchSize) {
|
| 68 |
readerContext.assertReaderActive();
|
|
|
|
| 278 |
throw new Error("PDF_V3_SOURCE_MISMATCH");
|
| 279 |
const repository = readerContext.pdfV3Repository =
|
| 280 |
readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count);
|
| 281 |
+
readerContext.pdfV3Manifest = manifest;
|
| 282 |
+
readerContext.pdfOcrManifest = manifest.text_layer ? manifest : null;
|
| 283 |
+
readerContext.ocrManifestUrl = manifest.text_layer ? readerContext.sourceUrl : "";
|
| 284 |
+
// Start the primary PDF while the independent page map is being read.
|
| 285 |
+
startPdfHybridDocument(manifest.page_count);
|
| 286 |
const pageMap = repository.read(manifest.page_map);
|
| 287 |
// The opening preview partition depends only on the validated reading
|
| 288 |
// manifest, so fetch it alongside the page map; preview() still
|
|
|
|
| 294 |
if (part) return repository.read(part.resource);
|
| 295 |
}).catch(() => {});
|
| 296 |
readerContext.pdfV3Map = readerContext.validatePageMap(await pageMap, manifest);
|
|
|
|
|
|
|
|
|
|
| 297 |
}
|
| 298 |
readerContext.assertReaderActive();
|
| 299 |
const source = readerContext.pdfManifestSource();
|
|
|
|
| 301 |
if (!source) throw new Error("PDF_MANIFEST_INVALID");
|
| 302 |
readerContext.pdfPageManifest = { ...source, pageCount: totalPages };
|
| 303 |
if (readerContext.pdfV3Manifest) readerContext.pdfPageManifest.pageUrl = () => "";
|
| 304 |
+
startPdfHybridDocument(totalPages);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
readerContext.updateDocumentState({ pageCount: totalPages });
|
| 306 |
readerContext.pageInput.max = String(totalPages);
|
| 307 |
document.querySelector("#page-total").textContent = `/ ${totalPages}`;
|
static/reader/pdf/reader-pdf-renderer.mjs
CHANGED
|
@@ -136,7 +136,22 @@ export function createReaderPdfRenderer(readerContext) {
|
|
| 136 |
shell._hybridCancel = () => { controller.abort(); shell._renderCancel?.(); };
|
| 137 |
const pdfTask = readerContext.renderHybridPdfCanvas(shell, controller.signal, priority);
|
| 138 |
const previewTask = (async () => {
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
|
| 141 |
if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask;
|
| 142 |
if (!readerContext.normalizePdfPriority(priority) && readerContext.pdfDocument) return pdfTask;
|
|
@@ -274,7 +289,7 @@ export function createReaderPdfRenderer(readerContext) {
|
|
| 274 |
if (!entry) throw new Error("PDF page entry missing");
|
| 275 |
let target = readerContext.pdfPageManifest.pageUrl(entry.page);
|
| 276 |
if (readerContext.pdfV3Manifest) {
|
| 277 |
-
|
| 278 |
shell._v3PreviewEntry = preview.entry;
|
| 279 |
readerContext.assertReaderActive();
|
| 280 |
if (renderController.signal.aborted) throw readerContext.readerAbortError();
|
|
|
|
| 136 |
shell._hybridCancel = () => { controller.abort(); shell._renderCancel?.(); };
|
| 137 |
const pdfTask = readerContext.renderHybridPdfCanvas(shell, controller.signal, priority);
|
| 138 |
const previewTask = (async () => {
|
| 139 |
+
// Keep the visible page's fallback immediate; only speculative pages
|
| 140 |
+
// should wait for the primary-PDF grace period.
|
| 141 |
+
if (readerContext.isPdfPageVisible(shell) && !readerContext.pdfDocument)
|
| 142 |
+
return readerContext.renderPdfImageShell(shell, false, priority);
|
| 143 |
+
// Give a warm primary PDF a short chance to win before starting preview
|
| 144 |
+
// I/O. If drawing is slower, the image stream remains the fallback.
|
| 145 |
+
const documentReady = await Promise.race([
|
| 146 |
+
readerContext.pdfHybridDocumentPromise?.then(() => true, () => false) || Promise.resolve(false),
|
| 147 |
+
readerContext.waitForReader(readerContext.PDF_HYBRID_DOCUMENT_GRACE_MS).then(() => false)
|
| 148 |
+
]);
|
| 149 |
+
if (!documentReady) return readerContext.renderPdfImageShell(shell, false, priority);
|
| 150 |
+
const winner = await Promise.race([
|
| 151 |
+
pdfTask.then(() => "pdf", () => "preview"),
|
| 152 |
+
readerContext.waitForReader(150).then(() => "preview")
|
| 153 |
+
]);
|
| 154 |
+
if (winner === "pdf") return pdfTask;
|
| 155 |
if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
|
| 156 |
if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask;
|
| 157 |
if (!readerContext.normalizePdfPriority(priority) && readerContext.pdfDocument) return pdfTask;
|
|
|
|
| 289 |
if (!entry) throw new Error("PDF page entry missing");
|
| 290 |
let target = readerContext.pdfPageManifest.pageUrl(entry.page);
|
| 291 |
if (readerContext.pdfV3Manifest) {
|
| 292 |
+
const preview = await readerContext.pdfV3Repository.preview(readerContext.pdfV3Manifest, readerContext.pdfV3Map, entry.page, renderController.signal);
|
| 293 |
shell._v3PreviewEntry = preview.entry;
|
| 294 |
readerContext.assertReaderActive();
|
| 295 |
if (renderController.signal.aborted) throw readerContext.readerAbortError();
|
static/reader/pdf/reader-pdf-text-runtime.mjs
CHANGED
|
@@ -11,9 +11,10 @@ export function createReaderPdfTextRuntime(readerContext) {
|
|
| 11 |
function createPdfV3Repository(sourceSha, pageCount) {
|
| 12 |
return readerContext.createV3Repository({ sourceSha, pageCount, signal: readerContext.readerAbortController.signal,
|
| 13 |
resourceUrl: readerContext.v3ResourceUrl,
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 17 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 18 |
const packed = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.chapterBytes);
|
| 19 |
const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", packed))]
|
|
@@ -24,8 +25,8 @@ export function createReaderPdfTextRuntime(readerContext) {
|
|
| 24 |
});
|
| 25 |
}
|
| 26 |
|
| 27 |
-
async function readPdfOcrJson(url, limit = VoiceOfMLReaderSecurity.LIMITS.chapterBytes, digest = "", size = 0, priority = "auto") {
|
| 28 |
-
const response = await readerContext.fetchReaderUrl(url, { priority });
|
| 29 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 30 |
let bytes = await VoiceOfMLReaderSecurity.readBytes(response, limit);
|
| 31 |
if (size && bytes.byteLength !== size) throw new Error("PDF_OCR_SIZE_MISMATCH");
|
|
@@ -70,7 +71,7 @@ export function createReaderPdfTextRuntime(readerContext) {
|
|
| 70 |
return readerContext.loadPdfOcrManifest().then((manifest) => {
|
| 71 |
if (manifest?.text_layer) {
|
| 72 |
readerContext.pdfTextLayerRepository ||= readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count);
|
| 73 |
-
|
| 74 |
if (readerContext.pdfV3Map) {
|
| 75 |
const geometry = readerContext.pdfV3Map.pages[page - 1];
|
| 76 |
if (Math.abs((payload.geometry.width / payload.geometry.height) / (geometry.width / geometry.height) - 1) > .01)
|
|
|
|
| 11 |
function createPdfV3Repository(sourceSha, pageCount) {
|
| 12 |
return readerContext.createV3Repository({ sourceSha, pageCount, signal: readerContext.readerAbortController.signal,
|
| 13 |
resourceUrl: readerContext.v3ResourceUrl,
|
| 14 |
+
readJson: (url, ref, signal) => readerContext.readPdfOcrJson(
|
| 15 |
+
url, VoiceOfMLReaderSecurity.LIMITS.chapterBytes, ref.sha256, ref.bytes, "auto", signal),
|
| 16 |
+
readBytes: async (url, ref, signal) => {
|
| 17 |
+
const response = await readerContext.fetchReaderUrl(url, signal ? { signal } : {});
|
| 18 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 19 |
const packed = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.chapterBytes);
|
| 20 |
const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", packed))]
|
|
|
|
| 25 |
});
|
| 26 |
}
|
| 27 |
|
| 28 |
+
async function readPdfOcrJson(url, limit = VoiceOfMLReaderSecurity.LIMITS.chapterBytes, digest = "", size = 0, priority = "auto", signal = null) {
|
| 29 |
+
const response = await readerContext.fetchReaderUrl(url, { priority, ...(signal ? { signal } : {}) });
|
| 30 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 31 |
let bytes = await VoiceOfMLReaderSecurity.readBytes(response, limit);
|
| 32 |
if (size && bytes.byteLength !== size) throw new Error("PDF_OCR_SIZE_MISMATCH");
|
|
|
|
| 71 |
return readerContext.loadPdfOcrManifest().then((manifest) => {
|
| 72 |
if (manifest?.text_layer) {
|
| 73 |
readerContext.pdfTextLayerRepository ||= readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count);
|
| 74 |
+
return readerContext.pdfTextLayerRepository.textPage(manifest.text_layer, page, readerContext.readerAbortController.signal).then(payload => {
|
| 75 |
if (readerContext.pdfV3Map) {
|
| 76 |
const geometry = readerContext.pdfV3Map.pages[page - 1];
|
| 77 |
if (Math.abs((payload.geometry.width / payload.geometry.height) / (geometry.width / geometry.height) - 1) > .01)
|
static/reader/pdf/reader-v3.mjs
CHANGED
|
@@ -78,15 +78,16 @@ export function createV3Repository({ readJson, readBytes, resourceUrl, signal, p
|
|
| 78 |
const cache = new Map(), pending = new Map();
|
| 79 |
let bytes = 0, disposed = false, index = null, textPending = null, indexKey = "";
|
| 80 |
const active = () => { if (disposed || signal?.aborted) throw new DOMException("Reader closed", "AbortError"); };
|
| 81 |
-
async function read(ref, source = sourceSha) {
|
| 82 |
active();
|
|
|
|
| 83 |
validateResource(ref, source);
|
| 84 |
const key = ref.bucket + ":" + ref.path + ":" + ref.sha256;
|
| 85 |
const cached = cache.get(key);
|
| 86 |
if (cached) { cache.delete(key); cache.set(key, cached); return cached.value; }
|
| 87 |
if (pending.has(key)) return pending.get(key);
|
| 88 |
if (pending.size >= 8) throw new Error("PDF_V3_BUSY");
|
| 89 |
-
const task = Promise.resolve().then(() => readJson(resourceUrl(ref), ref)).then(value => {
|
| 90 |
active();
|
| 91 |
const size = JSON.stringify(value).length * 2;
|
| 92 |
if (size <= 2 * 1024 * 1024) {
|
|
@@ -101,13 +102,14 @@ export function createV3Repository({ readJson, readBytes, resourceUrl, signal, p
|
|
| 101 |
pending.set(key, task);
|
| 102 |
return task;
|
| 103 |
}
|
| 104 |
-
|
| 105 |
-
|
|
|
|
| 106 |
const key = ref.bucket + ":" + ref.path + ":" + ref.sha256;
|
| 107 |
if (indexKey && indexKey !== key) throw new Error("PDF_V3_GENERATION_CHANGED");
|
| 108 |
indexKey = key;
|
| 109 |
if (index) return index;
|
| 110 |
-
if (!textPending) textPending = read(ref).then(value => {
|
| 111 |
if (value.version !== 1 || value.kind !== "pdf-text-layer-index" || value.complete !== true ||
|
| 112 |
value.source_sha256 !== sourceSha || value.page_count !== pageCount || !sha(value.generation) ||
|
| 113 |
!["raw", "effective"].includes(value.revision) || value.offset_unit !== "unicode-codepoint") invalid();
|
|
@@ -128,10 +130,10 @@ export function createV3Repository({ readJson, readBytes, resourceUrl, signal, p
|
|
| 128 |
return {
|
| 129 |
read,
|
| 130 |
textIndex,
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
if (value.version !== 1 || value.kind !== "pdf-text-partition" || value.source_sha256 !== sourceSha ||
|
| 136 |
value.generation !== root.generation || value.start !== part.start || value.end !== part.end ||
|
| 137 |
!Array.isArray(value.pages) || value.pages.length !== part.end - part.start + 1 ||
|
|
@@ -141,11 +143,12 @@ export function createV3Repository({ readJson, readBytes, resourceUrl, signal, p
|
|
| 141 |
if (!entry.resource.path.endsWith(`/text/page-${String(page).padStart(6, "0")}.json.gz`)) invalid();
|
| 142 |
return validateTextLayer(await read(entry.resource), entry, sourceSha);
|
| 143 |
},
|
| 144 |
-
async preview(manifest, map, page) {
|
| 145 |
active();
|
|
|
|
| 146 |
const part = partitionFor(manifest.preview.partitions, page);
|
| 147 |
if (!part) throw new Error("PDF_V3_PREVIEW_PENDING");
|
| 148 |
-
const data = await read(part.resource);
|
| 149 |
if (data.version !== 3 || data.kind !== "pdf-preview-partition" || data.source_sha256 !== sourceSha ||
|
| 150 |
data.page_map_sha256 !== manifest.page_map.sha256 || data.start !== part.start || data.end !== part.end ||
|
| 151 |
!Array.isArray(data.pages) || data.pages.length > part.end - part.start + 1) invalid();
|
|
@@ -164,8 +167,9 @@ export function createV3Repository({ readJson, readBytes, resourceUrl, signal, p
|
|
| 164 |
if (part.complete && data.pages.length !== part.end - part.start + 1) invalid();
|
| 165 |
const entry = data.pages.find(p => p.page === page);
|
| 166 |
if (!entry) throw new Error("PDF_V3_PREVIEW_PENDING");
|
| 167 |
-
const packed = await readBytes(resourceUrl(entry.resource), entry.resource);
|
| 168 |
active();
|
|
|
|
| 169 |
return { entry, blob: new Blob([packed], { type: entry.codec === "svg" ? "image/svg+xml" : `image/${entry.codec}` }) };
|
| 170 |
},
|
| 171 |
dispose() { disposed = true; cache.clear(); bytes = 0; index = null; }
|
|
|
|
| 78 |
const cache = new Map(), pending = new Map();
|
| 79 |
let bytes = 0, disposed = false, index = null, textPending = null, indexKey = "";
|
| 80 |
const active = () => { if (disposed || signal?.aborted) throw new DOMException("Reader closed", "AbortError"); };
|
| 81 |
+
async function read(ref, source = sourceSha, signal = null) {
|
| 82 |
active();
|
| 83 |
+
signal?.throwIfAborted?.();
|
| 84 |
validateResource(ref, source);
|
| 85 |
const key = ref.bucket + ":" + ref.path + ":" + ref.sha256;
|
| 86 |
const cached = cache.get(key);
|
| 87 |
if (cached) { cache.delete(key); cache.set(key, cached); return cached.value; }
|
| 88 |
if (pending.has(key)) return pending.get(key);
|
| 89 |
if (pending.size >= 8) throw new Error("PDF_V3_BUSY");
|
| 90 |
+
const task = Promise.resolve().then(() => readJson(resourceUrl(ref), ref, signal)).then(value => {
|
| 91 |
active();
|
| 92 |
const size = JSON.stringify(value).length * 2;
|
| 93 |
if (size <= 2 * 1024 * 1024) {
|
|
|
|
| 102 |
pending.set(key, task);
|
| 103 |
return task;
|
| 104 |
}
|
| 105 |
+
async function textIndex(ref, signal = null) {
|
| 106 |
+
active(); validateResource(ref, sourceSha);
|
| 107 |
+
signal?.throwIfAborted?.();
|
| 108 |
const key = ref.bucket + ":" + ref.path + ":" + ref.sha256;
|
| 109 |
if (indexKey && indexKey !== key) throw new Error("PDF_V3_GENERATION_CHANGED");
|
| 110 |
indexKey = key;
|
| 111 |
if (index) return index;
|
| 112 |
+
if (!textPending) textPending = read(ref, sourceSha, signal).then(value => {
|
| 113 |
if (value.version !== 1 || value.kind !== "pdf-text-layer-index" || value.complete !== true ||
|
| 114 |
value.source_sha256 !== sourceSha || value.page_count !== pageCount || !sha(value.generation) ||
|
| 115 |
!["raw", "effective"].includes(value.revision) || value.offset_unit !== "unicode-codepoint") invalid();
|
|
|
|
| 130 |
return {
|
| 131 |
read,
|
| 132 |
textIndex,
|
| 133 |
+
async textPage(ref, page, signal = null) {
|
| 134 |
+
if (!integer(page) || page > pageCount) invalid();
|
| 135 |
+
const root = await textIndex(ref, signal), part = partitionFor(root.partitions, page);
|
| 136 |
+
const value = await read(part.resource, sourceSha, signal);
|
| 137 |
if (value.version !== 1 || value.kind !== "pdf-text-partition" || value.source_sha256 !== sourceSha ||
|
| 138 |
value.generation !== root.generation || value.start !== part.start || value.end !== part.end ||
|
| 139 |
!Array.isArray(value.pages) || value.pages.length !== part.end - part.start + 1 ||
|
|
|
|
| 143 |
if (!entry.resource.path.endsWith(`/text/page-${String(page).padStart(6, "0")}.json.gz`)) invalid();
|
| 144 |
return validateTextLayer(await read(entry.resource), entry, sourceSha);
|
| 145 |
},
|
| 146 |
+
async preview(manifest, map, page, signal = null) {
|
| 147 |
active();
|
| 148 |
+
signal?.throwIfAborted?.();
|
| 149 |
const part = partitionFor(manifest.preview.partitions, page);
|
| 150 |
if (!part) throw new Error("PDF_V3_PREVIEW_PENDING");
|
| 151 |
+
const data = await read(part.resource, sourceSha, signal);
|
| 152 |
if (data.version !== 3 || data.kind !== "pdf-preview-partition" || data.source_sha256 !== sourceSha ||
|
| 153 |
data.page_map_sha256 !== manifest.page_map.sha256 || data.start !== part.start || data.end !== part.end ||
|
| 154 |
!Array.isArray(data.pages) || data.pages.length > part.end - part.start + 1) invalid();
|
|
|
|
| 167 |
if (part.complete && data.pages.length !== part.end - part.start + 1) invalid();
|
| 168 |
const entry = data.pages.find(p => p.page === page);
|
| 169 |
if (!entry) throw new Error("PDF_V3_PREVIEW_PENDING");
|
| 170 |
+
const packed = await readBytes(resourceUrl(entry.resource), entry.resource, signal);
|
| 171 |
active();
|
| 172 |
+
signal?.throwIfAborted?.();
|
| 173 |
return { entry, blob: new Blob([packed], { type: entry.codec === "svg" ? "image/svg+xml" : `image/${entry.codec}` }) };
|
| 174 |
},
|
| 175 |
dispose() { disposed = true; cache.clear(); bytes = 0; index = null; }
|
tests/test_reader_pdf_hybrid.py
CHANGED
|
@@ -173,6 +173,22 @@ class HybridPdfTests(unittest.TestCase):
|
|
| 173 |
self.assertNotIn(60, self.images)
|
| 174 |
self.assertEqual(self.errors, [])
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
def test_late_page_stream_backfills_slow_pdf_then_handoffs_on_mobile(self):
|
| 177 |
self.page.set_viewport_size({"width": 390, "height": 844})
|
| 178 |
self.open()
|
|
|
|
| 173 |
self.assertNotIn(60, self.images)
|
| 174 |
self.assertEqual(self.errors, [])
|
| 175 |
|
| 176 |
+
def test_fast_pdf_handoff_does_not_start_preview_stream(self):
|
| 177 |
+
self.page.add_init_script("""(() => {
|
| 178 |
+
const timer = setInterval(() => {
|
| 179 |
+
if (typeof window.__releaseDocument !== 'function') return;
|
| 180 |
+
clearInterval(timer);
|
| 181 |
+
setTimeout(() => window.__releaseDocument(), 50);
|
| 182 |
+
}, 0);
|
| 183 |
+
})();""")
|
| 184 |
+
source = ("https://voiceofml-search.hf.space" if PAGES else self.origin) + "/api/reader-bucket-resource?" + urlencode({"path": PATH})
|
| 185 |
+
self.page.goto(self.origin + BASE_PATH + "/static/reader.html?" + urlencode(
|
| 186 |
+
{"url": source, "ext": "pdf-pages", "pdf_document": PDF}), wait_until="domcontentloaded")
|
| 187 |
+
shell = self.page.locator('.reader-page[data-page="1"]')
|
| 188 |
+
shell.locator("canvas.ready").wait_for()
|
| 189 |
+
self.assertEqual(self.images, [1])
|
| 190 |
+
self.assertEqual(self.errors, [])
|
| 191 |
+
|
| 192 |
def test_late_page_stream_backfills_slow_pdf_then_handoffs_on_mobile(self):
|
| 193 |
self.page.set_viewport_size({"width": 390, "height": 844})
|
| 194 |
self.open()
|