vomebook commited on
Commit
2e65b18
·
verified ·
1 Parent(s): 5a6960c

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
- if (readerContext.pdfV3Manifest) return;
 
 
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
- readerContext.pdfHybridUrl = readerContext.pdfPrimarySource();
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
- if (readerContext.pdfDocument || shell.dataset.pdfPresented === "1") await readerContext.waitForReader(250);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const preview = await readerContext.pdfV3Repository.preview(readerContext.pdfV3Manifest, readerContext.pdfV3Map, entry.page);
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
- readJson: (url, ref) => readerContext.readPdfOcrJson(url, VoiceOfMLReaderSecurity.LIMITS.chapterBytes, ref.sha256, ref.bytes),
15
- readBytes: async (url, ref) => {
16
- const response = await readerContext.fetchReaderUrl(url);
 
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
- return readerContext.pdfTextLayerRepository.textPage(manifest.text_layer, page).then(payload => {
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
- async function textIndex(ref) {
105
- active(); validateResource(ref, sourceSha);
 
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
- async textPage(ref, page) {
132
- if (!integer(page) || page > pageCount) invalid();
133
- const root = await textIndex(ref), part = partitionFor(root.partitions, page);
134
- const value = await read(part.resource);
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()