PDF处理工作流完全指南:合并、分割、压缩与转换
让我对 PDF 又爱又恨的那次,是一份 80MB 的扫描合同——客户用手机拍了 40 页纸,导出成 PDF,然后理直气壮地发到我邮箱,结果附件超限退回来三次。我先想压缩,发现里面全是图片,pdf-lib 那套对象流压缩根本没用;又想提取文字归档,PDF.js 跑完返回的是一堆空字符串,因为压根没有文字层,全是图。那天我才真正意识到:PDF 这东西,看着是「文档」,本质可能是「一沓图片穿了件 PDF 的外套」。
还有更离谱的。有次月底要把 30 张供应商发票合并成一个文件交给财务,我老老实实在 Acrobat 里一张张拖,拖到第十几张手都酸了,才反应过来这种活儿就该写脚本。后来又遇到客户发来一份「加密 PDF」却忘了告诉密码,打回去问,对方回一句「没设密码啊」——其实是权限加密(owner password),能打开但不能复制不能打印,跟用户密码完全两码事。
这些坑我基本都踩过一遍,所以这篇就按我自己的工作流来讲:合并、分割、压缩、转换、加密,哪些浏览器端能搞定,哪些得老老实实上服务端,以及那些文档里不会写、踩了才知道的细节。
目录
先看这张踩坑表
正式动手前,先把我吃过亏的几个场景列出来。下面这些是真实卡过我的点,比任何「最佳实践」清单都管用:
| 场景 | 我一般用什么 | 踩过的坑 |
|---|---|---|
| 合并多个 PDF | pdf-lib copyPages | 页面顺序按文件名排序,不按你拖入的顺序,记得先 sort |
| 拆分/提页 | pdf-lib copyPages 指定索引 | 页码是从 0 开始的,跟用户看到的「第 1 页」差一位,被坑过好几次 |
| 压缩 | 看是不是图片占大头 | 纯文字 PDF 怎么压都没用,图片 PDF 才需要降采样,浏览器端基本无能为力 |
| 提取文字 | PDF.js getTextContent | 扫描件没有文字层,返回空字符串,得先 OCR |
| 加密/解密 | 服务端 Ghostscript / qpdf | 用户密码和权限密码是两回事,客户说「没密码」多半指前者 |
| 转图片 | PDF.js 渲染到 canvas | scale 给小了字会糊,建议至少 2 倍,A4 出来差不多 1240px 宽 |
PDF基础知识
PDF的特点
优势:
- ✅ 跨平台兼容性好
- ✅ 保持原始排版
- ✅ 支持加密和签名
- ✅ 文件大小可控
- ✅ 可嵌入字体和图片
劣势:
- ❌ 编辑不如Word方便
- ❌ 文件可能较大
- ❌ 复制文字可能格式混乱
PDF文件结构
PDF文件结构
├── Header(文件头)- PDF版本信息
├── Body(主体)
│ ├── 页面对象
│ ├── 内容流
│ ├── 字体
│ ├── 图片
│ └── 其他资源
├── Cross-Reference Table(交叉引用表)
└── Trailer(文件尾)
PDF版本
// 常见PDF版本
const pdfVersions = {
1.3: 'Acrobat 4', // 1999年
1.4: 'Acrobat 5', // 2001年,支持透明度
1.5: 'Acrobat 6', // 2003年,支持压缩
1.6: 'Acrobat 7', // 2005年
1.7: 'Acrobat 8+', // 2006年,最常用
'2.0': 'ISO 32000-2', // 2017年,最新标准
};
浏览器端PDF处理
使用PDF.js渲染PDF
// PDF.js是Mozilla开发的开源PDF渲染库
import * as pdfjsLib from 'pdfjs-dist';
// 配置Worker
pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdf.worker.js';
async function renderPDF(url, container) {
// 加载PDF文档
const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;
console.log(`PDF加载完成,共${pdf.numPages}页`);
// 渲染第一页
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
// 创建canvas
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
// 渲染
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
container.appendChild(canvas);
}
// 使用
renderPDF('/sample.pdf', document.getElementById('viewer'));
提取PDF文本
async function extractText(url) {
const pdf = await pdfjsLib.getDocument(url).promise;
const textContent = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
const pageText = content.items.map((item) => item.str).join(' ');
textContent.push({
page: i,
text: pageText,
});
}
return textContent;
}
// 使用
const text = await extractText('/document.pdf');
console.log(text[0].text); // 第一页的文本
PDF缩略图生成
async function generateThumbnail(url, pageNumber = 1, scale = 0.3) {
const pdf = await pdfjsLib.getDocument(url).promise;
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
// 转换为Data URL
return canvas.toDataURL('image/jpeg', 0.8);
}
// 生成所有页面的缩略图
async function generateAllThumbnails(url) {
const pdf = await pdfjsLib.getDocument(url).promise;
const thumbnails = [];
for (let i = 1; i <= pdf.numPages; i++) {
const thumb = await generateThumbnail(url, i);
thumbnails.push(thumb);
}
return thumbnails;
}
常见PDF操作
1. PDF合并
// 使用pdf-lib库
import { PDFDocument } from 'pdf-lib';
async function mergePDFs(pdfFiles) {
// 创建新的PDF文档
const mergedPdf = await PDFDocument.create();
for (const file of pdfFiles) {
// 读取文件
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
// 复制所有页面
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
// 添加到新文档
copiedPages.forEach((page) => {
mergedPdf.addPage(page);
});
}
// 保存
const pdfBytes = await mergedPdf.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
// 使用
document.getElementById('mergeBtn').addEventListener('click', async () => {
const files = document.getElementById('fileInput').files;
const merged = await mergePDFs(Array.from(files));
// 下载
const url = URL.createObjectURL(merged);
const a = document.createElement('a');
a.href = url;
a.download = 'merged.pdf';
a.click();
URL.revokeObjectURL(url);
});
2. PDF分割
async function splitPDF(file, pageRanges) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const result = [];
for (const range of pageRanges) {
const newPdf = await PDFDocument.create();
const pages = await newPdf.copyPages(pdf, range);
pages.forEach((page) => newPdf.addPage(page));
const pdfBytes = await newPdf.save();
result.push(new Blob([pdfBytes], { type: 'application/pdf' }));
}
return result;
}
// 使用示例
// 分割为多个单页PDF
async function splitIntoPages(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const pageCount = pdf.getPageCount();
const ranges = Array.from({ length: pageCount }, (_, i) => [i]);
return await splitPDF(file, ranges);
}
// 分割为指定范围
const parts = await splitPDF(file, [
[0, 1, 2], // 第1-3页
[3, 4, 5, 6], // 第4-7页
[7, 8, 9], // 第8-10页
]);
3. PDF压缩
async function compressPDF(file, quality = 0.7) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
// 压缩策略:降低图片质量
const pages = pdf.getPages();
for (const page of pages) {
// 获取页面资源
const resources = page.node.Resources();
if (resources && resources.lookup(PDFName.of('XObject'))) {
const xObjects = resources.lookup(PDFName.of('XObject'));
// 遍历所有图片对象
xObjects.entries().forEach(([name, ref]) => {
const image = pdf.context.lookup(ref);
if (image && image.lookup(PDFName.of('Subtype'))?.toString() === '/Image') {
// 这里可以实现图片压缩逻辑
// 注意:pdf-lib的压缩功能有限,复杂压缩需要服务端处理
}
});
}
}
// 保存时使用压缩选项
const pdfBytes = await pdf.save({
useObjectStreams: true, // 使用对象流压缩
addDefaultPage: false,
});
return new Blob([pdfBytes], { type: 'application/pdf' });
}
说句实话,浏览器端这点压缩能力聊胜于无。我试过对一份图片型 PDF 调 useObjectStreams,最后只小了不到 5%。真要把 80MB 压到能发邮件,老老实实上 Ghostscript:
# 使用Ghostscript压缩(服务端)
gs -sDEVICE=pdfwrite \
-dCompatibilityLevel=1.4 \
-dPDFSETTINGS=/ebook \
-dNOPAUSE -dQUIET -dBATCH \
-sOutputFile=compressed.pdf \
input.pdf
# 压缩质量选项
# /screen - 低质量,小文件(72dpi)
# /ebook - 中等质量(150dpi)
# /printer - 高质量(300dpi)
# /prepress - 印刷质量(300dpi,保留颜色)
4. PDF转图片
async function pdfToImages(file, scale = 2) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
const images = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
// 转换为高质量JPEG
const imageData = canvas.toDataURL('image/jpeg', 0.95);
images.push({
page: i,
data: imageData,
width: viewport.width,
height: viewport.height,
});
}
return images;
}
// 下载所有图片
async function downloadPDFAsImages(file) {
const images = await pdfToImages(file);
images.forEach((img, index) => {
const a = document.createElement('a');
a.href = img.data;
a.download = `page-${index + 1}.jpg`;
a.click();
});
}
5. 图片转PDF
async function imagesToPDF(imageFiles) {
const pdfDoc = await PDFDocument.create();
for (const file of imageFiles) {
const arrayBuffer = await file.arrayBuffer();
let image;
if (file.type === 'image/jpeg' || file.type === 'image/jpg') {
image = await pdfDoc.embedJpg(arrayBuffer);
} else if (file.type === 'image/png') {
image = await pdfDoc.embedPng(arrayBuffer);
} else {
console.warn(`跳过不支持的格式: ${file.type}`);
continue;
}
// 创建页面(A4尺寸或图片原始尺寸)
const page = pdfDoc.addPage([image.width, image.height]);
// 绘制图片
page.drawImage(image, {
x: 0,
y: 0,
width: image.width,
height: image.height,
});
}
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
// 使用
const images = document.getElementById('imageInput').files;
const pdf = await imagesToPDF(Array.from(images));
6. 添加水印
async function addWatermark(file, watermarkText) {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const pages = pdfDoc.getPages();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
pages.forEach((page) => {
const { width, height } = page.getSize();
// 添加文字水印
page.drawText(watermarkText, {
x: width / 2 - 100,
y: height / 2,
size: 50,
font: font,
color: rgb(0.7, 0.7, 0.7),
rotate: degrees(45),
opacity: 0.3,
});
});
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
// 添加图片水印
async function addImageWatermark(file, watermarkImageUrl) {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const imageBytes = await fetch(watermarkImageUrl).then((res) => res.arrayBuffer());
const watermark = await pdfDoc.embedPng(imageBytes);
const pages = pdfDoc.getPages();
pages.forEach((page) => {
const { width, height } = page.getSize();
const watermarkSize = 100;
page.drawImage(watermark, {
x: width - watermarkSize - 20,
y: 20,
width: watermarkSize,
height: watermarkSize,
opacity: 0.5,
});
});
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
PDF优化技巧
减小文件大小
1. 压缩图片
async function optimizeImages(pdfFile) {
// 提取PDF中的图片
const images = await extractImages(pdfFile);
// 压缩每张图片
const compressedImages = await Promise.all(images.map((img) => compressImage(img, 0.7)));
// 重新创建PDF
return await createPDFFromImages(compressedImages);
}
async function compressImage(imageBlob, quality) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob(resolve, 'image/jpeg', quality);
};
img.src = URL.createObjectURL(imageBlob);
});
}
2. 删除元数据
async function removeMetadata(file) {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
// 清除元数据
pdfDoc.setTitle('');
pdfDoc.setAuthor('');
pdfDoc.setSubject('');
pdfDoc.setKeywords([]);
pdfDoc.setProducer('');
pdfDoc.setCreator('');
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
3. 删除未使用的资源
// 这通常需要专业工具
// Adobe Acrobat Pro: 文件 > 另存为其他 > 优化的PDF
// Ghostscript: 会自动清理未使用资源
提高加载速度
1. 启用快速Web查看
// pdf-lib默认生成线性化PDF(快速Web查看)
const pdfBytes = await pdfDoc.save({
useObjectStreams: false, // 禁用对象流以支持线性化
});
2. 分页加载
class PDFViewer {
constructor(url, container) {
this.url = url;
this.container = container;
this.currentPage = 1;
}
async init() {
this.pdf = await pdfjsLib.getDocument(this.url).promise;
this.totalPages = this.pdf.numPages;
await this.renderPage(1);
}
async renderPage(pageNumber) {
const page = await this.pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: canvas.getContext('2d'),
viewport: viewport,
}).promise;
this.container.innerHTML = '';
this.container.appendChild(canvas);
this.currentPage = pageNumber;
}
nextPage() {
if (this.currentPage < this.totalPages) {
this.renderPage(this.currentPage + 1);
}
}
prevPage() {
if (this.currentPage > 1) {
this.renderPage(this.currentPage - 1);
}
}
}
PDF安全性
加密PDF
// 注意:pdf-lib的加密功能有限
// 真正的PDF加密需要使用专业工具或服务端处理
async function encryptPDF(file, password) {
// 浏览器端加密PDF较复杂
// 推荐使用服务端API
const formData = new FormData();
formData.append('file', file);
formData.append('password', password);
const response = await fetch('/api/pdf/encrypt', {
method: 'POST',
body: formData,
});
return await response.blob();
}
// 服务端示例(Node.js + pdf-lib)
// const { PDFDocument, StandardFonts } = require('pdf-lib');
//
// async function encryptPDF(inputPath, outputPath, password) {
// const pdfBytes = fs.readFileSync(inputPath);
// const pdfDoc = await PDFDocument.load(pdfBytes);
//
// const encryptedPdfBytes = await pdfDoc.save({
// userPassword: password,
// ownerPassword: password,
// permissions: {
// printing: 'highResolution',
// modifying: false,
// copying: false,
// annotating: false,
// fillingForms: false,
// contentAccessibility: true,
// documentAssembly: false
// }
// });
//
// fs.writeFileSync(outputPath, encryptedPdfBytes);
// }
数字签名
// PDF数字签名需要证书
// 通常在服务端处理
async function signPDF(file, certificateData) {
const formData = new FormData();
formData.append('file', file);
formData.append('certificate', certificateData);
const response = await fetch('/api/pdf/sign', {
method: 'POST',
body: formData,
});
return await response.blob();
}
权限控制
const permissions = {
printing: 'none', // 禁止打印
modifying: false, // 禁止修改
copying: false, // 禁止复制
annotating: false, // 禁止注释
fillingForms: false, // 禁止填写表单
contentAccessibility: true, // 允许屏幕阅读器
documentAssembly: false, // 禁止页面提取
};
实战工作流
工作流1:批量发票处理
class InvoiceProcessor {
async process(invoiceFiles) {
const results = [];
for (const file of invoiceFiles) {
try {
// 1. 提取文本
const text = await this.extractText(file);
// 2. 解析发票信息
const invoiceData = this.parseInvoiceData(text);
// 3. 验证数据
if (this.validate(invoiceData)) {
// 4. 添加水印
const watermarked = await addWatermark(file, 'PROCESSED');
// 5. 重命名文件
const newName = `${invoiceData.number}_${invoiceData.date}.pdf`;
results.push({
success: true,
file: watermarked,
name: newName,
data: invoiceData,
});
}
} catch (error) {
results.push({
success: false,
error: error.message,
});
}
}
return results;
}
parseInvoiceData(text) {
// 使用正则提取发票号、日期、金额等
const numberMatch = text.match(/发票号码[::]\s*(\S+)/);
const dateMatch = text.match(/日期[::]\s*(\d{4}-\d{2}-\d{2})/);
const amountMatch = text.match(/金额[::]\s*¥?(\d+\.?\d*)/);
return {
number: numberMatch?.[1],
date: dateMatch?.[1],
amount: amountMatch?.[1],
};
}
validate(data) {
return data.number && data.date && data.amount;
}
async extractText(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
const page = await pdf.getPage(1);
const content = await page.getTextContent();
return content.items.map((item) => item.str).join(' ');
}
}
工作流2:电子书制作
class EbookMaker {
async create(chapters) {
const pdfDoc = await PDFDocument.create();
const font = await pdfDoc.embedFont(StandardFonts.TimesRoman);
const boldFont = await pdfDoc.embedFont(StandardFonts.TimesRomanBold);
let pageNumber = 1;
for (const chapter of chapters) {
// 添加章节标题页
const titlePage = pdfDoc.addPage([595, 842]); // A4
titlePage.drawText(chapter.title, {
x: 50,
y: 750,
size: 24,
font: boldFont,
});
// 添加章节内容
const lines = this.splitIntoLines(chapter.content, 70);
let yPosition = 700;
for (const line of lines) {
if (yPosition < 100) {
// 新建页面
const page = pdfDoc.addPage([595, 842]);
yPosition = 750;
// 添加页码
page.drawText(`${pageNumber}`, {
x: 550,
y: 50,
size: 10,
font: font,
});
pageNumber++;
}
titlePage.drawText(line, {
x: 50,
y: yPosition,
size: 12,
font: font,
});
yPosition -= 20;
}
pageNumber++;
}
// 添加目录
await this.addTableOfContents(pdfDoc, chapters);
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
splitIntoLines(text, maxChars) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if ((currentLine + word).length > maxChars) {
lines.push(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
}
if (currentLine) {
lines.push(currentLine.trim());
}
return lines;
}
async addTableOfContents(pdfDoc, chapters) {
const tocPage = pdfDoc.insertPage(0, [595, 842]);
const font = await pdfDoc.embedFont(StandardFonts.TimesRomanBold);
tocPage.drawText('目录', {
x: 250,
y: 750,
size: 20,
font: font,
});
let yPosition = 700;
chapters.forEach((chapter, index) => {
tocPage.drawText(`${index + 1}. ${chapter.title}`, {
x: 50,
y: yPosition,
size: 14,
font: font,
});
yPosition -= 30;
});
}
}
工作流3:简历筛选系统
class ResumeScreener {
constructor(keywords) {
this.keywords = keywords;
}
async screen(resumeFiles) {
const results = [];
for (const file of resumeFiles) {
const text = await this.extractText(file);
const score = this.calculateScore(text);
const matches = this.findKeywordMatches(text);
results.push({
filename: file.name,
score: score,
matches: matches,
qualified: score >= 60,
});
}
// 按分数排序
return results.sort((a, b) => b.score - a.score);
}
calculateScore(text) {
let score = 0;
const lowerText = text.toLowerCase();
this.keywords.forEach((keyword) => {
const regex = new RegExp(keyword.term, 'gi');
const matches = lowerText.match(regex);
if (matches) {
score += keyword.weight * matches.length;
}
});
return Math.min(score, 100);
}
findKeywordMatches(text) {
const matches = [];
this.keywords.forEach((keyword) => {
const regex = new RegExp(keyword.term, 'gi');
const found = text.match(regex);
if (found) {
matches.push({
keyword: keyword.term,
count: found.length,
});
}
});
return matches;
}
async extractText(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
fullText += content.items.map((item) => item.str).join(' ') + '\n';
}
return fullText;
}
}
// 使用
const screener = new ResumeScreener([
{ term: 'javascript', weight: 10 },
{ term: 'react', weight: 8 },
{ term: 'node\\.js', weight: 8 },
{ term: '本科', weight: 5 },
{ term: '3年经验', weight: 7 },
]);
const results = await screener.screen(resumeFiles);
console.log(
'合格简历:',
results.filter((r) => r.qualified)
);
写在最后
折腾 PDF 这么多年,如果只让我留三句话给后来人,大概是这样:
- 扫描件 PDF 别指望直接提取文字。 先判断有没有文字层(
getTextContent返回一堆空串基本就是了),是图就先 OCR,别在那儿调试为什么正则匹配不到发票号。 - 压缩前先看是不是图片占大头。 纯文字 PDF 本来就没几 KB 能压的,你折腾半天它纹丝不动;真正占体积的是扫描图,要降采样、降 dpi,这活儿浏览器干不动,Ghostscript 一行命令的事。
- 页码索引从 0 开始。 这个我栽过不止一次,拆「第 5 页」结果拆出第 6 页,调试半天最后发现是这个低级问题。
顺手提一句隐私
PDF 里装的东西,往往比图片敏感得多——合同、发票、简历、身份证扫描件、银行流水,全是不想被人看到的玩意儿。所以但凡涉及这类文件,我个人是坚决不上传到不知底细的在线工具的,谁知道它后台留没留副本。
ToolsForge 上这套 PDF 工具全部跑在你自己浏览器里,文件不会上传到任何服务器,合并、拆分、压缩、转换都是本地完成,关掉页面就什么都不剩。敏感文档放心拖进去就行。
下次再遇到那种死活发不出去的 80MB 扫描件,希望你能少绕几个我绕过的弯。有别的坑欢迎来踩,回头见。
核心要点
-
选择合适的库:
- PDF.js:渲染和阅读
- pdf-lib:创建和修改
- jsPDF:从零创建PDF
-
浏览器vs服务端:
- 浏览器:适合轻量级操作
- 服务端:适合复杂处理(加密、OCR、压缩)
-
性能优化:
- 分页加载
- 压缩图片
- 启用快速Web查看
-
安全考虑:
- 敏感文档加密
- 权限控制
- 数字签名验证
最佳实践
- ✅ 大文件使用服务端处理
- ✅ 压缩前备份原始文件
- ✅ 合并前检查文件完整性
- ✅ 使用Web Worker避免阻塞UI
- ✅ 添加错误处理和进度提示
相关工具
关键词: PDF处理, PDF合并, PDF分割, PDF压缩, PDF.js, pdf-lib
更新时间: 2026-01-05
我们是一支开发者小团队,负责构建和维护 ToolsForge —— 150+ 个完全在浏览器本地运行、隐私优先的工具。这些文章来自我们日常的工程实践,而不是纸上谈兵。
了解我们相关阅读
一张照片能做多少事:AI 改图工具实战指南
换发型、换发色、换装、转卡通、风格迁移、室内设计、老照片修复——用一张照片走完这些 AI 改图流程,以及怎么让结果更稳、更像本人。
把脸保住:AI 改图提示词怎么写才不"换人"
换发型、换装、换风格时,AI 老把脸也改了?这篇讲清楚改图提示词的写法:保人约束、双通道(gpt-image-2 / Nano Banana)各自的脾气,以及一套能直接抄的模板。
TypeScript 类型体操:什么时候值,什么时候在炫技
务实的 TypeScript 高级类型指南 — mapped types、conditional types、template literal types 真正能给你什么,什么时候用,什么时候应该退回到朴素代码。