<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF 压缩工具 (Web Worker 版本)</title>
</head>
<body>
<div class="container">
<h1>📄 PDF 压缩工具<span class="badge">Web Worker</span></h1>
<p class="subtitle">基于 Ghostscript WASM,在浏览器中本地压缩 PDF 文件</p>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<div class="upload-text">点击或拖拽 PDF 文件到这里</div>
<input type="file" id="fileInput" accept=".pdf">
</div>
<div class="file-info" id="fileInfo">
<div class="file-info-item">
<span class="file-info-label">文件名:</span>
<span class="file-info-value" id="fileName">-</span>
</div>
<div class="file-info-item">
<span class="file-info-label">文件大小:</span>
<span class="file-info-value" id="fileSize">-</span>
</div>
</div>
<div class="settings">
<h3>⚙️ 压缩设置</h3>
<div class="setting-item">
<label for="quality">压缩质量:</label>
<select id="quality">
<option value="screen">Screen(最小文件)</option>
<option value="ebook" selected>Ebook(推荐)</option>
<option value="printer">Printer(高质量)</option>
<option value="prepress">Prepress(最高质量)</option>
</select>
</div>
<div class="setting-item">
<label for="dpi">图像 DPI:</label>
<select id="dpi">
<option value="72">72 DPI(最低)</option>
<option value="96">96 DPI</option>
<option value="150" selected>150 DPI(推荐)</option>
<option value="200">200 DPI</option>
<option value="300">300 DPI(高质量)</option>
</select>
</div>
</div>
<button class="button" id="compressBtn" disabled>开始压缩</button>
<div class="progress-container" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">准备中...</div>
</div>
<div class="error" id="error"></div>
<div class="logs" id="logs"></div>
<div class="result" id="result">
<h3>✅ 压缩完成!</h3>
<div class="result-stats">
<div class="stat-item">
<div class="stat-label">原始大小</div>
<div class="stat-value" id="originalSize">-</div>
</div>
<div class="stat-item">
<div class="stat-label">压缩后大小</div>
<div class="stat-value" id="compressedSize">-</div>
</div>
<div class="stat-item">
<div class="stat-label">压缩率</div>
<div class="stat-value" id="compressionRatio">-</div>
</div>
</div>
<button class="download-btn" id="downloadBtn">下载压缩后的 PDF</button>
</div>
</div>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
padding: 15px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 900px;
width: 100%;
padding: 20px 30px;
max-height: calc(100vh - 30px);
overflow-y: auto;
}
h1 {
color: #333;
margin-bottom: 5px;
font-size: 22px;
}
.subtitle {
color: #666;
margin-bottom: 15px;
font-size: 13px;
}
.badge {
display: inline-block;
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
margin-left: 10px;
}
.upload-area {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 20px;
text-align: center;
cursor: pointer;
background: #fafafa;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #667eea;
background: #f0f4ff;
}
.upload-icon {
font-size: 32px;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
}
input[type="file"] {
display: none;
}
.file-info {
display: none;
margin-top: 12px;
padding: 12px 15px;
background: #f0f9ff;
border-radius: 6px;
border: 1px solid #bee3f8;
}
.file-info-item {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
}
.file-info-item:last-child {
margin-bottom: 0;
}
.file-info-label {
color: #555;
font-weight: 500;
}
.file-info-value {
color: #333;
}
.settings {
margin-top: 15px;
padding: 15px;
background: #f9f9f9;
border-radius: 6px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.settings h3 {
grid-column: 1 / -1;
margin: 0 0 8px 0;
font-size: 14px;
}
.setting-item {
margin-bottom: 0;
}
.setting-item label {
display: block;
margin-bottom: 6px;
color: #555;
font-size: 13px;
}
.setting-item select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
width: 100%;
margin-top: 15px;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.progress-container {
display: none;
margin-top: 15px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
width: 0%;
}
.progress-text {
font-size: 13px;
color: #666;
text-align: center;
}
.result {
display: none;
margin-top: 15px;
padding: 15px;
background: #f0fdf4;
border-radius: 6px;
}
.result h3 {
color: #16a34a;
margin-bottom: 12px;
font-size: 16px;
}
.result-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.stat-item {
background: white;
padding: 10px;
border-radius: 4px;
text-align: center;
}
.stat-label {
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #16a34a;
}
.download-btn {
background: #16a34a;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
width: 100%;
}
.error {
display: none;
margin-top: 12px;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
}
.logs {
margin-top: 12px;
padding: 12px;
background: #1e1e1e;
border-radius: 6px;
color: #d4d4d4;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 120px;
overflow-y: auto;
display: none;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
let worker = null;
let selectedFile = null;
let compressedBlob = null;
let messageId = 0;
const pendingMessages = new Map();
// DOM 元素
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const compressBtn = document.getElementById('compressBtn');
const progressContainer = document.getElementById('progressContainer');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const errorDiv = document.getElementById('error');
const logsDiv = document.getElementById('logs');
const resultDiv = document.getElementById('result');
const downloadBtn = document.getElementById('downloadBtn');
const qualitySelect = document.getElementById('quality');
const dpiSelect = document.getElementById('dpi');
// 初始化 Worker(内联 Blob 方式)
function initWorker() {
if (worker) return;
// Worker 代码函数
function workerFunction() {
const nrcGhostscript = {
version: "1.0.0",
module: null,
loading: false,
loaded: false,
presets: {
screen: { pdfsettings: "/screen", dpi: 72 },
ebook: { pdfsettings: "/ebook", dpi: 150 },
printer: { pdfsettings: "/printer", dpi: 200 },
prepress: { pdfsettings: "/prepress", dpi: 300 }
},
load: async function (cdnUrl = "https://netnr.eu.org/@okathira/ghostpdl-wasm@1.1.0/dist") {
if (this.loaded && this.module) {
return this.module;
}
if (this.loading) {
return new Promise((resolve, reject) => {
const checkLoaded = setInterval(() => {
if (this.loaded) {
clearInterval(checkLoaded);
resolve(this.module);
} else if (!this.loading) {
clearInterval(checkLoaded);
reject(new Error("Ghostscript WASM 模块加载失败"));
}
}, 100);
});
}
this.loading = true;
try {
const { default: loadWASM } = await import(`${cdnUrl}/gs.js`);
this.module = await loadWASM({
locateFile: (path) => {
if (path.endsWith('.wasm')) {
return `${cdnUrl}/${path}`;
}
return path;
}
});
this.loaded = true;
this.loading = false;
return this.module;
} catch (error) {
this.loading = false;
throw new Error(`加载 Ghostscript WASM 失败: ${error.message}`);
}
},
compressPDF: async function (inputData, options = {}) {
const module = await this.load(options.cdnUrl);
const quality = options.quality || 'ebook';
const preset = this.presets[quality] || this.presets.ebook;
const pdfsettings = options.pdfsettings || preset.pdfsettings;
const dpi = options.dpi || preset.dpi;
try {
if (!(inputData instanceof Uint8Array)) {
if (inputData instanceof ArrayBuffer) {
inputData = new Uint8Array(inputData);
} else {
throw new Error('不支持的输入类型');
}
}
const inputFileName = 'input.pdf';
const outputFileName = 'output.pdf';
module.FS.writeFile(inputFileName, inputData);
const args = [
'-sDEVICE=pdfwrite',
`-dPDFSETTINGS=${pdfsettings}`,
'-dCompatibilityLevel=1.4',
'-dNOPAUSE',
'-dQUIET',
'-dBATCH',
`-dDownsampleColorImages=true`,
`-dColorImageResolution=${dpi}`,
`-dDownsampleGrayImages=true`,
`-dGrayImageResolution=${dpi}`,
`-dDownsampleMonoImages=true`,
`-dMonoImageResolution=${dpi}`,
`-sOutputFile=${outputFileName}`,
inputFileName
];
module.callMain(args);
const outputData = module.FS.readFile(outputFileName);
try {
module.FS.unlink(inputFileName);
module.FS.unlink(outputFileName);
} catch (e) {
console.warn('清理临时文件失败:', e);
}
return outputData;
} catch (error) {
throw new Error(`PDF 压缩失败: ${error.message}`);
}
}
};
// Worker 消息处理
self.addEventListener('message', async (event) => {
const { id, type, data } = event.data;
try {
switch (type) {
case 'compress':
const { inputData, options } = data;
const outputData = await nrcGhostscript.compressPDF(inputData, options);
self.postMessage({
id,
type: 'success',
data: {
outputData: outputData.buffer,
originalSize: inputData.byteLength,
compressedSize: outputData.byteLength
}
}, [outputData.buffer]);
break;
case 'load':
await nrcGhostscript.load(data.cdnUrl);
self.postMessage({ id, type: 'success', data: { loaded: true } });
break;
default:
throw new Error(`未知的消息类型: ${type}`);
}
} catch (error) {
self.postMessage({
id,
type: 'error',
data: { message: error.message }
});
}
});
}
// 将 Worker 代码转换为 Blob URL
const workerCode = '(' + workerFunction.toString() + ')()';
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
// 创建 Worker
worker = new Worker(workerUrl);
worker.addEventListener('message', (event) => {
const { id, type, data } = event.data;
const pending = pendingMessages.get(id);
if (!pending) return;
pendingMessages.delete(id);
if (type === 'success') {
pending.resolve(data);
} else if (type === 'error') {
pending.reject(new Error(data.message));
}
});
worker.addEventListener('error', (error) => {
addLog('❌ Worker 错误: ' + error.message);
console.error('Worker error:', error);
});
addLog('✅ Web Worker 已初始化(内联 Blob 模式)');
}
// 发送消息到 Worker
function sendWorkerMessage(type, data) {
return new Promise((resolve, reject) => {
const id = ++messageId;
pendingMessages.set(id, { resolve, reject });
worker.postMessage({ id, type, data });
});
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// 添加日志
function addLog(message) {
logsDiv.style.display = 'block';
const line = document.createElement('div');
line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
logsDiv.appendChild(line);
logsDiv.scrollTop = logsDiv.scrollHeight;
}
// 更新进度
function updateProgress(percent, text) {
progressFill.style.width = percent + '%';
progressText.textContent = text;
}
// 显示错误
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => errorDiv.style.display = 'none', 5000);
}
// 上传区域事件
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#667eea';
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.style.borderColor = '#ddd';
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#ddd';
if (e.dataTransfer.files.length > 0) {
handleFileSelect(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
// 处理文件选择
function handleFileSelect(file) {
if (file.type !== 'application/pdf') {
showError('请选择 PDF 文件!');
return;
}
if (file.size > 2 * 1024 * 1024 * 1024) {
showError('文件大小超过 2GB 限制!');
return;
}
selectedFile = file;
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
fileInfo.style.display = 'block';
compressBtn.disabled = false;
resultDiv.style.display = 'none';
errorDiv.style.display = 'none';
addLog(`已选择文件: ${file.name} (${formatFileSize(file.size)})`);
}
// 压缩按钮
compressBtn.addEventListener('click', async () => {
if (!selectedFile) return;
compressBtn.disabled = true;
compressBtn.innerHTML = '<span class="spinner"></span>正在压缩...';
progressContainer.style.display = 'block';
resultDiv.style.display = 'none';
logsDiv.innerHTML = '';
logsDiv.style.display = 'block';
const quality = qualitySelect.value;
const dpi = parseInt(dpiSelect.value);
try {
initWorker();
updateProgress(10, '准备数据...');
addLog('读取 PDF 文件...');
const inputData = await selectedFile.arrayBuffer();
updateProgress(20, '初始化 Worker...');
addLog('加载 Ghostscript WASM...');
await sendWorkerMessage('load', {});
updateProgress(40, '正在压缩 PDF...');
addLog(`开始压缩: ${selectedFile.name} (质量: ${quality}, DPI: ${dpi})`);
const result = await sendWorkerMessage('compress', {
inputData,
options: { quality, dpi }
});
updateProgress(90, '处理结果...');
compressedBlob = new Blob([result.outputData], { type: 'application/pdf' });
const ratio = ((1 - result.compressedSize / result.originalSize) * 100).toFixed(1);
document.getElementById('originalSize').textContent = formatFileSize(result.originalSize);
document.getElementById('compressedSize').textContent = formatFileSize(result.compressedSize);
document.getElementById('compressionRatio').textContent = ratio + '%';
updateProgress(100, '完成!');
resultDiv.style.display = 'block';
addLog('✅ 压缩完成!');
} catch (error) {
showError('压缩失败: ' + error.message);
addLog('❌ 压缩失败: ' + error.message);
console.error(error);
} finally {
compressBtn.disabled = false;
compressBtn.textContent = '开始压缩';
setTimeout(() => {
progressContainer.style.display = 'none';
}, 1000);
}
});
// 下载按钮
downloadBtn.addEventListener('click', () => {
if (!compressedBlob) return;
const url = URL.createObjectURL(compressedBlob);
const a = document.createElement('a');
a.href = url;
a.download = selectedFile.name.replace('.pdf', '_compressed.pdf');
a.click();
URL.revokeObjectURL(url);
addLog(`📥 下载文件: ${a.download}`);
});
addLog('页面加载完成,请选择 PDF 文件');