在日常办公和文件管理中,PDF 文件体积过大常常带来诸多困扰:邮件附件发送受限、云端存储空间紧张、文档传输耗时过长。一个安全、高效且不泄露隐私的 PDF 压缩工具,成为许多用户和开发者的刚需。
为此,一款完全开源的 PDF 压缩工具应运而生。它采用先进的纯前端技术,通过智能优化图片质量和移除冗余数据,在保证可读性的前提下显著减小 PDF 文件体积。所有处理均在浏览器本地完成,确保您的文档隐私零泄露。
核心功能亮点
- 智能压缩,灵活可控
- 多级压缩质量:提供高质量(较小压缩)、中等质量(推荐)和低质量(最大压缩)三种预设,满足不同场景需求。
- 精细图片质量控制:通过直观的滑块(10%-100%),可精确控制 PDF 中图片的压缩程度,在文件大小和视觉效果间找到最佳平衡。
- 批量处理,高效便捷
- 支持同时选择多个 PDF 文件进行压缩,大幅提升处理效率。
- 压缩完成后,可单独下载每个文件,或一键打包下载所有压缩后的文件。
- 实时反馈,清晰透明
- 每个文件都显示原始大小、压缩后大小和压缩比例。
- 压缩比例以颜色标识(高绿、中黄、低灰),直观呈现压缩效果。
- 处理状态(等待中、压缩中、完成、失败)一目了然。
- 隐私安全,纯前端处理
- 所有压缩操作均在您的浏览器本地完成,PDF 文件数据全程不会上传至任何服务器。
- 基于 pdf.js 和 pdf-lib 两大专业库实现,确保处理过程的准确性和可靠性。
- 广泛兼容,智能处理
- 支持大部分标准 PDF 格式,通过智能渲染和重编码技术实现有效压缩。
- 注:加密或损坏的 PDF 可能无法正常处理,系统会给出明确提示。
完整 Vue 3 + TypeScript 代码实现
以下是该工具的核心代码实现,基于 Vue 3、TypeScript、Element Plus 组件库,并集成了 pdf.js、pdf-lib、JSZip 和 FileSaver.js 等专业库:
<template>
<div>
<DetailHeader :title="info.title"></DetailHeader>
<div>
<div>
<el-text type="info">压缩 PDF 文档大小,支持批量压缩,所有操作在本地完成。</el-text>
</div>
<div>
<el-text type="info">通过降低图片质量和移除冗余数据来减小 PDF 文件体积。</el-text>
</div>
<div>
<div>
<span>压缩质量:</span>
<el-select v-model="compressQuality">
<el-option label="高质量 (较小压缩)" value="high" />
<el-option label="中等质量 (推荐)" value="medium" />
<el-option label="低质量 (最大压缩)" value="low" />
</el-select>
</div>
<div>
<span>图片质量:</span>
<el-slider v-model="imageQuality" :min="10" :max="100" :format-tooltip="(val: number) => `${val}%`" />
</div>
</div>
<div>
<el-upload accept=".pdf" :auto-upload="false" :on-change="onFileChange" :multiple="true" :show-file-list="false">
<el-button type="primary">选择 PDF 文件</el-button>
</el-upload>
</div>
<div v-if="files.length">
<div>
<el-button type="success" @click="compressAllFiles" :disabled="loading">开始压缩</el-button>
<el-button type="warning" @click="clearFiles" :disabled="loading">清空文件</el-button>
</div>
<div>
<div v-for="(file, idx) in files" :key="file.uid">
<div>
<span>{{ file.name }}</span>
<div>
<span>原始:{{ formatFileSize(file.size) }}</span>
<span v-if="file.compressedSize"> 压缩后:{{ formatFileSize(file.compressedSize) }} </span>
<span v-if="file.compressedSize" :class="getRatioClass(file)"> ({{ getRatio(file) }}) </span>
</div>
</div>
<div>
<el-button v-if="file.compressedUrl" type="primary" size="small" @click="downloadFile(file)">下载</el-button>
<el-button type="danger" size="small" @click="removeFile(idx)">删除</el-button>
</div>
<div v-if="file.status === 'processing'">
<el-icon><Loading /></el-icon>
<span>压缩中...</span>
</div>
<div v-else-if="file.status === 'done'">
<el-icon><CircleCheck /></el-icon>
<span>完成</span>
</div>
<div v-else-if="file.status === 'error'">
<el-icon><CircleClose /></el-icon>
<span>失败</span>
</div>
</div>
</div>
</div>
<div v-if="loading">
<el-alert type="info" :title="loadingMsg" show-icon :closable="false" />
<el-progress :percentage="progress" :format="() => `${progress}%`" />
</div>
<div v-if="errorMsg">
<el-alert type="error" :title="errorMsg" show-icon :closable="false" />
</div>
<div v-if="completedCount > 0">
<el-alert type="success" :title="`成功压缩 ${completedCount} 个文件`" show-icon :closable="false" />
<div>
<el-button type="primary" @click="downloadAllFiles">全部下载</el-button>
</div>
</div>
</div>
<ToolDetail title="使用说明">
<el-text>
<ul>
<li>点击'选择 PDF 文件'按钮,选择一个或多个需要压缩的 PDF 文件。</li>
<li>选择压缩质量:高质量保留更多细节,低质量压缩比更大。</li>
<li>调整图片质量滑块,控制 PDF 中图片的压缩程度。</li>
<li>点击'开始压缩'按钮开始压缩过程。</li>
<li>压缩完成后,可以单独下载每个文件或批量下载所有文件。</li>
<li>所有压缩操作均在本地完成,不会上传到服务器。</li>
<li>支持大部分标准 PDF 格式,加密或损坏的 PDF 可能无法正常压缩。</li>
</ul>
</el-text>
</ToolDetail>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import DetailHeader from '@/components/Layout/DetailHeader/DetailHeader.vue';
import ToolDetail from '@/components/Layout/ToolDetail/ToolDetail.vue';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min?url';
import { PDFDocument } from 'pdf-lib';
import { Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
const info = reactive({ title: "📦 PDF 压缩工具", });
interface FileItem {
uid: string;
name: string;
size: number;
raw: File;
status: 'pending' | 'processing' | 'done' | 'error';
compressedSize?: number;
compressedUrl?: string;
compressedBytes?: Uint8Array;
}
const files = ref<FileItem[]>([]);
const loading = ref(false);
const loadingMsg = ref('');
const progress = ref(0);
const errorMsg = ref('');
const compressQuality = ref('medium');
const imageQuality = ref(70);
const completedCount = computed(() => files.value.filter(f => f.status === 'done').length);
const onFileChange = (file: any) => {
if (!file || !file.raw) return;
if (file.raw.type !== 'application/pdf') {
errorMsg.value = '请选择 PDF 文件';
return;
}
errorMsg.value = '';
const newFile: FileItem = {
uid: Date.now().toString() + Math.random().toString(36).substr(2, 9),
name: file.name,
size: file.size,
raw: file.raw,
status: 'pending'
};
files.value.push(newFile);
};
const formatFileSize = (size: number): string => {
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB';
return (size / (1024 * 1024)).toFixed(2) + ' MB';
};
const getRatio = (file: FileItem): string => {
if (!file.compressedSize) return '';
const ratio = ((1 - file.compressedSize / file.size) * 100).toFixed(1);
return ratio + '% 减少';
};
const getRatioClass = (file: FileItem): string => {
if (!file.compressedSize) return '';
const ratio = (1 - file.compressedSize / file.size) * 100;
if (ratio >= 50) return 'ratio-high';
if (ratio >= 20) return 'ratio-medium';
return 'ratio-low';
};
const removeFile = (idx: number) => {
const file = files.value[idx];
if (file.compressedUrl) {
URL.revokeObjectURL(file.compressedUrl);
}
files.value.splice(idx, 1);
};
const clearFiles = () => {
files.value.forEach(file => {
if (file.compressedUrl) {
URL.revokeObjectURL(file.compressedUrl);
}
});
files.value = [];
errorMsg.value = '';
};
const compressPDF = async (file: FileItem): Promise<void> => {
const arrayBuffer = await file.raw.arrayBuffer();
const pdfJsDoc = await pdfjsLib.getDocument({ data: arrayBuffer.slice(0) }).promise;
const numPages = pdfJsDoc.numPages;
const newPdfDoc = await PDFDocument.create();
const quality = imageQuality.value / 100;
const scale = getScaleByQuality();
for (let i = 1; i <= numPages; i++) {
const page = await pdfJsDoc.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d')!;
context.fillStyle = '#FFFFFF';
context.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: context, viewport, canvas, }).promise;
const imgDataUrl = canvas.toDataURL('image/jpeg', quality);
const imgBytes = await fetch(imgDataUrl).then(r => r.arrayBuffer());
const img = await newPdfDoc.embedJpg(imgBytes);
const pdfPage = newPdfDoc.addPage([viewport.width, viewport.height]);
pdfPage.drawImage(img, { x: 0, y: 0, width: viewport.width, height: viewport.height, });
}
const compressedBytes = await newPdfDoc.save({ useObjectStreams: true, addDefaultPage: false, objectsPerTick: 50, });
const compressedSize = compressedBytes.length;
const blob = new Blob([new Uint8Array(compressedBytes)], { type: 'application/pdf' });
const compressedUrl = URL.createObjectURL(blob);
file.compressedSize = compressedSize;
file.compressedUrl = compressedUrl;
file.compressedBytes = compressedBytes;
};
const getScaleByQuality = (): number => {
switch (compressQuality.value) {
case 'high': return 1.5;
case 'low': return 0.8;
case 'medium': default: return 1.2;
}
};
const compressAllFiles = async () => {
if (files.value.length === 0) {
errorMsg.value = '请先选择 PDF 文件';
return;
}
loading.value = true;
errorMsg.value = '';
progress.value = 0;
const pendingFiles = files.value.filter(f => f.status === 'pending' || f.status === 'error');
for (let i = 0; i < pendingFiles.length; i++) {
const file = pendingFiles[i];
file.status = 'processing';
loadingMsg.value = `正在压缩:${file.name} (${i + 1}/${pendingFiles.length})`;
try {
await compressPDF(file);
file.status = 'done';
} catch (err) {
file.status = 'error';
console.error(`压缩失败:${file.name}`, err);
}
progress.value = Math.round(((i + 1) / pendingFiles.length) * 100);
}
loading.value = false;
loadingMsg.value = '';
};
const downloadFile = (file: FileItem) => {
if (!file.compressedUrl) return;
const link = document.createElement('a');
link.href = file.compressedUrl;
const baseName = file.name.replace('.pdf', '');
link.download = `${baseName}_compressed.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const downloadAllFiles = async () => {
const completedFiles = files.value.filter(f => f.status === 'done' && f.compressedBytes);
if (completedFiles.length === 0) {
errorMsg.value = '没有可下载的文件';
return;
}
if (completedFiles.length === 1) {
downloadFile(completedFiles[0]);
return;
}
loading.value = true;
loadingMsg.value = '正在打包文件...';
try {
const zip = new JSZip();
completedFiles.forEach(file => {
const baseName = file.name.replace('.pdf', '');
zip.file(`${baseName}_compressed.pdf`, file.compressedBytes!);
});
const blob = await zip.generateAsync({ type: 'blob' });
saveAs(blob, 'compressed_pdfs.zip');
} catch (err) {
errorMsg.value = '打包下载失败';
console.error(err);
} finally {
loading.value = false;
loadingMsg.value = '';
}
};
</script>
<style scoped>
.settings-section { padding: 16px; background: #f8f9fa; border-radius: 8px; }
.settings-row { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; }
.setting-item { display: flex; align-items: center; gap: 8px; }
.setting-label { font-size: 14px; color: #606266; white-space: nowrap; }
.file-list { margin-top: 18px; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; }
.file-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; border-bottom: 1px solid #e4e7ed; background: #fafbfc; transition: background-color 0.2s; flex-wrap: wrap; gap: 12px; }
.file-item:hover { background: #f0f9ff; }
.file-item:last-child { border-bottom: none; }
.file-info { flex: 1; min-width: 200px; }
.file-name { font-weight: 500; margin-right: 18px; }
.file-sizes { display: flex; gap: 12px; margin-top: 4px; }
.file-size { color: #666; font-size: 14px; }
.file-size.compressed { color: #67c23a; font-weight: 500; }
.file-ratio { font-size: 14px; font-weight: 500; }
.ratio-high { color: #67c23a; }
.ratio-medium { color: #e6a23c; }
.ratio-low { color: #909399; }
.file-actions { display: flex; gap: 8px; }
.file-status { display: flex; align-items: center; gap: 6px; font-size: 14px; }
.file-status.done { color: #67c23a; }
.file-status.error { color: #f56c6c; }
.file-status .is-loading { animation: rotate 1s linear infinite; }
@keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
ul { padding-left: 20px; margin: 0; line-height: 1.7; }
.mt-2 { margin-top: 8px; }
.mb-4 { margin-bottom: 16px; }
</style>


