跳至主要内容

REP-07:大型 PDF 按需預覽契約設計

狀態:已通過設計審閱(3 輪,2026-09-30)與程式碼審閱(2 輪,2026-09-30);已實作。實作細節見「實作註記」,程式碼審閱的裁決見「程式碼審閱裁決」 依據:ADR-029(第 5 點:以支援 byte-range 的受權 URL 交給 PDF.js,只渲染可視頁並以有界快取回收)、報表產生開發計畫 REP-07、REP-06 Ranged read 契約、appfuse-web PdfViewer 與 ReportViewer README 日期:2026-09-30

範圍​

納入(appfuse-web)不納入(所屬工作包)
PdfViewer 的載入:受權的 API/同源 URL 以 HTTP Range 分段讀取;不支援 Range 的回應退回完整下載選頁 subset PDF 與 ReportViewer 的列印 adapter(REP-08)
以 HttpClient 的攔截器處理 Bearer、主動 refresh、401 重試與 cookie;取消、重試、文件替換、安全卸載伺服器端的 Range 語意(REP-06 已完成)
連續模式的 DOM windowing、有界的已渲染頁數與 page proxy 快取、縮圖側欄 windowing、漸進式頁面尺寸跨頁搜尋、全文索引、PDF 標註
漸進、可取消的搜尋MediaViewer 的 PdfItemViewer 畫面(只受惠於新的載入層,不改畫面)
載入進度、錯誤、重試與 fallback 下載;修正下載與列印動作參考應用的報表畫面(FU-103)

已確定的方向​

以下四項已由框架維護者於 2026-09-30 決定,本文據此設計:

  1. 經 HttpClient 的 range transport。 實作 PDF.js 的 PDFDataRangeTransport,每個區段以 axios 送 Range 請求, 沿用既有攔截器(Bearer、主動 refresh、401 重試、withCredentials),可用 AbortSignal 取消;不公開 token 取得方式, 不依賴 PDF.js 內建網路層的 header 快照。
  2. 搜尋漸進、可取消。 逐頁讀取文字時即公布已找到的結果與進度;改變搜尋字或關閉搜尋列即取消。搜尋必然逐步下載全檔, 文件說明此成本。
  3. REP-07 順手修正列印。 現行 window.open(url, '_blank', 'noopener,noreferrer') 回傳 null,print() 從未執行; 改以隱藏 iframe 列印並以瀏覽器測試確認。
  4. 測試資料動態產生。 Playwright 以 page.route 模擬支援 Range 的伺服器,測試時產生數百頁的 PDF,不把大檔 commit 進 repo; 可精確記錄每個 range 請求。

設計原則​

  1. 第一頁不等全檔。 受權 URL 的載入只讀取 PDF.js 實際需要的區段(檔頭、xref、目前頁的物件)。
  2. 記憶體與 DOM 有上限。 已渲染的 canvas、縮圖、DOM 頁面節點,以及保留渲染資源的頁數都有上限,與文件頁數無關。 PDF.js 內部保留的 page proxy 物件、worker 已下載的原始位元組與 worker 快取不在框架控制內(見「Page proxy 快取」)。
  3. 不留下失控的工作。 來源變更或卸載時,載入、區段請求、render task 與 text layer 全部取消;工作被取代時(捲動、縮放、 跳頁)取消對應的 render task。搜尋是例外:文字抽取無法中途取消,改以單一佇列保證任何時刻最多一頁在進行(見「搜尋」)。
  4. 授權沿用 HttpClient。 元件不讀取 token;401、refresh 與 cookie 的行為與其他 API 呼叫一致。
  5. 退化要明確。 伺服器不支援 Range(200 完整內容、Accept-Ranges: none)時照常顯示,只是失去按需讀取。

載入​

來源分類​

來源載入方式
File、FileProxy、blob:、data: URL照舊:本機資料交給 PDF.js(不經網路)
API/同源 URL(needsHttpClientFetch 為真)HttpRangeTransport(下節)
外部 URL(非 API)PDF.js 內建網路層 getDocument({ url });不帶授權,伺服器支援時 PDF.js 自行使用 Range
沒有 HttpClientProvider 而需要 HttpClient維持現行錯誤:PdfViewer: HttpClientProvider is required…

文件以正規化後的來源鍵(字串 URL、FileDescriptor.url、File 或 FileProxy 物件)識別;同一來源鍵的新物件不重新載入 (修正 FileDescriptor 每次 render 都是新物件而重新載入)。

HttpRangeTransport​

// 內部模組;不公開
class HttpRangeTransport extends PDFDataRangeTransport {
static open(httpClient, url, { signal, chunkSize }): Promise<RangeProbe>
requestDataRange(begin: number, end: number): void
abort(): void
}

type RangeProbe =
| { kind: 'ranged'; transport: HttpRangeTransport } // 206:分段讀取
| { kind: 'full'; data: Uint8Array } // 200:伺服器忽略 Range,已取得完整內容
步驟內容
探測送一個 GET,Range: bytes=0-{chunkSize-1},responseType: 'arraybuffer'。206 → 依下方「回應驗證」取得總長度,第一段作為 initialData,記下驗證器;200 → 伺服器不支援或忽略 Range,回應本身就是完整內容,改以 getDocument({ data }) 載入;416 → 空檔案,顯示錯誤。只用一個請求,不另送 HEAD(下載端點不一定對應 HEAD,也省一次往返)
區段requestDataRange(begin, end) 送 Range: bytes={begin}-{end-1},有強 ETag 時加 If-Range: {ETag};回應通過驗證才 onDataRange(begin, bytes)
內容變更區段請求得到 200(If-Range 不符,檔案被替換)或總長與探測不同 → 整份文件失敗,錯誤碼 pdf.document-changed,提供重新載入
授權每個請求經 HttpClient:攔截器加 Bearer、必要時先 refresh、401 時 refresh 後重試一次;withCredentials 依 client 設定
暫時性失敗網路錯誤與 5xx 以退避重試 2 次(300 ms、1 s);仍失敗則整份文件失敗並提供重試
進度分段讀取時不呼叫 onDataProgress(5.7.284 已是只輸出棄用提示的空方法);PDF.js 由 PDFDataTransportStream 依 onDataRange 交付的位元組自動經 loadingTask.onProgress({ loaded, total }) 回報,viewer 讀取該事件。完整下載(探測回 200)時進度來自 axios 的 onDownloadProgress,有 Content-Length 時為確定型
取消abort() 取消所有進行中的請求;元件卸載或來源變更時呼叫,並 loadingTask.destroy()
區段大小chunkSize 預設 256 KiB(PDF.js 預設 64 KiB 會對大檔產生過多請求);getDocument 設 rangeChunkSize: chunkSize、disableAutoFetch: true、disableStream: true,只讀取需要的區段

回應驗證​

回應必須符合不符時
探測 206Content-Range 為 bytes {first}-{last}/{total},first 為 0、total 大於 0、last 等於 min(chunkSize, total) - 1,body 的 byteLength 等於 last - first + 1文件失敗,錯誤碼 pdf.invalid-range-response(伺服器回應不一致;不猜測、不送入 PDF.js)
探測 200Content-Encoding 可讀且為空或 identity、且有 Content-Length 時,兩者相同;有其他編碼(gzip、br 等:Content-Length 是編碼後長度,瀏覽器交給 axios 的 body 已解碼)或 header 讀不到(跨來源未 expose)時不比較長度,完整性交由連線本身與 PDF.js 解析(截斷的檔案會解析失敗)文件失敗,pdf.incomplete-response
區段 206 的編碼位元組範圍以未編碼的表示為準:Content-Encoding 可讀且不是空或 identity 時不可用;讀不到時由下方的 byteLength 檢查攔截(解碼後的長度與範圍不符)pdf.invalid-range-response
區段 206Content-Range 的 first 等於 begin、last 等於 min(end, total) - 1、total 等於探測的總長,byteLength 等於 last - first + 1總長不同 → pdf.document-changed;其餘(缺少或無法解析 Content-Range、起訖不符、body 過短)→ pdf.invalid-range-response
區段 200—帶 If-Range 時為內容變更(pdf.document-changed);未帶時表示伺服器不再回 Range,文件失敗(pdf.invalid-range-response),重試時重新探測、改走完整下載

探測 206 同樣適用上列編碼規則。Content-Range 在跨來源時需 expose(REP-06 的 RANGE_EXPOSED_HEADERS 已包含);讀不到時 無法解析,依上表為 pdf.invalid-range-response,錯誤細節提示檢查 CORS 的 exposed headers。瀏覽器不允許腳本設定 Accept-Encoding,因此不以請求 header 要求 identity。

驗證器:探測回應帶強 ETag(不以 W/ 開頭)時,後續區段送 If-Range,伺服器以此偵測內容替換。弱 ETag 或沒有 ETag 時不送 If-Range,只能以總長偵測替換;同長度的內容替換無法偵測。REP-06 的 FileResponseBuilder 以 write-once 的 fileId 作強 ETag, 框架端點不受此限。

PDF.js 的 PDFDataRangeTransport 沒有錯誤回呼:區段請求失敗時由 transport 通知 viewer 進入錯誤狀態並銷毀 loading task, 避免 PDF.js 永遠等待該區段。

文件替換與卸載​

事件行為
來源鍵變更取消舊 transport 與 loading task、銷毀舊文件、清空所有快取,再載入新文件;舊文件的 render task 在 effect cleanup 取消
卸載同上;卸載後不再 setState,也不留下進行中的 axios 請求
錯誤後重試重新探測與載入,還原目前頁碼與縮放

渲染與快取​

Page proxy 快取​

每份文件一個 PdfPageCache,render、text layer、尺寸、縮圖與搜尋共用,合併同頁的並行 getPage。

PDF.js 本身在 WorkerTransport 以頁碼保留每個取過的 PDFPageProxy,直到文件銷毀;page.cleanup() 釋放的是該頁在主執行緒的 渲染資源(operator list、解碼後的圖片與字型物件),不會移除 proxy 物件。因此框架能控制的是「保留渲染資源的頁數」,不是 proxy 物件數:

狀態定義上限
使用中(pinned)目前有掛載的主頁面或縮圖正在使用(引用計數大於 0)由 renderedPages 與 renderedThumbnails 決定,不受 retainedPages 影響
閒置保留引用計數歸 0,但暫不 cleanup(),以便捲回時不必重建retainedPages(LRU);超過時淘汰最久未用者並呼叫 page.cleanup()

保留渲染資源的頁數因此最多為 renderedPages + renderedThumbnails + retainedPages。cleanup() 在該頁仍有進行中的 render 時由 PDF.js 延後到完成後執行(#pendingCleanup)。

不受此上限控制、如實說明於 README:PDF.js 保留的 proxy 物件(每頁一個小物件)、worker 端已下載的原始位元組(ChunkedStream 不淘汰,瀏覽越多頁、保留越多,最多為檔案大小)與 worker 的字型/圖片快取。

連續模式的 windowing​

項目規則
頁面尺寸開啟時只取第 1 頁尺寸;尚未量測的頁以已知尺寸(最近一次量測或第 1 頁)估計,取得 page proxy 時更新。不再於開啟時逐頁取得所有尺寸
位置以各頁高度的前綴和計算每頁的位移;上下各以一個 spacer 撐出總高度
DOM只掛載視窗內與前後緩衝區(預設各 1 個視窗高)的頁面節點,數量上限 renderedPages
canvas只有掛載的頁面持有 canvas;卸載時取消 render task、把 canvas 寬高設為 0 釋放點陣
捲動錨定視窗上方頁面的實際高度與估計不同時,同步調整 scrollTop 抵銷差值,畫面不跳動(不依賴 Safari 不支援的 overflow-anchor)
目前頁由捲動位置與位移表計算,不再依 IntersectionObserver 的比例
跳頁以位移表計算目標位置;目標頁尺寸未知時先以估計捲動,量測後修正
縮放、旋轉位移表依比例重算,只重新渲染掛載的頁面;取消進行中的 render task

單頁模式本來就只渲染一頁,改用共用的 page proxy 快取與尺寸量測。

縮圖側欄​

同樣以 windowing 只掛載可見的縮圖(上限 renderedThumbnails),縮圖以同一個 page proxy 快取取得頁面;離開視窗即取消與釋放。

上限(可設定)​

interface PdfViewerProps {
// ...既有 props
/** 記憶體上限;未指定的項目使用預設值 */
memoryLimits?: {
renderedPages?: number // 預設 6:同時持有 canvas 的頁面
retainedPages?: number // 預設 16:閒置但保留渲染資源的頁(LRU);使用中的頁另計
renderedThumbnails?: number // 預設 24
}
}

搜尋​

項目規則
順序由目前頁往後、再從頭到目前頁前讀取文字;每頁完成即公布該頁的命中與進度(已搜尋 n/N 頁)
單一佇列每份文件一個跨搜尋世代共用的文字抽取佇列,任何時刻最多一個 getTextContent() 在進行;該呼叫的 promise 在 worker 送完內容、關閉串流時才 resolve,因此「promise 未結束」等於「worker 仍在抽取」。不使用 reader.cancel():取消串流的確認不會等待 worker 結束,若依此開始下一頁,快速換字會在 worker 累積多頁工作
更換搜尋字、關閉搜尋列搜尋世代編號加一;進行中的那一頁讓它讀完、結果依世代編號丟棄;讀完後佇列才為最新世代排下一頁(中間被取代的世代不會排任何頁)。關閉搜尋列時不再排頁
文件替換、卸載佇列停止排程;loadingTask.destroy() 終止 worker 端的工作,未完成的 promise 以拒絕結束並被忽略
共用請求文字抽取觸發的區段請求由共用 transport 服務、同時滿足可視頁的需求;搜尋的取消不中止 transport 或任何區段請求,只有文件替換與卸載才 abort()
快取只保留每頁的命中位置,不保留全文;文字內容取得後即釋放
成本完成一次全文搜尋等於讀取全部頁面的內容;README 說明大型文件的搜尋會逐步下載全檔

下載、列印、進度與錯誤​

項目行為
下載本機來源照舊以 object URL 下載;受權 URL 以 FileDownloader(經 HttpClient 的完整下載)下載,檔名取自 Content-Disposition 或 alt
列印取得完整內容(本機來源直接使用;受權 URL 經 HttpClient 完整下載)→ blob URL → 隱藏 iframe 載入後 contentWindow.print();afterprint 或逾時後移除 iframe 並釋放 URL。大型文件的選頁列印屬 REP-08
進度探測回 200(完整下載)時顯示確定型進度(已下載/總大小);分段讀取時初次載入只需少量區段,顯示 spinner
錯誤錯誤面板顯示可理解的訊息(不直接顯示 error.message 原文於主要文字,細節置於次要文字)、「重試」與「下載檔案」 fallback 動作;onError 照舊回報
單頁失敗單頁 render 失敗維持該頁的錯誤覆蓋,不影響其他頁

與 ReportViewer、MediaViewer 的關係​

  • ReportViewer 以 artifact.id 為 key 掛載 PdfViewer,文件替換沿用;已保存報表的下載 URL 經 REP-06 的 FileResponseBuilder.request(...) 提供 Range。README 補充建議的端點寫法。
  • PdfItemViewer(MediaViewer 內)使用內部 usePdfDocument,自動使用新的載入層;其單頁畫面不改。

相容性​

變更影響
受權 URL 改為分段讀取行為變更:伺服器需支援 Range(REP-06 的 FileResponseBuilder 已支援);不支援時自動退回完整下載,功能不受影響
連續模式不再預先取得所有頁面尺寸各頁尺寸不同的文件在量測前以估計尺寸占位,量測後修正並錨定捲動
memoryLimits新增 optional prop
列印修正(原本不會開啟列印對話框)

CHANGELOG(appfuse-web):Added memoryLimits;Changed 載入與連續模式;Fixed 列印、未取消的 loading task、README 與實作不符的 lazy render 描述。

驗收方式(對應計畫的完成條件)​

完成條件驗證方式
第一頁在完整 PDF 下載前顯示,可觀察到 206 與分段請求Playwright(tests-storybook):page.route 以測試時產生的數百頁、數十 MB 的 PDF 回應 Range;第一頁 canvas 出現時,已傳輸位元組遠小於檔案大小,所有回應為 206、請求帶 Range
伺服器不支援 Range同一測試改回 200 完整內容:照常顯示,只有一個請求
授權Story 自帶 auth 的 HttpClient:區段請求帶 Bearer;過期 token 的請求得到 401 後 refresh 並以新 token 重試成功
連續瀏覽大量頁面後 DOM/canvas/page cache 維持上限Playwright:捲過 300 頁後,頁面節點數與 canvas 數不超過 renderedPages、縮圖 canvas 不超過 renderedThumbnails;Vitest:PdfPageCache 中閒置保留的頁不超過 retainedPages、被淘汰者呼叫過 cleanup()、使用中的頁不被淘汰且不計入 retainedPages。PDF.js 內部的 proxy 物件與 worker 原始位元組不在驗收範圍(見「Page proxy 快取」)
zoom、跳頁、搜尋、快速捲動不留下未取消的 render task;可安全卸載Vitest(mock PDF.js):每個 render() 都有對應的完成或 cancel();卸載時 transport abort()、loadingTask.destroy();Playwright:快速捲動與卸載後無 console error、無進行中的 route 請求
transportVitest:探測 206/200/416、If-Range、內容變更、Content-Range 不符、退避重試、取消、進度
搜尋Vitest(mock PDF.js,getTextContent() 由測試手動 resolve,模擬 worker 仍在抽取):漸進公布;在第一頁的 promise 未 resolve 前連續更換搜尋字 10 次,getTextContent() 的呼叫次數維持 1;resolve 後下一個呼叫屬於最後的搜尋字,且中間 9 個世代從未排頁;最後公布的結果只屬於最後的搜尋字;關閉搜尋列後讀完當頁即停止;搜尋的取消不呼叫 transport 的 abort()
回應驗證Vitest:探測與區段的 Content-Range 缺少/無法解析/起訖不符/總長不符、body 過短、200 帶與不帶 If-Range、弱 ETag 與沒有 ETag 時不送 If-Range
壓縮回應Vitest(mock HttpClient 回應):探測 200 帶 Content-Encoding: gzip 且 Content-Length 小於解碼後 body 時照常載入;Content-Encoding 讀不到時不比較長度;206 帶 Content-Encoding: gzip 為 pdf.invalid-range-response;206 讀不到 Content-Encoding 但長度不符時同樣失敗
進度Vitest:分段讀取時以 loadingTask.onProgress 更新、不呼叫 onDataProgress;完整下載時以 axios 進度顯示確定型進度
列印Playwright:點擊列印後建立 iframe 並呼叫其 print()(以 init script 攔截)

已裁決的決策​

以下 4 項於設計審閱(2026-09-30,3 輪)由審閱者裁決,皆採作者方案。

#議題裁決捨棄的方案
1探測方式一個帶 Range 的 GET 同時判斷支援度並取得第一段先 HEAD 再分段(多一次往返,且端點需對應 HEAD)
2上限的預設值與是否公開公開 memoryLimits,預設 renderedPages: 6、retainedPages: 16、renderedThumbnails: 24;閒置保留與使用中的頁分別計算只用內部常數
3區段大小256 KiBPDF.js 預設 64 KiB;或 1 MiB
4受權 URL 的下載FileDownloader 經 HttpClient 完整下載pdfDocument.getData()(會讀完所有區段,在記憶體組出整份)

設計審閱中補充的規則​

輪次議題結果
R1-01page proxy 上限無法保證上限改為「保留渲染資源的頁」:使用中另計、閒置以 retainedPages LRU;PDF.js 內部 proxy、worker 原始位元組與快取明列不受控
R1-02getTextContent() 無法取消見 R2-01 的最終規則
R1-03區段回應驗證不足「回應驗證」表:起訖、總長、byteLength;只有強 ETag 才送 If-Range,弱或缺少 ETag 時同長度替換無法偵測
R1-04onDataProgress 已無作用進度改用 loadingTask.onProgress(分段)與 axios 下載進度(完整下載)
R2-01取消串流不等於 worker 結束跨搜尋世代共用的單一文字抽取佇列,最多一頁在進行;不使用 reader.cancel()
R2-02壓縮回應的長度比較只在未編碼且 header 可讀時比較 Content-Length;206 的編碼不可用;讀不到 header 時的策略
R2-03設計原則與細節矛盾原則 2、3 改寫,與快取及搜尋的邊界一致

程式碼審閱裁決​

程式碼審閱(2026-09-30,2 輪)的意見與裁決:

#議題裁決
R1-01關閉搜尋列時連同文字抽取佇列一起銷毀,重新開啟的新佇列可在舊的抽取完成前開始下一頁,違反「最多一頁在抽取」修正:佇列隨文件存續,usePdfSearch 以 enabled 表示搜尋列開關,關閉只停止排程並清空結果
R1-02尚未 resolve 就被淘汰的頁,同頁在 resolve 前被重新取用時,舊的延後清理仍清理使用中的 page proxy修正:延後清理在執行時核對同頁是否已由新的取用管理,是則不清理
R1-03側欄自行捲離目前頁、可見範圍已達上限時,額外掛載目前頁造成縮圖數為上限+1修正:從遠離目前頁的一端讓出名額,總數不超過 renderedThumbnails
1第一頁讀取量的驗收門檻接受「< 全檔 25%」:為指定 fixture(300 頁、每頁約 100 KB、平衡頁面樹)的回歸門檻,涵蓋開檔、第一頁、continuous 鄰近頁與前幾張縮圖;不作為一般文件的效能保證
2瀏覽器沒有內建 PDF 檢視器時的列印接受改為下載檔案:iframe 不會觸發 load,下載後由使用者的檢視器列印;iframe 30 秒未載入即清理並回報失敗
3探測階段的進度型態接受:只有總長超過一段(必為 200 完整下載)才顯示確定型進度條;小於一段的 200 與分段讀取顯示 spinner
4扁平頁面樹會讀完全檔接受只在 PdfViewer README 的限制表說明:框架報表 renderer 的輸出不受影響,不另作處理

實作註記​

項目內容
開檔讀取量與頁面樹PDF.js 開檔時的 checkLastPage 會確認最後一頁存在;頁面樹為扁平的 /Kids、且 page dict 與內容串流在檔案中交錯時,必須逐一讀取每個 page dict,等於讀完全檔。平衡頁面樹、或 page dict 集中在物件串流時不受影響。實測 appfuse-server PdfBoxRenderer 輸出的 945 頁、3.9 MB 報表(物件串流+XRef stream):開檔 1 個區段請求,顯示第一頁共 1–2 個,約讀取全檔的 5–12%。示範與測試用的產生器因此以平衡頁面樹(每 10 頁一個中間節點,集中在檔頭)產生
第一頁的讀取量門檻Playwright 以「第一頁顯示時已傳輸的位元組 < 全檔 25%」判定;300 頁、每頁約 100 KB 的測試檔在開檔、第一頁、continuous 的鄰近頁與前幾張縮圖後約為此量級。設計只要求「遠小於檔案大小」
進度分段讀取的已讀取/總大小不代表開檔進度(開檔只需少量區段),hook 仍以 loadingTask.onProgress 更新但標為非確定型,UI 顯示 spinner;探測回 200 且總長超過一段時(206 不會超過一段)才顯示確定型進度條。外部 URL 由 PDF.js 自身的網路層載入,同樣標為非確定型
列印與內建 PDF 檢視器navigator.pdfViewerEnabled === false(Playwright 預設的 headless shell、或使用者設定為「下載 PDF」)時,iframe 會變成下載而永遠不觸發 load;此時直接下載檔案,交由使用者的檢視器列印。iframe 在 30 秒內未載入時清理並回報失敗,不留下 iframe 與 blob URL
列印的測試有內建檢視器的路徑在 tests-storybook/pdf-viewer-print.spec.ts 以 channel: 'chromium'(新版 headless,具 PDF 檢視器)執行;test.use({ channel }) 只能在檔案層級設定,因此獨立成檔。沒有檢視器時的下載路徑在 range spec 內驗證
Storybook 示範.storybook/main.ts 的 dev middleware 於執行時產生 300 頁的 PDF 並依 Range/If-Range 回應(/range-demo/large-report.pdf),不 commit 大檔;LargeDocument 與 LargeDocumentAuthenticated(過期 token → refresh)兩個 story。Playwright 以 page.route 使用同一個產生器
適頁縮放continuous 模式以第一頁尺寸計算適頁寬度/適頁與未量測頁的估計;single 模式以目前頁尺寸計算
來源鍵FileDescriptor 以 url 識別;同 URL 的新物件不重新載入,修正每次 render 重建描述物件時反覆載入
搜尋與 transport文字抽取佇列不持有 transport,結構上無法 abort() 區段請求;只有文件替換與卸載由 usePdfDocument 取消
README修正「連續模式只渲染視窗 ±800px」與實作不符的描述,並補「大型文件按需預覽」「下載與列印」兩節;ReportViewer README 補建議的下載端點寫法
搜尋列關閉與重新開啟文字抽取佇列隨文件存續:usePdfSearch 以 enabled 表示搜尋列開關,關閉只停止排程並清空結果,重新開啟仍等待進行中的抽取完成。若關閉時連同佇列銷毀,重新開啟的新佇列會與尚未完成的抽取並行(程式碼審閱 R1-01)
頁快取的延後清理尚未 resolve 就被淘汰的頁,清理延後到取得頁面時;期間同頁若被重新取用(PDF.js 回傳同一個 page proxy),延後的清理不執行,資源歸新的取用管理(程式碼審閱 R1-02)
縮圖上限含目前頁側欄自行捲離目前頁時,目前頁仍須掛載(aria-activedescendant 指向它);可見範圍已用滿上限時,從遠離目前頁的一端讓出名額,總數不超過 renderedThumbnails(程式碼審閱 R1-03)
測試Vitest:RangeFetcher(26)、PdfPageCache(6)、TextSearchQueue(7)、page-layout(8)、usePdfDocument(9)、usePdfPage(2)、usePdfSearch(1)、PdfSidebar(7)與既有元件測試;Playwright(tests-storybook):第一頁在全檔下載前顯示且皆為 206、捲過 300 頁的節點/canvas/縮圖上限、卸載取消進行中的請求且無 console error、200 fallback、Bearer 與 401 refresh、兩種列印路徑

相關文檔​