Skip to content

实现 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-inputel-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 效果截图

PDF工具 效果截图

工具访问地址:https://www.i91tools.com/tools/pdf-tool