前端实现pdf切割

发布于:

#Reason

学校发的试卷是pdf文件,正反面的试卷印在了2页A4纸上,打印出来字有点小,需要将原文件50%左右分割,由2页变为4页。
起初想起来了wps,分割页面工具不仅能达到效果,并且支持随意大小分割,但是需要超级会员。只为这一个功能充个会员感觉不太实用(后来想着其实可以选择只充1天)

#ai是个好东西

先是用ai工具生成了相关功能,pc端正常,手机端有点问题。这时突发奇想,前端是不是可以直接处理,于是开始重新生成前端代码,经过不断的让ai帮我debug异常,最终实现了想要的效果。
这里感谢腾讯元宝,不像有些出场时好大气,时不时来个服务器繁忙,是真的好愁人……

#Result

网络比较差的,建议把all.min.css等文件放到服务器上,使用cdn时,会遇到pdf-lib.min.js文件4秒加载完成。
html
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PDF分割工具 - 坐标修复版</title> <!-- ai: 坐标注释版 --> <link rel="stylesheet" href="fonts/font-awesome/css/all.min.css"> <script src="js/pdf/pdf.min.js"></script> <script src="js/pdf/pdf-lib.min.js"></script> <style> :root { --primary: #4361ee; --secondary: #3a0ca3; --success: #43cea2; --warning: #f72585; --dark: #1d3557; --light: #f8f9fa; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; } body { background: linear-gradient(135deg, #1d2b64, #f8cdda); color: #2d3748; min-height: 100vh; padding: 20px; display: flex; flex-direction: column; align-items: center; overflow-x: hidden; } .header { text-align: center; padding: 20px 0; width: 100%; max-width: 800px; margin: 0 auto; } .app-title { font-size: 2.5rem; font-weight: 800; margin-bottom: 15px; background: linear-gradient(to right, #4361ee, #3a0ca3); -webkit-background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: 1px; } .app-subtitle { font-size: 1.2rem; margin-bottom: 25px; max-width: 600px; margin-left: auto; margin-right: auto; line-height: 1.6; color: #4a5568; } .card-container { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 25px; width: 100%; max-width: 800px; padding: 30px; box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15); border: 1px solid rgba(221, 221, 221, 0.5); } .upload-section { padding: 40px 20px; text-align: center; border-radius: 18px; background: rgba(255, 255, 255, 0.9); position: relative; margin-bottom: 25px; border: 2px dashed #cbd5e0; } .upload-icon { font-size: 4rem; margin-bottom: 20px; color: var(--primary); } .upload-text { font-size: 1.4rem; margin-bottom: 15px; font-weight: 600; color: #2d3748; } .upload-subtext { font-size: 0.95rem; color: #718096; max-width: 500px; margin: 0 auto; margin-bottom: 20px; } .upload-area { display: inline-block; padding: 15px 35px; background: linear-gradient(135deg, var(--primary), var(--secondary)); border-radius: 50px; font-weight: 600; font-size: 1.1rem; cursor: pointer; box-shadow: 0 5px 15px rgba(67, 97, 238, 0.3); transition: all 0.3s ease; border: none; color: white; } .upload-area:hover { transform: translateY(-3px); box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4); } .file-input { position: absolute; width: 1px; height: 1px; opacity: 0; } .preview-section { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 20px 0; display: none; } .file-preview { display: flex; align-items: center; gap: 20px; background: rgba(255, 255, 255, 0.8); border-radius: 15px; padding: 15px; width: 100%; max-width: 500px; } .file-icon { font-size: 2.2rem; color: var(--primary); } .file-info { text-align: left; flex: 1; } .file-name { font-weight: 600; font-size: 1.1rem; margin-bottom: 5px; color: #2d3748; } .file-size { font-size: 0.9rem; color: #718096; } .action-section { display: flex; flex-direction: column; gap: 15px; width: 100%; max-width: 500px; margin: 0 auto; } .process-btn, .download-btn { padding: 18px; font-size: 1.1rem; font-weight: 600; border-radius: 15px; border: none; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 12px; transition: all 0.3s ease; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); } .process-btn { background: linear-gradient(135deg, var(--primary), var(--secondary)); color: white; } .process-btn:hover:not([disabled]) { transform: translateY(-3px); box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4); } .download-btn { background: linear-gradient(135deg, #43cea2, #185a9d); color: white; } .download-btn:hover:not([disabled]) { transform: translateY(-3px); box-shadow: 0 8px 20px rgba(67, 206, 162, 0.4); } .process-btn:disabled, .download-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; } .progress-section { width: 100%; max-width: 500px; margin: 25px auto; display: none; } .progress-header { display: flex; justify-content: space-between; margin-bottom: 15px; font-size: 1.05rem; color: #2d3748; } .progress-container { height: 18px; background: #e2e8f0; border-radius: 50px; overflow: hidden; } .progress-bar { height: 100%; width: 0%; background: linear-gradient(90deg, var(--primary), #3a0ca3); border-radius: 50px; transition: width 0.5s ease; position: relative; } .status-section { min-height: 90px; padding: 20px; background: rgba(255, 255, 255, 0.8); border-radius: 15px; margin: 25px 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .status-icon { font-size: 2.2rem; margin-bottom: 15px; } .status-message { font-size: 1.1rem; line-height: 1.6; color: #2d3748; } .success { color: #38a169; } .error { color: #e53e3e; } .info { color: var(--primary); } .coordinate-guide { width: 100%; max-width: 600px; margin: 30px auto; background: rgba(255, 255, 255, 0.9); border-radius: 15px; padding: 25px; color: #2d3748; } .coordinate-title { font-weight: 700; font-size: 1.4rem; margin-bottom: 20px; text-align: center; color: var(--primary); } .coordinate-container { display: flex; flex-wrap: wrap; gap: 30px; margin-top: 25px; } .coordinate-card { flex: 1; min-width: 250px; padding: 20px; background: rgba(255, 255, 255, 0.7); border-radius: 12px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); } .coordinate-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #cbd5e0; } .axis-icon { font-size: 1.6rem; } .coordinate-name { font-weight: 700; font-size: 1.1rem; } .coordinate-info { line-height: 1.6; } .coordinate-values { background: rgba(245, 245, 245, 0.9); padding: 15px; border-radius: 8px; margin-top: 10px; font-family: monospace; } .coordinate-formula { font-style: italic; margin-top: 15px; color: #4a5568; } .feature-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 30px; padding-top: 25px; border-top: 1px solid rgba(221, 221, 221, 0.5); } .feature-card { background: rgba(255, 255, 255, 0.9); border-radius: 15px; padding: 20px; } .feature-icon { font-size: 2rem; margin-bottom: 15px; color: var(--primary); } .feature-title { font-weight: 600; margin-bottom: 10px; font-size: 1.1rem; color: #2d3748; } .feature-content { font-size: 0.95rem; color: #718096; line-height: 1.6; } @media (max-width: 600px) { .app-title { font-size: 2rem; } .app-subtitle { font-size: 1rem; } .card-container { padding: 20px; } .upload-text { font-size: 1.1rem; } .process-btn, .download-btn { padding: 16px; font-size: 1rem; } } </style> </head> <body> <div class="header"> <h1 class="app-title">PDF 分割工具</h1> <p class="app-subtitle">精确分割PDF页面 · 坐标参数修复版</p> </div> <div class="card-container"> <div class="upload-section"> <div class="upload-icon"> <i class="fas fa-file-pdf"></i> </div> <p class="upload-text">一键分割PDF页面</p> <p class="upload-subtext">将PDF文档的每个页面精确分割为左右两半 - 无需上传服务器,100%隐私保护</p> <button class="upload-area" id="uploadTrigger"> <i class="fas fa-cloud-upload-alt"></i> 选择PDF文件 <input type="file" id="fileInput" class="file-input" accept=".pdf,application/pdf"> </button> </div> <div class="preview-section" id="previewSection"> <div class="file-preview"> <div class="file-icon"> <i class="fas fa-file"></i> </div> <div class="file-info"> <div class="file-name" id="fileName">未选择文件</div> <div class="file-size" id="fileSize">-</div> </div> </div> <div class="action-section"> <button class="process-btn" id="processBtn" disabled> <i class="fas fa-cut"></i> 开始分割PDF </button> <button class="download-btn" id="downloadBtn" disabled> <i class="fas fa-download"></i> 下载处理结果 </button> </div> </div> <div class="progress-section" id="progressSection"> <div class="progress-header"> <span>处理进度</span> <span id="progressText">0%</span> </div> <div class="progress-container"> <div class="progress-bar" id="progressBar"></div> </div> </div> <div class="status-section" id="statusSection"> <div class="status-icon"> <i class="fas fa-info-circle"></i> </div> <div class="status-message" id="statusMessage"> 请上传PDF文件开始处理 </div> </div> <div class="coordinate-guide"> <div class="coordinate-title"> <i class="fas fa-drafting-compass"></i> PDF页面绘制坐标解释 </div> <div class="coordinate-container"> <div class="coordinate-card"> <div class="coordinate-header"> <div class="axis-icon"> <i class="fas fa-arrows-alt-h"></i> </div> <div class="coordinate-name">X轴参数 (x)</div> </div> <div class="coordinate-info"> <p>表示绘制位置的<strong>水平偏移量</strong>。</p> <p>正值为向右偏移,负值为向左偏移。</p> <p>在分割PDF页面时,它决定了页面内容在水平方向的位置:</p> <div class="coordinate-values"> // 左半部分:x = 0<br> // 右半部分:x = -原始宽度 / 2 </div> <p class="coordinate-formula">公式:x = -水平方向移动像素</p> </div> </div> <div class="coordinate-card"> <div class="coordinate-header"> <div class="axis-icon"> <i class="fas fa-arrows-alt-v"></i> </div> <div class="coordinate-name">Y轴参数 (y)</div> </div> <div class="coordinate-info"> <p>表示绘制位置的<strong>垂直偏移量</strong>。</p> <p>正值为向上偏移,负值为向下偏移。</p> <p>在分割PDF页面时,通常设置为0,表示没有垂直方向偏移。</p> <div class="coordinate-values"> // 默认值:y = 0 </div> <p class="coordinate-formula">公式:y = -垂直方向移动像素</p> </div> </div> <div class="coordinate-card"> <div class="coordinate-header"> <div class="axis-icon"> <i class="fas fa-expand"></i> </div> <div class="coordinate-name">宽度与高度 (width/height)</div> </div> <div class="coordinate-info"> <p>表示绘制内容的<strong>尺寸</strong>。</p> <p>它决定了绘制的PDF内容区域有多大。</p> <p>在分割PDF页面时,它应设置为原始页面的实际尺寸:</p> <div class="coordinate-values"> // 宽度:原始页面的宽度<br> // 高度:原始页面的高度 </div> <p class="coordinate-formula">尺寸不会改变,仅控制裁剪区域</p> </div> </div> </div> <div style="margin-top: 20px; text-align: center;"> <p style="font-weight: 600; color: var(--primary);">关键修复:在绘制右半部分时,正确的水平偏移应为x = -width / 2<br> 这样可以将右半部分内容正确对齐到新页面的左侧</p> </div> </div> <div class="feature-info"> <div class="feature-card"> <div class="feature-icon"> <i class="fas fa-shield-alt"></i> </div> <div class="feature-title">隐私保护</div> <div class="feature-content"> 所有处理在浏览器内完成,文件不会上传到任何服务器, 确保您的文档安全性和隐私保护。 </div> </div> <div class="feature-card"> <div class="feature-icon"> <i class="fas fa-project-diagram"></i> </div> <div class="feature-title">坐标问题修复</div> <div class="feature-content"> 修正了绘制坐标问题,确保左右半部分正确显示, 并保持原始页面顺序(左半部分在右半部分前)。 </div> </div> <div class="feature-card"> <div class="feature-icon"> <i class="fas fa-mobile-alt"></i> </div> <div class="feature-title">移动端优化</div> <div class="feature-content"> 针对移动设备优化了界面和交互,完美支持手机和平板电脑操作, 触控友好。 </div> </div> </div> </div> <script> // PDF相关变量 let pdfBytes = null; let processedPdfBytes = null; let filename = ''; let originalPdfBuffer = null; // DOM元素引用 const uploadTrigger = document.getElementById('uploadTrigger'); const fileInput = document.getElementById('fileInput'); const previewSection = document.getElementById('previewSection'); const fileName = document.getElementById('fileName'); const fileSize = document.getElementById('fileSize'); const processBtn = document.getElementById('processBtn'); const downloadBtn = document.getElementById('downloadBtn'); const progressSection = document.getElementById('progressSection'); const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); const statusSection = document.getElementById('statusSection'); const statusMessage = document.getElementById('statusMessage'); // 设置状态消息 function setStatus(message, type = 'info') { let icon = ''; let className = ''; switch(type) { case 'success': icon = '<i class="fas fa-check-circle">'; className = 'success'; break; case 'error': icon = '<i class="fas fa-exclamation-circle">'; className = 'error'; break; case 'processing': icon = '<i class="fas fa-cog fa-spin">'; className = 'info'; break; default: icon = '<i class="fas fa-info-circle">'; className = 'info'; } statusSection.innerHTML = ` <div class="status-icon">${icon}</i></div> <div class="status-message ${className}">${message}</div> `; } // 更新进度条 function updateProgress(percent) { const p = Math.min(100, Math.max(0, Math.floor(percent))); progressBar.style.width = `${p}%`; progressText.textContent = `${p}%`; } // 工具函数:格式化文件大小 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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } // 文件处理函数 function handleFileSelect(file) { // 验证文件类型 if (file.type !== 'application/pdf') { setStatus('请选择PDF文件', 'error'); return; } // 文件大小检查 if (file.size > 25 * 1024 * 1024) { setStatus('文件大小不能超过25MB', 'error'); return; } // 更新UI显示文件名 filename = file.name.replace('.pdf', '') + '_分割.pdf'; fileName.textContent = file.name; fileSize.textContent = formatFileSize(file.size); // 显示预览区 previewSection.style.display = 'flex'; processBtn.disabled = false; setStatus('PDF文件已加载,点击"开始分割PDF"进行处理', 'success'); const reader = new FileReader(); reader.onload = function(e) { try { // 保存原始ArrayBuffer供后续使用 originalPdfBuffer = e.target.result; // 使用slice创建缓冲区的副本 const bufferCopy = originalPdfBuffer.slice(0); pdfBytes = new Uint8Array(bufferCopy); } catch (error) { setStatus('读取文件错误: ' + error.message, 'error'); } }; reader.onerror = function() { setStatus('读取文件失败', 'error'); }; reader.readAsArrayBuffer(file); } // 处理PDF按钮(已修复坐标问题) processBtn.addEventListener('click', async function() { if (!pdfBytes) { setStatus('请先选择PDF文件', 'error'); return; } try { // 禁用按钮 processBtn.disabled = true; downloadBtn.disabled = true; progressSection.style.display = 'block'; setStatus('PDF处理中...这可能需要一些时间', 'processing'); // 显示初始进度 updateProgress(10); // 使用原始缓冲区重新创建Uint8Array const activePdfBytes = new Uint8Array(originalPdfBuffer.slice(0)); // 加载PDF文档 const pdfDoc = await PDFLib.PDFDocument.load(activePdfBytes, { ignoreEncryption: true }); const numPages = pdfDoc.getPageCount(); const newPdfDoc = await PDFLib.PDFDocument.create(); // 循环处理每一页 for (let i = 0; i < numPages; i++) { updateProgress(15 + Math.floor((i / numPages) * 80)); // 获取原始页面 const originalPage = pdfDoc.getPage(i); const { width, height } = originalPage.getSize(); // 嵌入页面内容到新文档 const embeddedPage = await newPdfDoc.embedPage(originalPage); // 创建左半部分页面 const leftPage = newPdfDoc.addPage([width / 2, height]); // 创建右半部分页面 const rightPage = newPdfDoc.addPage([width / 2, height]); // 绘制左半部分(修复:移除x偏移) leftPage.drawPage(embeddedPage, { x: 0, // 从新页面的0位置开始绘制 y: 0, width: width, height: height }); // 绘制右半部分(修复:x = -width / 2) rightPage.drawPage(embeddedPage, { x: -width / 2, // 向左移动一半宽度,显示右边内容 y: 0, width: width, height: height }); } // 生成处理后的PDF updateProgress(95); setStatus('正在生成最终PDF文件...', 'processing'); processedPdfBytes = await newPdfDoc.save(); // 更新UI updateProgress(100); setStatus('PDF处理完成!点击"下载处理结果"按钮获取文件', 'success'); downloadBtn.disabled = false; } catch (error) { console.error('处理PDF时出错:', error); if (error.message.includes('out of memory')) { setStatus('文件太大导致内存不足。请尝试小于25MB的文件', 'error'); } else if (error.message.includes('encrypted')) { setStatus('加密的PDF文件无法处理。请先解密文件', 'error'); } else if (error.message.includes('Page')) { setStatus('页面格式不受支持,请尝试其他PDF文件', 'error'); } else { setStatus('处理失败: ' + error.message, 'error'); } } finally { processBtn.disabled = false; } }); // 下载按钮事件 downloadBtn.addEventListener('click', function() { if (!processedPdfBytes) { setStatus('没有可下载的文件内容', 'error'); return; } try { const blob = new Blob([processedPdfBytes], { type: 'application/pdf' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); setStatus('文件已开始下载', 'success'); } catch (error) { setStatus('下载失败: ' + error.message, 'error'); } }); // 文件输入处理 fileInput.addEventListener('change', function(e) { if (e.target.files.length > 0) { handleFileSelect(e.target.files[0]); } }); // 点击上传区域触发文件选择 uploadTrigger.addEventListener('click', function() { fileInput.click(); }); // 拖放功能 document.addEventListener('dragover', function(e) { e.preventDefault(); }); document.addEventListener('drop', function(e) { e.preventDefault(); if (e.dataTransfer.files.length > 0) { handleFileSelect(e.dataTransfer.files[0]); } }); // 初始化状态 setStatus('准备就绪,请上传PDF文件开始处理'); </script> </body> </html>

#Tips

想要调整分割比例的,个人觉得关键点在这里微调。
javascript
// 循环处理每一页 for (let i = 0; i < numPages; i++) { updateProgress(15 + Math.floor((i / numPages) * 80)); // 获取原始页面 const originalPage = pdfDoc.getPage(i); const { width, height } = originalPage.getSize(); // 嵌入页面内容到新文档 const embeddedPage = await newPdfDoc.embedPage(originalPage); // 创建左半部分页面 const leftPage = newPdfDoc.addPage([width / 2, height]); // 创建右半部分页面 const rightPage = newPdfDoc.addPage([width / 2, height]); // 绘制左半部分(修复:移除x偏移) leftPage.drawPage(embeddedPage, { x: 0, // 从新页面的0位置开始绘制 y: 0, width: width, height: height }); // 绘制右半部分(修复:x = -width / 2) rightPage.drawPage(embeddedPage, { x: -width / 2, // 向左移动一半宽度,显示右边内容 y: 0, width: width, height: height }); }

#Doc

关于x、y、​​width​​、​​height​​说明,被ai忽悠了好几次,一开始说是页面左下角,对于不懂前端的小白来说,只能用他的矛攻击他的盾了
参数相对坐标系作用示例值视觉效果
​​x​​源页面坐标系将源页面内容在水平方向平移-width/2向左移动一半页面宽度
​​y​​源页面坐标系将源页面内容在垂直方向平移0不进行垂直移动
​​width​​目标页面坐标系绘制的区域宽度width使用源页面的原始宽度
​​height​​目标页面坐标系绘制的区域高度height使用源页面的原始高度