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”功能,不仅提升了用户体验,更重要的是有效保护了用户的数字隐私。这在追求用户信任和数据安全的小程序生态中,无疑是一个值得推广的实践。

希望这次分享能为你带来启发,共同探索小程序前端能力的更多可能性!



帮小忙工具箱开发
4 声望1 粉丝

从负责 2亿用户的“打工人”,到拥有 500万用户的“独立开发者”。