Appearance
实现 PDF工具
分类:developer | 标签:PDF、合并、拆分、水印、工具 在线PDF处理,支持合并、拆分提取、旋转、删除页面、水印、图片转PDF、PDF转图片、页面排序
2.1 功能说明
在线PDF处理,支持合并、拆分提取、旋转、删除页面、水印、图片转PDF、PDF转图片、页面排序
核心能力
- ✨ 增强处理
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
本工具目录 PdfTool/ 下除 index.vue 外,还包含以下源文件:
DeleteTool.vue(172 行)ExtractMergeTool.vue(390 行)FuncButtons.vue(50 行)HelpTab.vue(77 行)ImageToPdfTool.vue(265 行)MergeTool.vue(186 行)PdfResultPreview.vue(238 行)PdfToImageTool.vue(255 行)ReorderTool.vue(175 行)RotateTool.vue(184 行)WatermarkTool.vue(231 行)pdfRenderer.js(55 行)pdfUtils.js(235 行)
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
goBack()onLoading()formatPageNo()hexToRgb()pickMoreFile()resetMore()resetResize()onMoreFile()outName()runAddPageNumbers()runResize()loadPdfJs()canvasToBlob()runCompress()downloadCompressed()runExtractText()copyText()downloadText()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, markRaw, provide } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Loading, Upload } from '@element-plus/icons-vue'
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import { formatFileSize, downloadPdf } from './pdfUtils'
import FeedbackForm from '@/components/FeedbackForm.vue'
import MergeTool from './MergeTool.vue'
import RotateTool from './RotateTool.vue'
import DeleteTool from './DeleteTool.vue'
import WatermarkTool from './WatermarkTool.vue'
import ImageToPdfTool from './ImageToPdfTool.vue'
import PdfToImageTool from './PdfToImageTool.vue'
import ReorderTool from './ReorderTool.vue'
import ExtractMergeTool from './ExtractMergeTool.vue'
import HelpTab from './HelpTab.vue'
const router = useRouter()
const activeTab = ref('editor')
const activeFunc = ref('merge')
const globalLoading = ref(false)
const functions = [
{ key: 'merge', icon: '📄', label: '合并' },
{ key: 'extractMerge', icon: '📑', label: '批量提取' },
{ key: 'rotate', icon: '🔄', label: '旋转' },
{ key: 'delete', icon: '🗑️', label: '删除' },
{ key: 'watermark', icon: '💧', label: '水印' },
{ key: 'image2pdf', icon: '🖼️', label: '转PDF' },
{ key: 'pdf2image', icon: '📷', label: '转图片' },
{ key: 'reorder', icon: '📋', label: '排序' },
]
const toolComponents = {
merge: markRaw(MergeTool),
extractMerge: markRaw(ExtractMergeTool),
rotate: markRaw(RotateTool),
delete: markRaw(DeleteTool),
watermark: markRaw(WatermarkTool),
image2pdf: markRaw(ImageToPdfTool),
pdf2image: markRaw(PdfToImageTool),
reorder: markRaw(ReorderTool),
}
const goBack = () => {
router.push('/developer')
}
const onLoading = (val) => {
globalLoading.value = val
}
provide('pdfActiveFunc', activeFunc)
provide('pdfFunctions', functions)
/* ==================== 增强处理:页码 / 缩放裁边 / 压缩 / 提取文本 ==================== */
const MM_TO_PT = 72 / 25.4
const PAPER_SIZES = {
A4: [595.28, 841.89],
A3: [841.89, 1190.55],
A5: [419.53, 595.28],
Letter: [612, 792],
Legal: [612, 1008]
}
const moreInput = ref(null)
const moreFile = ref(null)
const morePages = ref(0)
const moreError = ref('')
const moreFunc = ref('pageno')
const busy = ref(false)
const progress = ref(0)
const pn = ref({
format: 'n/N',
position: 'bottom-center',
fontSize: 10,
margin: 28,
startAt: 1,
skip: 0,
color: '#606266'
})
const rs = ref({
mode: 'scale',
scale: 100,
preset: 'A4',
landscape: false,
top: 0,
bottom: 0,
left: 0,
right: 0
})
const cp = ref({ dpi: 110, quality: 75, grayscale: false })
const compressResult = ref(null)
let compressedBytes = null
const tx = ref({ pageMark: true, joinLines: true })
const textResult = ref('')
const textEmpty = ref(false)
const textStat = ref({ pages: 0, chars: 0, lines: 0 })
const pnPreview = computed(() => formatPageNo(pn.value.format, pn.value.startAt, morePages.value || 12))
function formatPageNo(fmt, n, total) {
switch (fmt) {
case 'n':
return String(n)
case 'n/N':
return `${n} / ${total}`
case 'Page n':
return `Page ${n}`
case 'Page n of N':
return `Page ${n} of ${total}`
case '- n -':
return `- ${n} -`
default:
return String(n)
}
}
function hexToRgb(hex) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '')
if (!m) return rgb(0.38, 0.38, 0.4)
return rgb(parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255)
}
function pickMoreFile() {
moreInput.value?.click()
}
function resetMore() {
moreFile.value = null
morePages.value = 0
moreError.value = ''
compressResult.value = null
compressedBytes = null
textResult.value = ''
textEmpty.value = false
}
function resetResize() {
rs.value = { mode: 'scale', scale: 100, preset: 'A4', landscape: false, top: 0, bottom: 0, left: 0, right: 0 }
}
async function onMoreFile(e) {
const f = (e.target.files || [])[0]
e.target.value = ''
if (!f) return
resetMore()
if (!/\.pdf$/i.test(f.name)) {
moreError.value = '请选择 PDF 文件'
return
}
try {
const doc = await PDFDocument.load(await f.arrayBuffer(), { ignoreEncryption: true })
morePages.value = doc.getPageCount()
moreFile.value = f
} catch (err) {
moreError.value = '无法读取该 PDF:' + (err && err.message ? err.message : String(err))
}
}
function outName(suffix) {
const base = (moreFile.value?.name || 'document').replace(/\.pdf$/i, '')
return `${base}${suffix}.pdf`
}
/* ---------- ① 添加页码 ---------- */
async function runAddPageNumbers() {
if (!moreFile.value) return
busy.value = true
globalLoading.value = true
moreError.value = ''
try {
const doc = await PDFDocument.load(await moreFile.value.arrayBuffer(), { ignoreEncryption: true })
const font = await doc.embedFont(StandardFonts.Helvetica)
const pages = doc.getPages()
const o = pn.value
const color = hexToRgb(o.color)
const numbered = pages.length - o.skip
for (let i = o.skip; i < pages.length; i++) {
const page = pages[i]
const { width, height } = page.getSize()
const label = formatPageNo(o.format, o.startAt + (i - o.skip), numbered)
const tw = font.widthOfTextAtSize(label, o.fontSize)
let x
if (o.position.endsWith('left')) x = o.margin
else if (o.position.endsWith('right')) x = width - o.margin - tw
else x = (width - tw) / 2
const y = o.position.startsWith('top') ? height - o.margin - o.fontSize : o.margin
page.drawText(label, { x, y, size: o.fontSize, font, color })
}
downloadPdf(await doc.save(), outName('-页码'))
ElMessage.success(`已为 ${numbered} 页添加页码`)
} catch (err) {
moreError.value = '添加页码失败:' + (err && err.message ? err.message : String(err))
} finally {
busy.value = false
globalLoading.value = false
}
}
/* ---------- ② 尺寸缩放与裁边 ---------- */
async function runResize() {
if (!moreFile.value) return
const o = rs.value
if (o.mode === 'none' && !o.top && !o.bottom && !o.left && !o.right) {
ElMessage.warning('请至少设置缩放比例或裁边数值')
return
}
busy.value = true
globalLoading.value = true
moreError.value = ''
try {
const doc = await PDFDocument.load(await moreFile.value.arrayBuffer(), { ignoreEncryption: true })
const pages = doc.getPages()
for (const page of pages) {
if (o.mode === 'scale') {
const k = o.scale / 100
// scale() 会同时缩放页面尺寸、内容与注释
page.scale(k, k)
} else if (o.mode === 'preset') {
const [pw, ph] = PAPER_SIZES[o.preset] || PAPER_SIZES.A4
const tw = o.landscape ? ph : pw
const th = o.landscape ? pw : ph
const { width, height } = page.getSize()
const k = Math.min(tw / width, th / height)
page.scaleContent(k, k)
page.scaleAnnotations(k, k)
// 缩放后内容贴在左下角,平移到纸张中央
page.translateContent((tw - width * k) / 2, (th - height * k) / 2)
page.setSize(tw, th)
}
// 裁边:收缩 CropBox(阅读器按此区域显示),内容本身保留
const t = o.top * MM_TO_PT
const b = o.bottom * MM_TO_PT
const l = o.left * MM_TO_PT
const r = o.right * MM_TO_PT
if (t || b || l || r) {
const { width, height } = page.getSize()
const cw = width - l - r
const ch = height - t - b
if (cw <= 1 || ch <= 1) throw new Error('裁边数值过大,裁剪后页面尺寸为零')
page.setCropBox(l, b, cw, ch)
}
}
downloadPdf(await doc.save(), outName('-调整尺寸'))
ElMessage.success(`已处理 ${pages.length} 页`)
} catch (err) {
moreError.value = '尺寸调整失败:' + (err && err.message ? err.message : String(err))
} finally {
busy.value = false
globalLoading.value = false
}
}
/* ---------- pdfjs 加载(与「转图片」功能保持一致的用法) ---------- */
async function loadPdfJs(arrayBuffer) {
const pdfjsLib = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).href
return pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }).promise
}
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('画布导出失败'))), type, quality)
})
}
/* ---------- ③ 图像重采样压缩 ---------- */
async function runCompress() {
if (!moreFile.value) return
busy.value = true
globalLoading.value = true
progress.value = 0
moreError.value = ''
compressResult.value = null
compressedBytes = null
try {
const srcBuf = await moreFile.value.arrayBuffer()
const pdf = await loadPdfJs(srcBuf.slice(0))
const out = await PDFDocument.create()
const o = cp.value
// pdfjs 默认 1.0 缩放对应 72 DPI
const zoom = o.dpi / 72
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: zoom })
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.floor(viewport.width))
canvas.height = Math.max(1, Math.floor(viewport.height))
const ctx = canvas.getContext('2d')
// JPEG 不支持透明,先铺白底
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
await page.render({ canvasContext: ctx, viewport }).promise
if (o.grayscale) {
const img = ctx.getImageData(0, 0, canvas.width, canvas.height)
const d = img.data
for (let p = 0; p < d.length; p += 4) {
const g = (d[p] * 299 + d[p + 1] * 587 + d[p + 2] * 114) / 1000
d[p] = d[p + 1] = d[p + 2] = g
}
ctx.putImageData(img, 0, 0)
}
const blob = await canvasToBlob(canvas, 'image/jpeg', o.quality / 100)
const jpg = await out.embedJpg(await blob.arrayBuffer())
// 以 72 DPI 的原始尺寸放置,保证页面物理大小不变
const base = page.getViewport({ scale: 1 })
const newPage = out.addPage([base.width, base.height])
newPage.drawImage(jpg, { x: 0, y: 0, width: base.width, height: base.height })
progress.value = Math.round((i / pdf.numPages) * 100)
}
const bytes = await out.save()
compressedBytes = bytes
const before = moreFile.value.size
const after = bytes.length
compressResult.value = {
before,
after,
ratio: Math.abs(((after - before) / before) * 100).toFixed(1)
}
ElMessage.success('压缩完成,请核对体积变化后再下载')
} catch (err) {
moreError.value = '压缩失败:' + (err && err.message ? err.message : String(err))
} finally {
busy.value = false
globalLoading.value = false
}
}
function downloadCompressed() {
if (!compressedBytes) return
downloadPdf(compressedBytes, outName('-压缩'))
}
/* ---------- ④ 提取文本 ---------- */
async function runExtractText() {
if (!moreFile.value) return
busy.value = true
globalLoading.value = true
progress.value = 0
moreError.value = ''
textResult.value = ''
textEmpty.value = false
try {
const buf = await moreFile.value.arrayBuffer()
const pdf = await loadPdfJs(buf)
const chunks = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
// 按 Y 坐标聚合成行:同一行的片段 Y 值接近
const lines = []
let lastY = null
let cur = ''
for (const item of content.items) {
if (!item.str) continue
const y = item.transform ? item.transform[5] : 0
if (lastY !== null && Math.abs(y - lastY) > 2) {
lines.push(cur)
cur = ''
}
cur += item.str
if (item.hasEOL) {
lines.push(cur)
cur = ''
lastY = null
continue
}
lastY = y
}
if (cur) lines.push(cur)
let pageText = lines.map((l) => l.replace(/\s+$/, '')).filter((l) => l.length).join('\n')
if (tx.value.joinLines) {
// 段落内换行合并:行尾不是句末标点则与下一行相连
pageText = pageText.replace(/([^\n。..!!??;;::”"』」\]))])\n(?![\s\d]*[-•·])/g, '$1')
}
if (tx.value.pageMark) chunks.push(`--- 第 ${i} 页 ---\n${pageText}`)
else chunks.push(pageText)
progress.value = Math.round((i / pdf.numPages) * 100)
}
const all = chunks.join('\n\n').trim()
if (!all || !all.replace(/---\s*第\s*\d+\s*页\s*---/g, '').trim()) {
textEmpty.value = true
ElMessage.warning('未提取到文本内容')
} else {
textResult.value = all
textStat.value = {
pages: pdf.numPages,
chars: all.length,
lines: all.split('\n').length
}
ElMessage.success('文本提取完成')
}
} catch (err) {
moreError.value = '文本提取失败:' + (err && err.message ? err.message : String(err))
} finally {
busy.value = false
globalLoading.value = false
}
}
function copyText() {
if (!textResult.value) return
navigator.clipboard
?.writeText(textResult.value)
.then(() => ElMessage.success('已复制到剪贴板'))
.catch(() => ElMessage.error('复制失败,请手动选择文本'))
}
function downloadText() {
if (!textResult.value) return
const blob = new Blob([textResult.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = (moreFile.value?.name || 'document').replace(/\.pdf$/i, '') + '.txt'
a.click()
URL.revokeObjectURL(url)
}2.3 效果截图
