博客 / 詳情

返回

告別服務器!小程序純前端“圖片轉 PDF”工具,隱私安全又高效!

1. 背景與痛點:純前端實踐的動力

在開發小程序時,實現如“圖片轉 PDF”這樣的功能時,常常面臨以下挑戰:

  • 隱私擔憂:將圖片上傳到服務器進行轉換,用户擔心圖片內容泄露。對於個人證件、私密照片等敏感內容,這一顧慮尤為突出。
  • 網絡依賴與效率:轉換過程需要頻繁與服務器交互,在弱網環境下速度慢、不穩定,甚至可能因上傳大文件而失敗。
  • 服務器成本:每一次轉換都意味着服務器資源的消耗(存儲、計算、帶寬),對於開發者而言,成本不容忽視。

為了解決這些痛點,我們探索了一個更優的實現路徑:純前端、在小程序本地完成圖片到 PDF 的轉換

2. 核心思路:本地文件系統與 pdf-lib 的巧妙結合

在小程序中實現純前端圖片轉 PDF,我們的核心思路是:

  1. 圖片本地化處理:充分利用小程序強大的本地文件系統能力,將用户選擇的圖片讀取到本地臨時路徑。
  2. PDF 文檔構建:引入功能豐富的 JavaScript 庫 pdf-lib,在小程序運行時直接在前端環境創建和操作 PDF 文件。
  3. 最終文件保存:將 pdf-lib 生成的 PDF 數據流保存為本地文件,供用户直接預覽或分享。

這種方式讓整個轉換過程都在用户的小程序沙箱環境內完成,圖片數據不會離開用户手機,極大保障了數據隱私和安全性,同時顯著提升了轉換效率並降低了服務器成本。

3. 技術核心:pdf-lib 的引入與應用

pdf-lib 是一個強大的純 JavaScript PDF 庫,支持在多種 JavaScript 環境下創建和修改 PDF 文件,完美契合小程序這種前端應用場景。

3.1 庫的引入

你需要將 pdf-lib 的小程序兼容版本(通常是 pdf-lib.min.js)放置在你的項目目錄中,並通過 require 引入:

const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js');
const fs = wx.getFileSystemManager(); // 小程序文件管理器實例

3.2 轉換邏輯概覽

整個圖片轉 PDF 的流程可分解為以下幾個關鍵步驟:

  1. 圖片預處理:獲取每張圖片的尺寸、類型 (wx.getImageInfo),並將其讀取為 Base64 格式 (fs.readFile),這是 pdf-lib 嵌入圖片所需的標準數據格式。
  2. 創建 PDF 文檔:初始化一個空的 PDFDocument 對象。
  3. 逐頁添加圖片:遍歷所有圖片,為每張圖片創建一個新的 PDF 頁面。根據圖片的原始尺寸和類型,將其嵌入到 PDF 中,並進行智能縮放、居中。對於橫向圖片,還會自動旋轉頁面 90 度以更好地適應 A4 紙張。
  4. 生成與保存:將構建好的 PDF 文檔保存為 Base64 編碼的字符串,再通過小程序文件系統的 fs.writeFile 接口,寫入到本地的臨時文件路徑。
  5. 返回結果:將生成的 PDF 文件本地路徑返回給業務層,用於後續的預覽或分享。

4. 核心代碼:img2pdf.js

以下是幫小忙工具箱實現圖片轉 PDF 功能的核心源代碼。

const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js');
const fs = wx.getFileSystemManager()
/**
 * 把圖片轉成pdf
 * @param {Array} urls 圖片url數組
 * @returns {String} pdfUrl pdf文件url
 */
export async function img2pdf(urls) {
    if (typeof urls == 'string') {
        urls = [urls]
    }

    // 圖片信息
    const imageInfo = urls.map((url) => {
        return wx.getImageInfo({
            src: url
        });
    });
    const imageInfoRes = await Promise.all(imageInfo);
    console.log(imageInfoRes);

    // 圖片base64
    const imageBase64 = urls.map((url) => {
        return readFile(url, "base64");
    });
    const imageBase64Res = await Promise.all(imageBase64);
    console.log(imageBase64Res);

    const pdfDoc = await PDFDocument.create();

    for (let i = 0; i < imageInfoRes.length; i++) {
        const {
            type,
            width,
            height
        } = imageInfoRes[i];
        let pdfImage = "";
        if (type === 'jpeg') {
            pdfImage = await pdfDoc.embedJpg(imageBase64Res[i]);
        } else if (type === 'png') {
            pdfImage = await pdfDoc.embedPng(imageBase64Res[i]);
        }

        const page = pdfDoc.addPage(PageSizes.A4);
        const {
            width: pageWidth,
            height: pageHeight
        } = page.getSize(); // 獲取頁面尺寸

        let drawOptions = {};

        // 如果圖片是寬大於高,則旋轉
        if (width > height) {
            // 頁面旋轉後,可用於繪製的"寬度"實際上是原始頁面的高度,"高度"是原始頁面的寬度
            const scaled = pdfImage.scaleToFit(pageHeight, pageWidth); // 注意參數順序因為頁面旋轉了

            drawOptions = {
                // x: scaled.height + (pageWidth - scaled.height) / 2,   // 注意這裏用的是 scaled.height
                x: (pageWidth - scaled.height) / 2,
                y: (pageHeight - scaled.width) / 2 + scaled.width,
                width: scaled.width,
                height: scaled.height,
                rotate: degrees(270),
            };
            console.log('drawOptions', drawOptions);
        } else {
            // 圖片是縱向或方形的
            const scaled = pdfImage.scaleToFit(pageWidth, pageHeight);
            drawOptions = {
                x: (pageWidth - scaled.width) / 2, // 居中 X
                y: (pageHeight - scaled.height) / 2, // 居中 Y
                width: scaled.width,
                height: scaled.height,
            };
        }
        page.drawImage(pdfImage, drawOptions);
    }

    // 3. 獲取 PDF 的 Uint8Array
    const docBase64 = await pdfDoc.saveAsBase64();
    const timestamp = Date.now();
    const pdfPath = await base64ToFile(docBase64, `/${timestamp}.pdf`);


    return pdfPath;
}

/**
 * base64轉本地文件
 * @param {string} base64 base64字符串
 * @param {string} fileName  文件名
 * @returns {Promise} Promise 文件路徑
 */
function base64ToFile(base64, fileName) {
    const {
        promise,
        resolve,
        reject
    } = Promise.withResolvers();
    const filePath = wx.env.USER_DATA_PATH + fileName;
    fs.writeFile({
        filePath,
        data: base64,
        encoding: "base64",
        success: res => {
            resolve(filePath)
        },
        fail: err => {
            reject(err)
        }
    });
    return promise;
}

/**
 * 使用Promise讀取文件
 * @param {string} filePath 文件路徑
 * @param {string} encoding 文件編碼
 * @returns {Promise} Promise對象
 */
function readFile(filePath, encoding = 'utf8') {
    const {
        promise,
        resolve,
        reject
    } = Promise.withResolvers();
    fs.readFile({
        filePath,
        encoding,
        success(fileRes) {
            resolve(fileRes.data)
        },
        fail(err) {
            reject(err)
        }
    });
    return promise;
}

5. 小程序端應用示例

在頁面中,可以通過簡單的交互完成轉換。

// pages/image-to-pdf/index.js
import { img2pdf } from '../../utils/img2pdf'; // 引入轉換工具

Page({
  data: {
    selectedImages: [], // 用户選擇的圖片臨時路徑數組
    pdfPath: '',
    loading: false
  },

  // 觸發圖片選擇
  async chooseImage() {
    const { tempFiles } = await wx.chooseMedia({
      count: 9, // 最多選擇 9 張圖片
      mediaType: ['image'],
      sizeType: ['original', 'compressed'], // 可以選擇原圖或壓縮圖
      sourceType: ['album', 'camera'],
    });
    this.setData({ selectedImages: tempFiles.map(file => file.tempFilePath) });
  },

  // 執行圖片轉 PDF 轉換
  async convertToPdf() {
    if (this.data.selectedImages.length === 0) {
      wx.showToast({ title: '請先選擇圖片', icon: 'none' });
      return;
    }

    this.setData({ loading: true });
    wx.showLoading({ title: '轉換中...' });

    try {
      const pdfFilePath = await img2pdf(this.data.selectedImages);
      this.setData({ pdfPath: pdfFilePath });
      wx.hideLoading();
      wx.showToast({ title: '轉換成功!', icon: 'success' });
      
      // 轉換成功後,自動打開 PDF 預覽
      wx.openDocument({
        filePath: pdfFilePath,
        fileType: 'pdf',
        success: res => console.log('打開 PDF 成功', res),
        fail: err => console.error('打開 PDF 失敗', err)
      });

    } catch (error) {
      wx.hideLoading();
      wx.showToast({ title: '轉換失敗!', icon: 'error' });
      console.error('圖片轉 PDF 發生錯誤', error);
    } finally {
      this.setData({ loading: false });
    }
  }
})

6. 經驗總結與注意事項

  1. 文件體積與性能

    • pdf-lib 庫本身有一定體積(通常在幾百 KB),會增加小程序包體大小,我們是使用分包,所以不影響主包。
    • 圖片數量越多、分辨率越高,轉換耗時越長,內存佔用越大。建議在選擇圖片時提示用户合理數量或適當壓縮。
    • pdf橫向圖片旋轉需要額外計算和處理,可能會略微增加複雜性,如果覺得複雜,也可以直接判斷圖片是否是縱向,如果是橫向使用canvas旋轉圖片,邏輯上就畢竟簡單了。
  2. Promise.withResolvers() 兼容性

    • 代碼使用了 Promise.withResolvers(),目前大多數小程序環境和瀏覽器中兼容性可能不好,我自己做了兼容。
  3. 本地文件系統限制

    • wx.env.USER_DATA_PATH 路徑下的文件是小程序沙箱環境特有的,用户無法直接在系統文件管理器中找到。
    • 生成的文件是臨時文件,小程序關閉或長時間不用可能被系統清理。如果需要長期保存,需引導用户通過 wx.saveFile (保存到相冊或本地文件) 或上傳雲存儲。
  4. 圖片類型支持
    pdf-lib 主要支持 JPEG 和 PNG 格式。其他格式(如 WebP、GIF)需要先轉換為 JPEG/PNG 再進行嵌入,可以利用canvas實現,後面會分享。

寫在最後

純前端實現“圖片轉 PDF”功能,不僅提升了用户體驗,更重要的是有效保護了用户的數字隱私。這在追求用户信任和數據安全的小程序生態中,無疑是一個值得推廣的實踐。

希望這次分享能為你帶來啓發,共同探索小程序前端能力的更多可能性!


user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.