系列:一張投影片背後的工程:從零打造網頁簡報編輯器
對應程式版本:Git tag
v0.4.0版本說明:本文接續 Day 23 的格式驗證,說明文件與圖片如何封裝、下載並還原。測試描述以這個 tag 的實作為準,後續版本的補強另行標示。
假設我在第一張投影片放了一張圖片,複製到第二張,再把簡報下載給另一台電腦。另一台電腦沒有原本的 IndexedDB,檔案裡若只有 assetId,它就無法顯示圖片。
Day 22 解決同一個瀏覽器中的保存,Day 23 擋住不合法的匯入資料。今天要完成的是一趟可攜的往返:把文件需要的圖片一起帶走,再從檔案還原成可編輯的簡報。
這一版的備份是自訂 JSON 格式,核心型別如下:
interface SerializedAsset extends Omit<AssetRecord, 'blob'> {
dataUrl: string
}
interface ProjectBackup {
backupVersion: 1
document: PresentationDocument
assets: SerializedAsset[]
}
document 保留投影片順序、文字、圖片位置、裁切設定與講者備註等資料。assets 保留圖片的 ID、MIME、檔名、尺寸、建立時間,以及轉成文字的圖片內容。選取狀態、畫布縮放與復原紀錄屬於編輯工作階段,不會放進備份。
輸出前會走訪文件,收集所有圖片物件的 assetId,再用 Set 去除重複 ID。同一筆資產被兩張投影片引用,備份只需要放一份;但把同一張原始圖片匯入兩次會建立不同資產 ID,這裡沒有依圖片內容去重。
收集範圍包含所有投影片,即使某頁被標記為播放略過也一樣。備份需要保留之後可以繼續修改的內容,因此不能拿播放用的頁面清單來決定要帶走哪些圖片。
圖片 Blob 的二進位內容需要明確轉換。這一版先讀取位元組,再編碼成帶有 MIME 前綴的 Base64 Data URL:
const bytes = new Uint8Array(await blob.arrayBuffer())
let binary = ''
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
}
return `data:${mimeType};base64,${btoa(binary)}`
迴圈一次處理 0x8000 個位元組,避免把整張大圖的位元組一次展開成函式參數。這仍然會在記憶體中建立完整字串,並不是串流輸出。
這裡的 btoa() 接收的是每個字元代表一個位元組的字串,不能直接拿來編碼任意中文字串。簡報中的中文仍由 JSON 保存,圖片才經過這段二進位轉換。MDN 的 btoa() 說明也特別區分了二進位字串與 Unicode 文字。
createProjectBackup() 會用 Promise.all() 等所有被引用的資產讀取及轉換完成,再產生 JSON。任何一筆資產找不到就丟出錯誤,下載流程不會繼續產生一份已知缺圖的檔案。
介面接著把 JSON 包成 application/json 的 Blob,建立暫時的 Object URL,再透過 <a> 的 download 屬性下載成 .slide-project.json 檔案。觸發下載後會排程釋放網址;檔案裡保存的是 JSON 與圖片的 Data URL,不是這個暫時網址。
Day 23 的解析器會先檢查備份版本與文件格式,再逐筆檢查資產欄位及重複 ID。每筆圖片的 Data URL 前綴必須與宣告的 MIME 一致,Base64 內容也必須能由 atob() 解碼。取得二進位字串後,再轉回 Uint8Array 與 Blob:
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return new Blob([bytes], { type: mimeType })
所有 Blob 還原後,解析器才比對資產 ID 集合與文件引用是否完全相符,缺少或多出資產都會拒絕。這些步驟都只在記憶體中處理,尚未寫入 IndexedDB。
完整專案解析成功後,介面把 saveProject() 排進既有儲存佇列,等待涵蓋文件與圖片的 transaction 完成,才用 replace() 切換畫面。物件 ID 和資產 ID 沿用備份中的值,因此圖片物件仍能找到它引用的 Blob。
真正渲染時,AssetImage 才從資料庫讀出 Blob,建立新的 Object URL。這個顯示用網址沒有被存進備份;元件清理時會釋放它。Blob 與 Object URL 的用法可對照 MDN:Blob。
匯入成功會清空原本的復原紀錄。測試這個流程時,先下載目前簡報的備份,再使用另外一份測試簡報操作。
v0.4.0 的 projectBackup.test.ts 會建立含圖片的文件,寫入資產,然後執行:
const restored = parseProjectBackup(await createProjectBackup(document))
expect(restored.document).toEqual(document)
expect(restored.assets[0]).toMatchObject({
id: 'asset-1',
fileName: 'chart.png',
})
expect(restored.assets[0].blob.size).toBe(8)
這個測試的 Blob 內容是 'png-data',只是 8 個位元組的固定資料。它能驗證文件、資產 ID、檔名及 Blob 大小經過序列化後仍符合預期,沒有逐欄比對其餘資產資料,也沒有驗證真實 PNG 解碼或逐位元組比較圖片內容。
因此還需要瀏覽器流程。專案內的 V4 Edge 驗收紀錄(docs/validation/2026-09-12-v4-edge-check.md)包含真實圖片、下載備份、修改簡報名稱、重新匯入,以及檢查名稱、講者備註與圖片載入。這補上了單元測試沒有處理的瀏覽器檔案與圖像呈現。
不過,這段 Edge 流程是在同一個瀏覽器設定檔內重新匯入,沒有先移除原本的圖片資產。因此它不能單獨證明「另一台沒有原始資料的電腦也能還原圖片」;要驗證這一點,還需要在獨立的瀏覽器儲存環境匯入。
讀者可在獨立的 v0.4.0 副本執行以下測試;目前版本也保留這兩個測試檔,但案例數會隨功能增加:
npm test -- src/storage/projectBackup.test.ts src/storage/presentationDb.test.ts
若要手動重現,可依照以下步驟操作;這是驗證方法與預期結果,並非上述 Edge 紀錄已全部涵蓋的項目:
assets 只有一筆、含有 dataUrl,而兩頁的圖片物件都引用它的 assetId。最後一步把原本的 IndexedDB 排除在外,才能確認圖片確實由備份帶過去。PNG 與 JPEG 可各用一份簡報重做這個流程。
Base64 會增加檔案體積。匯入上限是 32 * 1024 * 1024 位元組,但輸出沒有相同的總大小檢查,所以仍可能下載出超過匯入上限的備份;大量圖片也會提高轉換時的記憶體使用量。
此外,格式驗證只確認資產欄位、引用與編碼可以解析,不代表位元組一定是可顯示的 PNG 或 JPEG。v0.4.0 的輸出端也沒有先驗證整份文件;後續版本才補上輸出前的文件檢查,避免已知不合法的文件被下載成備份。完整的大小檢查與圖片解碼驗證仍需要另外設計。
現在有了可以搬走的可編輯檔案,下一篇接著讓同一份文件進入播放模式,並處理只供講者準備使用的備註。