Search / static /reader /pdf /reader-pdf-text-runtime.mjs
vomebook's picture
perf(reader): gate speculative PDF image streams
46b1742 verified
Raw History Blame Contribute Delete
15.2 kB
export function createReaderPdfTextRuntime(readerContext) {
function pdfOcrSource() {
return VoiceOfMLReader.pdfPageSource(readerContext.ocrManifestUrl, location.href, location.origin, "ocr-manifest.json");
}
function v3ResourceUrl(ref) {
readerContext.validateResource(ref);
return new URL(readerContext.readerDownloadApiUrl(`/api/reader-bucket-resource?path=${encodeURIComponent(ref.path)}`), location.href).href;
}
function createPdfV3Repository(sourceSha, pageCount) {
return readerContext.createV3Repository({ sourceSha, pageCount, signal: readerContext.readerAbortController.signal,
resourceUrl: readerContext.v3ResourceUrl,
readJson: (url, ref) => readerContext.readPdfOcrJson(url,
VoiceOfMLReaderSecurity.LIMITS.chapterBytes, ref.sha256, ref.bytes),
readBytes: async (url, ref, signal) => {
const response = await readerContext.fetchReaderUrl(url, signal ? { signal } : {});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const packed = await VoiceOfMLReaderSecurity.readBytes(response, VoiceOfMLReaderSecurity.LIMITS.chapterBytes);
const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", packed))]
.map(value => value.toString(16).padStart(2, "0")).join("");
if (packed.byteLength !== ref.bytes || actual !== ref.sha256) throw new Error("PDF_V3_HASH_MISMATCH");
return packed;
}
});
}
async function readPdfOcrJson(url, limit = VoiceOfMLReaderSecurity.LIMITS.chapterBytes, digest = "", size = 0, priority = "auto") {
const response = await readerContext.fetchReaderUrl(url, { priority });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let bytes = await VoiceOfMLReaderSecurity.readBytes(response, limit);
if (size && bytes.byteLength !== size) throw new Error("PDF_OCR_SIZE_MISMATCH");
if (digest) {
const actual = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((value) => value.toString(16).padStart(2, "0")).join("");
if (actual !== digest) throw new Error("PDF_OCR_HASH_MISMATCH");
}
const parsed = new URL(url, location.href);
if (/\.gz$/i.test(parsed.searchParams.get("path") || parsed.pathname)) {
if (typeof DecompressionStream !== "function") throw new Error("PDF_OCR_GZIP_UNSUPPORTED");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
bytes = await VoiceOfMLReaderSecurity.readBytes(new Response(stream), limit);
}
return JSON.parse(new TextDecoder().decode(bytes));
}
async function loadPdfOcrManifest() {
if (readerContext.pdfOcrManifest) return readerContext.pdfOcrManifest;
if (!readerContext.ocrManifestUrl) return null;
if (!readerContext.pdfOcrManifestPromise) {
if (!readerContext.pdfOcrSource()) throw new Error("PDF_OCR_SOURCE_INVALID");
readerContext.pdfOcrManifestPromise = readerContext.readPdfOcrJson(readerContext.ocrManifestUrl, VoiceOfMLReaderSecurity.LIMITS.manifestBytes, "", 0, "high").then((manifest) => {
readerContext.assertReaderActive();
if (manifest.page_count !== readerContext.documentState.pageCount) throw new Error("PDF_OCR_PAGE_COUNT_MISMATCH");
readerContext.pdfOcrManifest = VoiceOfMLReaderSecurity.validatePdfOcrManifest(manifest);
return readerContext.pdfOcrManifest;
});
}
try { return await readerContext.pdfOcrManifestPromise; }
catch (error) { readerContext.pdfOcrManifestPromise = null; throw error; }
}
function loadPdfOcrPage(page, priority = "high") {
const cached = readerContext.cachedPdfOcrPage(page);
if (cached) return Promise.resolve(cached);
const pending = readerContext.pdfOcrPagePromises.get(page);
if (pending) return pending;
const task = Promise.resolve().then(() => {
// OCR is published as its own manifest. The image page manifest may be
// intentionally lean and contain only page_count.
return readerContext.loadPdfOcrManifest().then((manifest) => {
if (manifest?.text_layer) {
readerContext.pdfTextLayerRepository ||= readerContext.createPdfV3Repository(manifest.source_sha256, manifest.page_count);
return readerContext.pdfTextLayerRepository.textPage(manifest.text_layer, page).then(payload => {
if (readerContext.pdfV3Map) {
const geometry = readerContext.pdfV3Map.pages[page - 1];
if (Math.abs((payload.geometry.width / payload.geometry.height) / (geometry.width / geometry.height) - 1) > .01)
throw new Error("PDF_V3_TEXT_GEOMETRY_MISMATCH");
}
readerContext.assertReaderActive(); readerContext.cachePdfOcrPage(page, payload); return payload;
});
}
const entry = manifest?.pages?.[page - 1];
if (!entry || entry.p !== page) throw new Error("PDF_OCR_PAGE_MISSING");
return readerContext.readPdfOcrJson(readerContext.pdfOcrSource().assetUrl(entry.o),
VoiceOfMLReaderSecurity.LIMITS.chapterBytes, entry.os, entry.ob, priority).then((payload) => {
VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page);
readerContext.cachePdfOcrPage(page, payload);
return payload;
});
});
}).finally(() => readerContext.pdfOcrPagePromises.delete(page));
readerContext.pdfOcrPagePromises.set(page, task);
return task;
}
function estimatePdfTextContentBytes(content) {
let bytes = 0;
for (const item of content?.items || [])
bytes += String(item.str || "").length * 2 + (item.transform?.length || 0) * 8 + 64;
for (const style of Object.values(content?.styles || {}))
bytes += Object.entries(style || {}).reduce(
(total, [key, value]) => total + key.length + String(value).length * 2 + 16, 0
);
return Math.max(128, bytes);
}
function cachePdfTextContent(page, content) {
const bytes = readerContext.estimatePdfTextContentBytes(content);
if (bytes > readerContext.PDF_TEXT_CONTENT_CACHE_BYTES) return;
const previous = readerContext.pdfTextContentCache.get(page);
if (previous) readerContext.pdfTextContentCacheBytes -= previous.bytes;
readerContext.pdfTextContentCache.delete(page);
readerContext.pdfTextContentCache.set(page, { content, bytes });
readerContext.pdfTextContentCacheBytes += bytes;
while (readerContext.pdfTextContentCacheBytes > readerContext.PDF_TEXT_CONTENT_CACHE_BYTES) {
const oldest = readerContext.pdfTextContentCache.keys().next().value;
const removed = readerContext.pdfTextContentCache.get(oldest);
readerContext.pdfTextContentCacheBytes -= removed.bytes;
readerContext.pdfTextContentCache.delete(oldest);
}
}
function cachedPdfTextContent(page) {
const record = readerContext.pdfTextContentCache.get(page);
if (!record) return null;
readerContext.pdfTextContentCache.delete(page);
readerContext.pdfTextContentCache.set(page, record);
return record.content;
}
function loadPdfTextContent(page, pdfPage, generation = null) {
readerContext.assertReaderActive();
const cached = readerContext.cachedPdfTextContent(page);
if (cached) return Promise.resolve(cached);
const pending = readerContext.pdfTextContentPromises.get(page);
if (pending) {
if (generation === null) pending.request.demanded = true;
else pending.request.generation = generation;
readerContext.drainPdfTextWaiters();
return pending;
}
// Rendering and search share extraction, but retain their own cancellation
// and result ownership. Only active work lives here, not another text cache.
const request = { demanded: generation === null, generation, page };
const task = Promise.resolve().then(async () => {
const background = await readerContext.acquirePdfTextSlot(request.demanded, request);
try {
readerContext.assertReaderActive();
if (!request.demanded && !readerContext.isReaderGenerationCurrent("search", request.generation)) throw readerContext.readerAbortError();
const text = await readerContext.awaitReader(pdfPage.getTextContent());
readerContext.cachePdfTextContent(page, text);
return text;
} finally {
readerContext.releasePdfTextSlot(background);
}
})
.finally(() => {
if (readerContext.pdfTextContentPromises.get(page) === task) readerContext.pdfTextContentPromises.delete(page);
});
task.request = request;
readerContext.pdfTextContentPromises.set(page, task);
return task;
}
function estimatePdfOcrPageBytes(payload) {
if (payload?.kind === "pdf-text-layer") return Math.max(128, JSON.stringify(payload).length * 2);
return Math.max(128, (payload?.blocks || []).reduce(
(total, block) => total + String(block?.t || "").length * 2 + 48, 0
));
}
function cachePdfOcrPage(page, payload) {
const bytes = readerContext.estimatePdfOcrPageBytes(payload);
if (bytes > readerContext.PDF_OCR_PAGE_CACHE_BYTES) return;
const previous = readerContext.pdfOcrPageCache.get(page);
if (previous) readerContext.pdfOcrPageCacheBytes -= previous.bytes;
readerContext.pdfOcrPageCache.delete(page);
readerContext.pdfOcrPageCache.set(page, { payload, bytes });
readerContext.pdfOcrPageCacheBytes += bytes;
while (readerContext.pdfOcrPageCacheBytes > readerContext.PDF_OCR_PAGE_CACHE_BYTES) {
const oldest = readerContext.pdfOcrPageCache.keys().next().value;
const removed = readerContext.pdfOcrPageCache.get(oldest);
readerContext.pdfOcrPageCacheBytes -= removed.bytes;
readerContext.pdfOcrPageCache.delete(oldest);
}
}
function cachedPdfOcrPage(page) {
const record = readerContext.pdfOcrPageCache.get(page);
if (!record) return null;
readerContext.pdfOcrPageCache.delete(page);
readerContext.pdfOcrPageCache.set(page, record);
return record.payload;
}
async function renderPdfOcrText(shell) {
if (shell.dataset.textReady === "1" || !readerContext.ocrManifestUrl) return;
if (shell._ocrPromise) return shell._ocrPromise;
const layer = shell.querySelector(".reader-pdf-text");
if (!layer) return;
const epoch = shell._textEpoch || 0;
const task = (async () => {
try {
const page = Number(shell.dataset.page);
await readerContext.acquirePdfTextSlot(readerContext.isPdfPageVisible(shell));
try {
if (epoch !== (shell._textEpoch || 0)) return;
const payload = await readerContext.loadPdfOcrPage(page, readerContext.isPdfPageVisible(shell) ? "high" : "low");
readerContext.assertReaderActive();
if (epoch !== (shell._textEpoch || 0)) return;
if (payload.kind === "pdf-text-layer") {
readerContext.populateIndependentTextLayer(layer, payload);
shell._bookmarkTextItems = payload.regions.map(region => ({ text: region.text, y: region.box[1] }));
} else {
VoiceOfMLReaderSecurity.validatePdfOcrPage(payload, page);
readerContext.populateOcrTextLayer(layer, payload.blocks, payload.layout);
shell._bookmarkTextItems = payload.blocks.map((block) => ({ text: block.t, y: block.b?.[1] || 0 }));
}
shell.dataset.textReady = "1";
readerContext.highlightPdfText(shell);
} finally {
readerContext.releasePdfTextSlot();
}
} catch (error) {
if (!readerContext.readerAbortController.signal.aborted)
console.warn(`PDF page ${shell.dataset.page} OCR text failed`, error);
}
})().finally(() => { if (shell._ocrPromise === task) delete shell._ocrPromise; });
shell._ocrPromise = task;
return task;
}
async function renderPdfText(page, shell, signal = null) {
if (readerContext.ocrManifestUrl && readerContext.pdfV3Manifest) return readerContext.renderPdfOcrText(shell);
if (shell.dataset.textReady === "1") return;
const layer = shell.querySelector(".reader-pdf-text");
if (!layer || typeof page.getTextContent !== "function") return;
const epoch = shell._textEpoch || 0;
try {
const pageNumber = Number(shell.dataset.page);
const text = await readerContext.loadPdfTextContent(pageNumber, page);
readerContext.assertReaderActive();
if (signal?.aborted) return;
readerContext.cachePdfTextContent(pageNumber, text);
if (epoch !== (shell._textEpoch || 0) || !shell.isConnected) return;
const pdfViewport = page.getViewport({ scale: 1 });
shell._bookmarkTextItems = text.items
.filter((item) => item.str && item.str.trim())
.map((item) => {
const point =
item.transform && pdfViewport.convertToViewportPoint
? pdfViewport.convertToViewportPoint(item.transform[4], item.transform[5])
: null;
return {
text: item.str,
y: point ? Math.max(0, Math.min(1, point[1] / Math.max(1, pdfViewport.height))) : 0
};
});
readerContext.populatePdfTextLayer(layer, text.items, pdfViewport, text.styles);
shell.dataset.textReady = "1";
readerContext.highlightPdfText(shell);
} catch (error) {
if (!readerContext.readerAbortController.signal.aborted && !signal?.aborted)
console.warn(`PDF page ${shell.dataset.page} text extraction failed`, error);
}
}
function highlightPdfText(shell, query = readerContext.searchState.query) {
if (!shell || readerContext.readerAbortController.signal.aborted || !query) return;
const layer = shell.querySelector(".reader-pdf-text");
if (!layer || layer.querySelector("mark")) return;
const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT), runs = [];
let raw = "";
while (walker.nextNode()) {
const node = walker.currentNode, start = raw.length;
raw += node.data;
runs.push({ node, start, end: raw.length });
}
const mapping = readerContext.pdfSearchText(raw);
const hits = [];
for (const hit of mapping.text.matchAll(new RegExp(readerContext.fullSearchEscape(readerContext.normalizePdfSearchText(query)), "giu"))) {
hits.push(hit);
if (hits.length === 100) break;
}
// Bound highlights in the displayed page, without limiting searchable results.
for (const hit of hits.reverse()) {
const start = readerContext.pdfSourceOffset(mapping, hit.index), end = readerContext.pdfSourceOffset(mapping, hit.index + hit[0].length - 1) + 1;
readerContext.highlightTextParts(runs.filter((run) => run.end > start && run.start < end).map((run) => ({
node: run.node, start: Math.max(start, run.start) - run.start, end: Math.min(end, run.end) - run.start
})));
}
}
return {
pdfOcrSource,
v3ResourceUrl,
createPdfV3Repository,
readPdfOcrJson,
loadPdfOcrManifest,
loadPdfOcrPage,
estimatePdfTextContentBytes,
cachePdfTextContent,
cachedPdfTextContent,
loadPdfTextContent,
estimatePdfOcrPageBytes,
cachePdfOcrPage,
cachedPdfOcrPage,
renderPdfOcrText,
renderPdfText,
highlightPdfText
};
}