iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

從零打造網頁簡報編輯器系列 第 24 篇

Day 24|讓含圖片的備份可以完整往返

  • 分享至 

  • xImage
  •  

系列:一張投影片背後的工程:從零打造網頁簡報編輯器

對應程式版本: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 轉成可以放進 JSON 的資料

圖片 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,不是這個暫時網址。

匯入時還原 Blob,再寫回資料庫

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 紀錄已全部涵蓋的項目:

  1. 在測試簡報的第一頁加入中文與一張 PNG 或 JPEG,再複製整頁,讓兩頁引用同一筆圖片資產。等待顯示「已儲存」後,下載備份。
  2. 用文字編輯器開啟備份,確認 assets 只有一筆、含有 dataUrl,而兩頁的圖片物件都引用它的 assetId。
  3. 修改簡報名稱與其中一個物件位置,再匯入剛才的檔案。預期名稱、頁面順序、圖片與位置都回到備份時的狀態。
  4. 在另一個全新的瀏覽器設定檔開啟同一個程式,確認起始為空白簡報,直接匯入備份,不重新上傳圖片。預期兩頁的中文與圖片都能顯示;重新整理後,匯入結果也應保留。

最後一步把原本的 IndexedDB 排除在外,才能確認圖片確實由備份帶過去。PNG 與 JPEG 可各用一份簡報重做這個流程。

這一版仍有大小與解碼限制

Base64 會增加檔案體積。匯入上限是 32 * 1024 * 1024 位元組,但輸出沒有相同的總大小檢查,所以仍可能下載出超過匯入上限的備份;大量圖片也會提高轉換時的記憶體使用量。

此外,格式驗證只確認資產欄位、引用與編碼可以解析,不代表位元組一定是可顯示的 PNG 或 JPEG。v0.4.0 的輸出端也沒有先驗證整份文件;後續版本才補上輸出前的文件檢查,避免已知不合法的文件被下載成備份。完整的大小檢查與圖片解碼驗證仍需要另外設計。

現在有了可以搬走的可編輯檔案,下一篇接著讓同一份文件進入播放模式,並處理只供講者準備使用的備註。


上一篇
Day 23|驗證專案格式並保護既有資料
下一篇
Day 25|讓簡報支援全螢幕、黑畫面與轉場
系列文
從零打造網頁簡報編輯器 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言