diff --git a/src/chrome/src/ui/emergency-pdf-search.js b/src/chrome/src/ui/emergency-pdf-search.js new file mode 100644 index 000000000..a5744dd48 --- /dev/null +++ b/src/chrome/src/ui/emergency-pdf-search.js @@ -0,0 +1,50 @@ +export function createEmergencyPdfTextSearch() { + let latestSearchId = 0; + + return { + async find(query, options = {}) { + const id = ++latestSearchId; + const normalizedQuery = String(query || '').trim().toLocaleLowerCase(); + const lastPage = Math.max(0, Math.floor(Number(options.numPages) || 0)); + if (!normalizedQuery || !lastPage || typeof options.pageText !== 'function') { + return { id, query: normalizedQuery, page: 0 }; + } + const currentPage = Math.max(1, Math.min(lastPage, Math.floor(Number(options.pageNumber) || 1))); + const order = []; + for (let page = currentPage + 1; page <= lastPage; page += 1) order.push(page); + for (let page = 1; page <= currentPage; page += 1) order.push(page); + + try { + for (const page of order) { + const text = await options.pageText(page); + if (id !== latestSearchId) return { id, query: normalizedQuery, page: 0 }; + if (String(text || '').toLocaleLowerCase().includes(normalizedQuery)) { + return { id, query: normalizedQuery, page }; + } + } + } catch (error) { + if (id !== latestSearchId) return { id, query: normalizedQuery, page: 0 }; + return { id, query: normalizedQuery, page: 0, error }; + } + return { id, query: normalizedQuery, page: 0 }; + }, + async apply(result, action) { + const isCurrent = () => Number.isInteger(result?.id) && result.id === latestSearchId; + if (!isCurrent() || typeof action !== 'function') return false; + try { + const applied = await action(isCurrent); + if (applied === false) return false; + } catch (error) { + if (!isCurrent()) return false; + throw error; + } + return isCurrent(); + }, + isCurrent(result) { + return Number.isInteger(result?.id) && result.id === latestSearchId; + }, + cancel() { + latestSearchId += 1; + }, + }; +} diff --git a/src/chrome/src/ui/emergency-pdf.js b/src/chrome/src/ui/emergency-pdf.js index d55375758..8025044cf 100644 --- a/src/chrome/src/ui/emergency-pdf.js +++ b/src/chrome/src/ui/emergency-pdf.js @@ -1,4 +1,5 @@ import { createEmergencyBoxStorage, createEmergencyBoxStore } from '../agent/emergency-box.js'; +import { createEmergencyPdfTextSearch } from './emergency-pdf-search.js'; import { t } from './i18n.js'; import { THEME_MODES, applyMode, loadMode, watch } from './theme.js'; @@ -27,12 +28,15 @@ let record = null; let file = null; let pdf = null; let pageNumber = 1; +let requestedPageNumber = 1; let scale = 1.15; let fitWidth = true; let renderTask = null; let renderSequence = 0; +let pageRequestSequence = 0; let resizeTimer = null; const textCache = new Map(); +const textSearch = createEmergencyPdfTextSearch(); function setMessage(message, kind = '') { elements['reader-message'].hidden = false; @@ -75,47 +79,98 @@ function updatePageControls() { elements['next-page'].disabled = pageNumber >= pdf.numPages; } -async function renderPage() { - if (!pdf) return; - const sequence = ++renderSequence; - try { renderTask?.cancel?.(); } catch { /* a completed render cannot be cancelled */ } - const page = await pdf.getPage(pageNumber); - if (sequence !== renderSequence) return; +function cancelRender() { + renderSequence += 1; + const pendingTask = renderTask; + renderTask = null; + try { pendingTask?.cancel?.(); } catch { /* a completed render cannot be cancelled */ } +} + +function cancelPendingResize() { + clearTimeout(resizeTimer); + resizeTimer = null; +} + +async function renderPage(targetPageNumber = pageNumber, isCurrent = () => true) { + if (!pdf || !isCurrent()) return false; + const targetPage = Math.max(1, Math.min(pdf.numPages, Math.floor(Number(targetPageNumber) || 1))); + cancelRender(); + const sequence = renderSequence; + let page; + try { + page = await pdf.getPage(targetPage); + } catch (error) { + if (sequence !== renderSequence || !isCurrent()) return false; + throw error; + } + if (sequence !== renderSequence || !isCurrent()) return false; + let renderScale = scale; if (fitWidth) { const natural = page.getViewport({ scale: 1 }); - scale = Math.max(.5, Math.min(3, (elements['document-stage'].clientWidth - 56) / natural.width)); + renderScale = Math.max(.5, Math.min(3, (elements['document-stage'].clientWidth - 56) / natural.width)); } const pixelRatio = Math.min(2, globalThis.devicePixelRatio || 1); - const cssViewport = page.getViewport({ scale }); - const renderViewport = page.getViewport({ scale: scale * pixelRatio }); - const canvas = elements['pdf-canvas']; - canvas.width = Math.floor(renderViewport.width); - canvas.height = Math.floor(renderViewport.height); - canvas.style.width = `${Math.floor(cssViewport.width)}px`; - canvas.style.height = `${Math.floor(cssViewport.height)}px`; - const context = canvas.getContext('2d', { alpha: false }); - renderTask = page.render({ canvasContext: context, viewport: renderViewport }); + const cssViewport = page.getViewport({ scale: renderScale }); + const renderViewport = page.getViewport({ scale: renderScale * pixelRatio }); + // Keep superseded renders from clearing or partially painting the committed page. + const renderCanvas = document.createElement('canvas'); + renderCanvas.width = Math.floor(renderViewport.width); + renderCanvas.height = Math.floor(renderViewport.height); + const context = renderCanvas.getContext('2d', { alpha: false }); + const task = page.render({ canvasContext: context, viewport: renderViewport }); + renderTask = task; try { - await renderTask.promise; + await task.promise; } catch (error) { - if (error?.name === 'RenderingCancelledException') return; + if (error?.name === 'RenderingCancelledException' || sequence !== renderSequence || !isCurrent()) return false; throw error; + } finally { + if (renderTask === task) renderTask = null; } - if (sequence !== renderSequence) return; + if (sequence !== renderSequence || !isCurrent()) return false; + const canvas = elements['pdf-canvas']; + canvas.width = renderCanvas.width; + canvas.height = renderCanvas.height; + canvas.style.width = `${Math.floor(cssViewport.width)}px`; + canvas.style.height = `${Math.floor(cssViewport.height)}px`; + canvas.getContext('2d', { alpha: false }).drawImage(renderCanvas, 0, 0); + pageNumber = targetPage; + requestedPageNumber = targetPage; + scale = renderScale; elements['reader-message'].hidden = true; canvas.hidden = false; - canvas.setAttribute('aria-label', t('ep.page_aria', { page: pageNumber, total: pdf.numPages })); + canvas.setAttribute('aria-label', t('ep.page_aria', { page: targetPage, total: pdf.numPages })); updatePageControls(); - setStatus(t('ep.page_status', { page: pageNumber, total: pdf.numPages, zoom: Math.round(scale * 100) })); + setStatus(t('ep.page_status', { page: targetPage, total: pdf.numPages, zoom: Math.round(scale * 100) })); elements['document-stage'].scrollTo({ top: 0, left: 0 }); + return true; } async function goToPage(value) { - if (!pdf) return; + if (!pdf) return false; + const request = ++pageRequestSequence; + const isCurrent = () => request === pageRequestSequence; const next = Math.max(1, Math.min(pdf.numPages, Math.floor(Number(value) || 1))); - if (next === pageNumber && !elements['pdf-canvas'].hidden) return; - pageNumber = next; - await renderPage(); + requestedPageNumber = next; + try { + const rendered = await renderPage(next, isCurrent); + if (!rendered && isCurrent() && requestedPageNumber === next) requestedPageNumber = pageNumber; + return rendered; + } catch (error) { + if (isCurrent() && requestedPageNumber === next) requestedPageNumber = pageNumber; + throw error; + } +} + +function requestPage(value) { + cancelPendingResize(); + textSearch.cancel(); + return goToPage(value); +} + +function rerenderRequestedPage() { + cancelPendingResize(); + return goToPage(requestedPageNumber); } async function pageText(number) { @@ -128,20 +183,47 @@ async function pageText(number) { } async function findText(query) { + cancelPendingResize(); const needle = String(query || '').trim().toLocaleLowerCase(); - if (!pdf || !needle) return; - setStatus(t('ep.searching', { query: needle })); - const order = []; - for (let number = pageNumber + 1; number <= pdf.numPages; number += 1) order.push(number); - for (let number = 1; number <= pageNumber; number += 1) order.push(number); - for (const number of order) { - const text = await pageText(number); - if (!text.toLocaleLowerCase().includes(needle)) continue; - await goToPage(number); - setStatus(t('ep.found', { query: needle, page: number }), 'success'); + const pendingSearch = textSearch.find(needle, { + pageNumber, + numPages: pdf?.numPages || 0, + pageText, + }); + pageRequestSequence += 1; + requestedPageNumber = pageNumber; + cancelRender(); + if (pdf && needle) setStatus(t('ep.searching', { query: needle })); + else if (pdf) setStatus(t('ep.page_status', { + page: pageNumber, total: pdf.numPages, zoom: Math.round(scale * 100), + })); + const result = await pendingSearch; + if (!pdf || !textSearch.isCurrent(result)) return; + if (!needle) { + await textSearch.apply(result, isCurrent => renderPage(pageNumber, isCurrent)); return; } - setStatus(t('ep.not_found', { query: needle }), 'error'); + if (result.error) { + const applied = await textSearch.apply(result, isCurrent => renderPage(pageNumber, isCurrent)); + if (!applied) return; + throw result.error; + } + if (result.page) { + requestedPageNumber = result.page; + let applied; + try { + applied = await textSearch.apply(result, isCurrent => renderPage(result.page, isCurrent)); + } catch (error) { + if (textSearch.isCurrent(result) && requestedPageNumber === result.page) requestedPageNumber = pageNumber; + throw error; + } + if (!applied) return; + setStatus(t('ep.found', { query: result.query, page: result.page }), 'success'); + return; + } + const applied = await textSearch.apply(result, isCurrent => renderPage(pageNumber, isCurrent)); + if (!applied) return; + setStatus(t('ep.not_found', { query: result.query }), 'error'); } async function initialize() { @@ -165,22 +247,22 @@ async function initialize() { await renderPage(); } -elements['previous-page'].addEventListener('click', () => goToPage(pageNumber - 1)); -elements['next-page'].addEventListener('click', () => goToPage(pageNumber + 1)); -elements['page-number'].addEventListener('change', event => goToPage(event.target.value)); +elements['previous-page'].addEventListener('click', () => requestPage(requestedPageNumber - 1)); +elements['next-page'].addEventListener('click', () => requestPage(requestedPageNumber + 1)); +elements['page-number'].addEventListener('change', event => requestPage(event.target.value)); elements['zoom-out'].addEventListener('click', () => { fitWidth = false; scale = Math.max(.5, scale - .15); - renderPage(); + rerenderRequestedPage(); }); elements['zoom-in'].addEventListener('click', () => { fitWidth = false; scale = Math.min(3, scale + .15); - renderPage(); + rerenderRequestedPage(); }); elements['fit-width'].addEventListener('click', () => { fitWidth = true; - renderPage(); + rerenderRequestedPage(); }); elements['search-form'].addEventListener('submit', event => { event.preventDefault(); @@ -202,15 +284,15 @@ elements['save-copy'].addEventListener('click', () => { }); globalThis.addEventListener('keydown', event => { if (event.target instanceof HTMLInputElement) return; - if (event.key === 'ArrowLeft') goToPage(pageNumber - 1); - if (event.key === 'ArrowRight') goToPage(pageNumber + 1); + if (event.key === 'ArrowLeft') requestPage(requestedPageNumber - 1); + if (event.key === 'ArrowRight') requestPage(requestedPageNumber + 1); if (event.key === '+' || event.key === '=') elements['zoom-in'].click(); if (event.key === '-') elements['zoom-out'].click(); }); globalThis.addEventListener('resize', () => { if (!fitWidth || !pdf) return; - clearTimeout(resizeTimer); - resizeTimer = setTimeout(renderPage, 120); + cancelPendingResize(); + resizeTimer = setTimeout(rerenderRequestedPage, 120); }); initialize().catch(error => { diff --git a/src/firefox/src/ui/emergency-pdf-search.js b/src/firefox/src/ui/emergency-pdf-search.js new file mode 100644 index 000000000..a5744dd48 --- /dev/null +++ b/src/firefox/src/ui/emergency-pdf-search.js @@ -0,0 +1,50 @@ +export function createEmergencyPdfTextSearch() { + let latestSearchId = 0; + + return { + async find(query, options = {}) { + const id = ++latestSearchId; + const normalizedQuery = String(query || '').trim().toLocaleLowerCase(); + const lastPage = Math.max(0, Math.floor(Number(options.numPages) || 0)); + if (!normalizedQuery || !lastPage || typeof options.pageText !== 'function') { + return { id, query: normalizedQuery, page: 0 }; + } + const currentPage = Math.max(1, Math.min(lastPage, Math.floor(Number(options.pageNumber) || 1))); + const order = []; + for (let page = currentPage + 1; page <= lastPage; page += 1) order.push(page); + for (let page = 1; page <= currentPage; page += 1) order.push(page); + + try { + for (const page of order) { + const text = await options.pageText(page); + if (id !== latestSearchId) return { id, query: normalizedQuery, page: 0 }; + if (String(text || '').toLocaleLowerCase().includes(normalizedQuery)) { + return { id, query: normalizedQuery, page }; + } + } + } catch (error) { + if (id !== latestSearchId) return { id, query: normalizedQuery, page: 0 }; + return { id, query: normalizedQuery, page: 0, error }; + } + return { id, query: normalizedQuery, page: 0 }; + }, + async apply(result, action) { + const isCurrent = () => Number.isInteger(result?.id) && result.id === latestSearchId; + if (!isCurrent() || typeof action !== 'function') return false; + try { + const applied = await action(isCurrent); + if (applied === false) return false; + } catch (error) { + if (!isCurrent()) return false; + throw error; + } + return isCurrent(); + }, + isCurrent(result) { + return Number.isInteger(result?.id) && result.id === latestSearchId; + }, + cancel() { + latestSearchId += 1; + }, + }; +} diff --git a/src/firefox/src/ui/emergency-pdf.js b/src/firefox/src/ui/emergency-pdf.js index d55375758..8025044cf 100644 --- a/src/firefox/src/ui/emergency-pdf.js +++ b/src/firefox/src/ui/emergency-pdf.js @@ -1,4 +1,5 @@ import { createEmergencyBoxStorage, createEmergencyBoxStore } from '../agent/emergency-box.js'; +import { createEmergencyPdfTextSearch } from './emergency-pdf-search.js'; import { t } from './i18n.js'; import { THEME_MODES, applyMode, loadMode, watch } from './theme.js'; @@ -27,12 +28,15 @@ let record = null; let file = null; let pdf = null; let pageNumber = 1; +let requestedPageNumber = 1; let scale = 1.15; let fitWidth = true; let renderTask = null; let renderSequence = 0; +let pageRequestSequence = 0; let resizeTimer = null; const textCache = new Map(); +const textSearch = createEmergencyPdfTextSearch(); function setMessage(message, kind = '') { elements['reader-message'].hidden = false; @@ -75,47 +79,98 @@ function updatePageControls() { elements['next-page'].disabled = pageNumber >= pdf.numPages; } -async function renderPage() { - if (!pdf) return; - const sequence = ++renderSequence; - try { renderTask?.cancel?.(); } catch { /* a completed render cannot be cancelled */ } - const page = await pdf.getPage(pageNumber); - if (sequence !== renderSequence) return; +function cancelRender() { + renderSequence += 1; + const pendingTask = renderTask; + renderTask = null; + try { pendingTask?.cancel?.(); } catch { /* a completed render cannot be cancelled */ } +} + +function cancelPendingResize() { + clearTimeout(resizeTimer); + resizeTimer = null; +} + +async function renderPage(targetPageNumber = pageNumber, isCurrent = () => true) { + if (!pdf || !isCurrent()) return false; + const targetPage = Math.max(1, Math.min(pdf.numPages, Math.floor(Number(targetPageNumber) || 1))); + cancelRender(); + const sequence = renderSequence; + let page; + try { + page = await pdf.getPage(targetPage); + } catch (error) { + if (sequence !== renderSequence || !isCurrent()) return false; + throw error; + } + if (sequence !== renderSequence || !isCurrent()) return false; + let renderScale = scale; if (fitWidth) { const natural = page.getViewport({ scale: 1 }); - scale = Math.max(.5, Math.min(3, (elements['document-stage'].clientWidth - 56) / natural.width)); + renderScale = Math.max(.5, Math.min(3, (elements['document-stage'].clientWidth - 56) / natural.width)); } const pixelRatio = Math.min(2, globalThis.devicePixelRatio || 1); - const cssViewport = page.getViewport({ scale }); - const renderViewport = page.getViewport({ scale: scale * pixelRatio }); - const canvas = elements['pdf-canvas']; - canvas.width = Math.floor(renderViewport.width); - canvas.height = Math.floor(renderViewport.height); - canvas.style.width = `${Math.floor(cssViewport.width)}px`; - canvas.style.height = `${Math.floor(cssViewport.height)}px`; - const context = canvas.getContext('2d', { alpha: false }); - renderTask = page.render({ canvasContext: context, viewport: renderViewport }); + const cssViewport = page.getViewport({ scale: renderScale }); + const renderViewport = page.getViewport({ scale: renderScale * pixelRatio }); + // Keep superseded renders from clearing or partially painting the committed page. + const renderCanvas = document.createElement('canvas'); + renderCanvas.width = Math.floor(renderViewport.width); + renderCanvas.height = Math.floor(renderViewport.height); + const context = renderCanvas.getContext('2d', { alpha: false }); + const task = page.render({ canvasContext: context, viewport: renderViewport }); + renderTask = task; try { - await renderTask.promise; + await task.promise; } catch (error) { - if (error?.name === 'RenderingCancelledException') return; + if (error?.name === 'RenderingCancelledException' || sequence !== renderSequence || !isCurrent()) return false; throw error; + } finally { + if (renderTask === task) renderTask = null; } - if (sequence !== renderSequence) return; + if (sequence !== renderSequence || !isCurrent()) return false; + const canvas = elements['pdf-canvas']; + canvas.width = renderCanvas.width; + canvas.height = renderCanvas.height; + canvas.style.width = `${Math.floor(cssViewport.width)}px`; + canvas.style.height = `${Math.floor(cssViewport.height)}px`; + canvas.getContext('2d', { alpha: false }).drawImage(renderCanvas, 0, 0); + pageNumber = targetPage; + requestedPageNumber = targetPage; + scale = renderScale; elements['reader-message'].hidden = true; canvas.hidden = false; - canvas.setAttribute('aria-label', t('ep.page_aria', { page: pageNumber, total: pdf.numPages })); + canvas.setAttribute('aria-label', t('ep.page_aria', { page: targetPage, total: pdf.numPages })); updatePageControls(); - setStatus(t('ep.page_status', { page: pageNumber, total: pdf.numPages, zoom: Math.round(scale * 100) })); + setStatus(t('ep.page_status', { page: targetPage, total: pdf.numPages, zoom: Math.round(scale * 100) })); elements['document-stage'].scrollTo({ top: 0, left: 0 }); + return true; } async function goToPage(value) { - if (!pdf) return; + if (!pdf) return false; + const request = ++pageRequestSequence; + const isCurrent = () => request === pageRequestSequence; const next = Math.max(1, Math.min(pdf.numPages, Math.floor(Number(value) || 1))); - if (next === pageNumber && !elements['pdf-canvas'].hidden) return; - pageNumber = next; - await renderPage(); + requestedPageNumber = next; + try { + const rendered = await renderPage(next, isCurrent); + if (!rendered && isCurrent() && requestedPageNumber === next) requestedPageNumber = pageNumber; + return rendered; + } catch (error) { + if (isCurrent() && requestedPageNumber === next) requestedPageNumber = pageNumber; + throw error; + } +} + +function requestPage(value) { + cancelPendingResize(); + textSearch.cancel(); + return goToPage(value); +} + +function rerenderRequestedPage() { + cancelPendingResize(); + return goToPage(requestedPageNumber); } async function pageText(number) { @@ -128,20 +183,47 @@ async function pageText(number) { } async function findText(query) { + cancelPendingResize(); const needle = String(query || '').trim().toLocaleLowerCase(); - if (!pdf || !needle) return; - setStatus(t('ep.searching', { query: needle })); - const order = []; - for (let number = pageNumber + 1; number <= pdf.numPages; number += 1) order.push(number); - for (let number = 1; number <= pageNumber; number += 1) order.push(number); - for (const number of order) { - const text = await pageText(number); - if (!text.toLocaleLowerCase().includes(needle)) continue; - await goToPage(number); - setStatus(t('ep.found', { query: needle, page: number }), 'success'); + const pendingSearch = textSearch.find(needle, { + pageNumber, + numPages: pdf?.numPages || 0, + pageText, + }); + pageRequestSequence += 1; + requestedPageNumber = pageNumber; + cancelRender(); + if (pdf && needle) setStatus(t('ep.searching', { query: needle })); + else if (pdf) setStatus(t('ep.page_status', { + page: pageNumber, total: pdf.numPages, zoom: Math.round(scale * 100), + })); + const result = await pendingSearch; + if (!pdf || !textSearch.isCurrent(result)) return; + if (!needle) { + await textSearch.apply(result, isCurrent => renderPage(pageNumber, isCurrent)); return; } - setStatus(t('ep.not_found', { query: needle }), 'error'); + if (result.error) { + const applied = await textSearch.apply(result, isCurrent => renderPage(pageNumber, isCurrent)); + if (!applied) return; + throw result.error; + } + if (result.page) { + requestedPageNumber = result.page; + let applied; + try { + applied = await textSearch.apply(result, isCurrent => renderPage(result.page, isCurrent)); + } catch (error) { + if (textSearch.isCurrent(result) && requestedPageNumber === result.page) requestedPageNumber = pageNumber; + throw error; + } + if (!applied) return; + setStatus(t('ep.found', { query: result.query, page: result.page }), 'success'); + return; + } + const applied = await textSearch.apply(result, isCurrent => renderPage(pageNumber, isCurrent)); + if (!applied) return; + setStatus(t('ep.not_found', { query: result.query }), 'error'); } async function initialize() { @@ -165,22 +247,22 @@ async function initialize() { await renderPage(); } -elements['previous-page'].addEventListener('click', () => goToPage(pageNumber - 1)); -elements['next-page'].addEventListener('click', () => goToPage(pageNumber + 1)); -elements['page-number'].addEventListener('change', event => goToPage(event.target.value)); +elements['previous-page'].addEventListener('click', () => requestPage(requestedPageNumber - 1)); +elements['next-page'].addEventListener('click', () => requestPage(requestedPageNumber + 1)); +elements['page-number'].addEventListener('change', event => requestPage(event.target.value)); elements['zoom-out'].addEventListener('click', () => { fitWidth = false; scale = Math.max(.5, scale - .15); - renderPage(); + rerenderRequestedPage(); }); elements['zoom-in'].addEventListener('click', () => { fitWidth = false; scale = Math.min(3, scale + .15); - renderPage(); + rerenderRequestedPage(); }); elements['fit-width'].addEventListener('click', () => { fitWidth = true; - renderPage(); + rerenderRequestedPage(); }); elements['search-form'].addEventListener('submit', event => { event.preventDefault(); @@ -202,15 +284,15 @@ elements['save-copy'].addEventListener('click', () => { }); globalThis.addEventListener('keydown', event => { if (event.target instanceof HTMLInputElement) return; - if (event.key === 'ArrowLeft') goToPage(pageNumber - 1); - if (event.key === 'ArrowRight') goToPage(pageNumber + 1); + if (event.key === 'ArrowLeft') requestPage(requestedPageNumber - 1); + if (event.key === 'ArrowRight') requestPage(requestedPageNumber + 1); if (event.key === '+' || event.key === '=') elements['zoom-in'].click(); if (event.key === '-') elements['zoom-out'].click(); }); globalThis.addEventListener('resize', () => { if (!fitWidth || !pdf) return; - clearTimeout(resizeTimer); - resizeTimer = setTimeout(renderPage, 120); + cancelPendingResize(); + resizeTimer = setTimeout(rerenderRequestedPage, 120); }); initialize().catch(error => { diff --git a/test/run.js b/test/run.js index 64adab8f7..96107ac28 100644 --- a/test/run.js +++ b/test/run.js @@ -22352,6 +22352,286 @@ test('Emergency Box serializes competing PDF writers and reuses the verified win } }); +test('Emergency PDF ignores stale search and page operations', async () => { + for (const browser of ['chrome', 'firefox']) { + const readerSource = fs.readFileSync(path.join(ROOT, `src/${browser}/src/ui/emergency-pdf.js`), 'utf8'); + assert.match( + readerSource, + /textSearch\.apply\(result, isCurrent => renderPage\(result\.page, isCurrent\)\)/, + `${browser}: PDF search navigation does not carry latest-query ownership`, + ); + assert.match(readerSource, + /async function renderPage\(targetPageNumber = pageNumber, isCurrent = \(\) => true\)[\s\S]*?sequence !== renderSequence \|\| !isCurrent\(\)[\s\S]*?sequence !== renderSequence \|\| !isCurrent\(\)/, + `${browser}: PDF rendering can commit after its search loses ownership`); + assert.match(readerSource, + /const renderCanvas = document\.createElement\('canvas'\)[\s\S]*?page\.render\(\{ canvasContext: context[\s\S]*?if \(sequence !== renderSequence \|\| !isCurrent\(\)\) return false;[\s\S]*?drawImage\(renderCanvas, 0, 0\)/, + `${browser}: pending PDF renders can mutate the visible canvas before ownership is confirmed`); + assert.match(readerSource, + /function requestPage\(value\) \{\s*cancelPendingResize\(\);\s*textSearch\.cancel\(\);\s*return goToPage\(value\);\s*\}/, + `${browser}: manual PDF navigation does not supersede a pending text search`); + assert.match(readerSource, + /function rerenderRequestedPage\(\) \{\s*cancelPendingResize\(\);\s*return goToPage\(requestedPageNumber\);\s*\}[\s\S]*?resizeTimer = setTimeout\(rerenderRequestedPage, 120\)/, + `${browser}: PDF view changes can silently revert a pending page request`); + assert.match(readerSource, + /if \(result\.error\) \{[\s\S]*?textSearch\.apply\(result, isCurrent => renderPage\(pageNumber, isCurrent\)\)[\s\S]*?throw result\.error/, + `${browser}: PDF extraction errors do not restore the committed page after cancelling a render`); + const { createEmergencyPdfTextSearch } = await import(pathToFileURL(path.join( + ROOT, `src/${browser}/src/ui/emergency-pdf-search.js`, + )).href); + const search = createEmergencyPdfTextSearch(); + let releaseOlder; + const olderPage = new Promise(resolve => { releaseOlder = resolve; }); + const older = search.find('older result', { + pageNumber: 1, + numPages: 3, + pageText: async page => page === 2 ? await olderPage : '', + }); + const latest = await search.find('latest result', { + pageNumber: 1, + numPages: 3, + pageText: async page => page === 3 ? 'latest result' : '', + }); + assert.equal(latest.page, 3, `${browser}: latest PDF query did not find its page`); + assert.equal(search.isCurrent(latest), true, `${browser}: latest PDF query was treated as stale`); + + releaseOlder('older result'); + const stale = await older; + assert.equal(stale.page, 0, `${browser}: older PDF query remained actionable`); + assert.equal(search.isCurrent(stale), false, `${browser}: older PDF query could replace the latest result`); + + let rejectOlder; + const olderFailure = search.find('older failure', { + pageNumber: 1, + numPages: 2, + pageText: async () => await new Promise((_resolve, reject) => { rejectOlder = reject; }), + }); + const replacement = await search.find('replacement', { + pageNumber: 1, + numPages: 2, + pageText: async () => 'replacement', + }); + rejectOlder(new Error('stale extraction failure')); + const staleFailure = await olderFailure; + assert.equal(search.isCurrent(replacement), true, `${browser}: replacement PDF query lost ownership`); + assert.equal(search.isCurrent(staleFailure), false, `${browser}: older PDF error could replace current status`); + + const currentFailure = await search.find('current failure', { + pageNumber: 1, + numPages: 2, + pageText: async () => { throw new Error('current extraction failure'); }, + }); + assert.match(currentFailure.error?.message || '', /current extraction failure/, + `${browser}: current PDF extraction error was swallowed`); + assert.equal(search.isCurrent(currentFailure), true, `${browser}: current PDF error lost ownership`); + + const navigationResult = await search.find('navigation result', { + pageNumber: 1, + numPages: 2, + pageText: async () => 'navigation result', + }); + let releaseNavigation; + const navigationGate = new Promise(resolve => { releaseNavigation = resolve; }); + let staleNavigationCommitted = false; + const oldNavigation = search.apply(navigationResult, async isCurrent => { + await navigationGate; + if (isCurrent()) staleNavigationCommitted = true; + throw new Error('stale navigation failure'); + }); + const currentResult = await search.find('current result', { + pageNumber: 1, + numPages: 2, + pageText: async () => 'current result', + }); + releaseNavigation(); + assert.equal(await oldNavigation, false, `${browser}: stale PDF navigation remained actionable`); + assert.equal(staleNavigationCommitted, false, `${browser}: stale PDF navigation committed UI state`); + assert.equal(search.isCurrent(currentResult), true, `${browser}: current query lost navigation ownership`); + assert.equal(await search.apply(currentResult, async () => false), false, + `${browser}: cancelled PDF render was reported as applied`); + + let releaseCancelledSearch; + const cancelledSearch = search.find('cancelled by navigation', { + pageNumber: 1, + numPages: 2, + pageText: async () => await new Promise(resolve => { releaseCancelledSearch = resolve; }), + }); + search.cancel(); + releaseCancelledSearch('cancelled by navigation'); + const cancelledResult = await cancelledSearch; + assert.equal(search.isCurrent(cancelledResult), false, + `${browser}: manual navigation did not invalidate the pending PDF search`); + + const runtimeStart = readerSource.indexOf('function updatePageControls()'); + const runtimeEnd = readerSource.indexOf('\n\nasync function initialize(', runtimeStart); + assert.notEqual(runtimeStart, -1, `${browser}: PDF page controls are missing`); + assert.notEqual(runtimeEnd, -1, `${browser}: PDF page runtime boundary is missing`); + const createPageRuntime = Function('state', ` + const elements = state.elements; + const document = state.document; + const textSearch = state.textSearch; + const t = key => key; + let pdf = state.pdf; + let pageNumber = 1; + let requestedPageNumber = 1; + let scale = 1; + let fitWidth = false; + let renderTask = null; + let renderSequence = 0; + let pageRequestSequence = 0; + let resizeTimer = null; + const textCache = new Map(); + function setStatus(message, kind = '') { + state.status = { message, kind }; + } + ${readerSource.slice(runtimeStart, runtimeEnd)} + return { + requestPage, + rerenderRequestedPage, + findText, + snapshot() { return { pageNumber, requestedPageNumber, renderSequence, pageRequestSequence }; }, + }; + `); + const renderTasks = []; + const visibleCanvas = { + width: 111, + height: 222, + hidden: false, + marker: 'page 1', + style: {}, + getContext() { + return { drawImage: source => { this.marker = source.marker; } }; + }, + setAttribute() {}, + }; + const elements = { + 'page-number': { value: '1' }, + 'page-count': { textContent: '2' }, + 'previous-page': { disabled: true }, + 'next-page': { disabled: false }, + 'document-stage': { clientWidth: 800, scrollTo() {} }, + 'reader-message': { hidden: true }, + 'pdf-canvas': visibleCanvas, + }; + const runtimeTextSearch = createEmergencyPdfTextSearch(); + const pageRuntimeState = { + searchCancellations: 0, + elements, + textSearch: { + find: (...args) => runtimeTextSearch.find(...args), + apply: (...args) => runtimeTextSearch.apply(...args), + isCurrent: (...args) => runtimeTextSearch.isCurrent(...args), + cancel() { + pageRuntimeState.searchCancellations += 1; + runtimeTextSearch.cancel(); + }, + }, + document: { + createElement(tagName) { + assert.equal(tagName, 'canvas', `${browser}: PDF renderer created an unexpected element`); + const canvas = { + width: 0, + height: 0, + marker: '', + getContext() { return { canvas }; }, + }; + return canvas; + }, + }, + pdf: { + numPages: 3, + async getPage(number) { + return { + getViewport: ({ scale: viewportScale }) => ({ width: 100 * viewportScale, height: 200 * viewportScale }), + async getTextContent() { + return { items: [{ str: number === 2 ? 'search needle' : '' }] }; + }, + render({ canvasContext }) { + let release; + let fail; + const promise = new Promise((resolve, reject) => { + release = () => { + canvasContext.canvas.marker = `page ${number}`; + resolve(); + }; + fail = reject; + }); + const task = { + cancelled: false, + cancel() { this.cancelled = true; }, + promise, + release, + fail, + }; + renderTasks.push(task); + return task; + }, + }; + }, + }, + }; + const pageRuntime = createPageRuntime(pageRuntimeState); + const forward = pageRuntime.requestPage(2); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(renderTasks.length, 1, `${browser}: deferred next-page render did not start`); + assert.equal(visibleCanvas.marker, 'page 1', `${browser}: pending render changed visible PDF pixels`); + assert.equal(visibleCanvas.width, 111, `${browser}: pending render resized the visible PDF canvas`); + + const resized = pageRuntime.rerenderRequestedPage(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(renderTasks.length, 2, `${browser}: requested-page resize render did not start`); + assert.equal(renderTasks[0].cancelled, true, `${browser}: resize did not cancel the superseded page render`); + renderTasks[0].release(); + assert.equal(await forward, false, `${browser}: pre-resize page render still committed`); + assert.equal(pageRuntime.snapshot().requestedPageNumber, 2, + `${browser}: pre-resize render reset the current requested page`); + assert.equal(visibleCanvas.marker, 'page 1', `${browser}: pre-resize render changed visible PDF pixels`); + renderTasks[1].release(); + assert.equal(await resized, true, `${browser}: resize did not finish the pending page request`); + assert.equal(pageRuntime.snapshot().pageNumber, 2, + `${browser}: resize silently reverted the pending page request`); + assert.equal(visibleCanvas.marker, 'page 2', `${browser}: resized page request did not commit its pixels`); + + const reset = pageRuntime.requestPage(1); + await new Promise(resolve => setImmediate(resolve)); + renderTasks[2].release(); + assert.equal(await reset, true, `${browser}: PDF runtime could not reset the committed page`); + + const nextThenPrevious = pageRuntime.requestPage(2); + await new Promise(resolve => setImmediate(resolve)); + const back = pageRuntime.requestPage(1); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(renderTasks.length, 5, `${browser}: committed-page restoration render did not start`); + assert.equal(renderTasks[3].cancelled, true, + `${browser}: returning to the current page did not cancel stale rendering`); + assert.equal(visibleCanvas.marker, 'page 1', `${browser}: restoration changed pixels before render completion`); + renderTasks[4].release(); + assert.equal(await back, true, `${browser}: returning to the committed page was not accepted`); + renderTasks[3].release(); + assert.equal(await nextThenPrevious, false, `${browser}: superseded next-page render still committed`); + assert.equal(pageRuntime.snapshot().pageNumber, 1, + `${browser}: quick next-then-previous navigation ended on the stale page`); + assert.equal(pageRuntime.snapshot().requestedPageNumber, 1, + `${browser}: requested PDF page reverted to the stale render`); + assert.equal(visibleCanvas.marker, 'page 1', `${browser}: stale render replaced the committed PDF pixels`); + assert.equal(visibleCanvas.width, 100, `${browser}: committed-page restoration used the wrong canvas size`); + assert.equal(pageRuntimeState.searchCancellations, 4, + `${browser}: manual PDF navigation did not cancel text-search ownership`); + + const failedSearch = pageRuntime.findText('needle'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(renderTasks.length, 6, `${browser}: search-result render did not start`); + renderTasks[5].fail(new Error('search result render failed')); + await assert.rejects(failedSearch, /search result render failed/, + `${browser}: current search-result render failure was swallowed`); + assert.equal(pageRuntime.snapshot().pageNumber, 1, + `${browser}: failed search-result render changed the committed page`); + assert.equal(pageRuntime.snapshot().requestedPageNumber, 1, + `${browser}: failed search-result render left a stale requested page`); + assert.equal(visibleCanvas.marker, 'page 1', + `${browser}: failed search-result render changed the committed PDF pixels`); + } +}); + test('Emergency Box UI and PDF reader stay in Chrome and Firefox parity', () => { const files = [ 'src/agent/apocalypse-mode.js', @@ -22364,6 +22644,7 @@ test('Emergency Box UI and PDF reader stay in Chrome and Firefox parity', () => 'src/ui/emergency-pdf.html', 'src/ui/emergency-pdf.css', 'src/ui/emergency-pdf.js', + 'src/ui/emergency-pdf-search.js', 'src/ui/emergency-communication.html', 'src/ui/emergency-communication.css', 'src/ui/emergency-communication.js',