232 lines
5.9 KiB
Vue
232 lines
5.9 KiB
Vue
<template>
|
||
<div class="pdf-stamper">
|
||
<div class="toolbar">
|
||
<el-button size="mini" @click="prevPage" :disabled="pageNo <= 1">上一页</el-button>
|
||
<span class="page">第 {{ pageNo }} / {{ pageCount }} 页</span>
|
||
<el-button size="mini" @click="nextPage" :disabled="pageNo >= pageCount">下一页</el-button>
|
||
<span class="split"></span>
|
||
<el-input-number size="mini" v-model="stampWidth" :min="10" :max="600" :step="5" />
|
||
<span class="wh">宽</span>
|
||
<el-input-number size="mini" v-model="stampHeight" :min="10" :max="600" :step="5" />
|
||
<span class="wh">高</span>
|
||
<span class="hint">在 PDF 上单击选择盖章位置(以页面左下为原点)</span>
|
||
</div>
|
||
|
||
<div class="canvas-wrap" ref="wrap" @click="onClick">
|
||
<canvas ref="canvas"></canvas>
|
||
<div
|
||
v-if="hasStamp"
|
||
class="stamp-box"
|
||
:style="stampBoxStyle"
|
||
title="盖章位置预览"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import * as pdfjsLib from 'pdfjs-dist'
|
||
// worker
|
||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js'
|
||
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
|
||
|
||
export default {
|
||
name: 'PdfStamper',
|
||
props: {
|
||
/** PDF 的可访问 URL(OSS url) */
|
||
pdfUrl: {
|
||
type: String,
|
||
required: true
|
||
},
|
||
/** 初始页 */
|
||
initialPage: {
|
||
type: Number,
|
||
default: 1
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
loading: false,
|
||
pdf: null,
|
||
pageCount: 1,
|
||
pageNo: 1,
|
||
// 渲染缩放
|
||
scale: 1,
|
||
// pdf 页面像素尺寸(渲染后)
|
||
viewportWidth: 0,
|
||
viewportHeight: 0,
|
||
// 盖章尺寸(像素,基于当前 scale 渲染像素)
|
||
stampWidth: 160,
|
||
stampHeight: 160,
|
||
// 盖章位置(以 canvas 左上为原点的像素)
|
||
stampXTopLeft: 0,
|
||
stampYTopLeft: 0,
|
||
hasStamp: false
|
||
}
|
||
},
|
||
watch: {
|
||
pdfUrl: {
|
||
immediate: true,
|
||
handler() {
|
||
this.init()
|
||
}
|
||
},
|
||
pageNo() {
|
||
this.renderPage()
|
||
},
|
||
stampWidth() {
|
||
this.emitStamp()
|
||
},
|
||
stampHeight() {
|
||
this.emitStamp()
|
||
}
|
||
},
|
||
methods: {
|
||
async init() {
|
||
if (!this.pdfUrl) return
|
||
this.loading = true
|
||
this.hasStamp = false
|
||
try {
|
||
this.pdf = await pdfjsLib.getDocument({ url: this.pdfUrl }).promise
|
||
this.pageCount = this.pdf.numPages || 1
|
||
this.pageNo = Math.min(Math.max(this.initialPage, 1), this.pageCount)
|
||
await this.$nextTick()
|
||
await this.renderPage()
|
||
} catch (e) {
|
||
console.error('PDF加载失败', e)
|
||
this.$emit('error', e)
|
||
} finally {
|
||
this.loading = false
|
||
}
|
||
},
|
||
|
||
async renderPage() {
|
||
if (!this.pdf) return
|
||
const page = await this.pdf.getPage(this.pageNo)
|
||
const wrap = this.$refs.wrap
|
||
const canvas = this.$refs.canvas
|
||
const ctx = canvas.getContext('2d')
|
||
|
||
// 自适应容器宽度
|
||
const unscaledViewport = page.getViewport({ scale: 1 })
|
||
const maxWidth = (wrap && wrap.clientWidth) ? wrap.clientWidth : 900
|
||
this.scale = maxWidth / unscaledViewport.width
|
||
const viewport = page.getViewport({ scale: this.scale })
|
||
|
||
canvas.width = viewport.width
|
||
canvas.height = viewport.height
|
||
this.viewportWidth = viewport.width
|
||
this.viewportHeight = viewport.height
|
||
|
||
// 清空
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||
|
||
await page.render({ canvasContext: ctx, viewport }).promise
|
||
|
||
// 切页后清空盖章框(避免误用上一页坐标)
|
||
this.hasStamp = false
|
||
this.emitStamp()
|
||
},
|
||
|
||
prevPage() {
|
||
if (this.pageNo > 1) this.pageNo -= 1
|
||
},
|
||
nextPage() {
|
||
if (this.pageNo < this.pageCount) this.pageNo += 1
|
||
},
|
||
|
||
onClick(e) {
|
||
const rect = this.$refs.canvas.getBoundingClientRect()
|
||
const x = e.clientX - rect.left
|
||
const y = e.clientY - rect.top
|
||
|
||
// 让点击点成为盖章框中心
|
||
this.stampXTopLeft = Math.max(0, Math.min(x - this.stampWidth / 2, this.viewportWidth - this.stampWidth))
|
||
this.stampYTopLeft = Math.max(0, Math.min(y - this.stampHeight / 2, this.viewportHeight - this.stampHeight))
|
||
this.hasStamp = true
|
||
this.emitStamp()
|
||
},
|
||
|
||
emitStamp() {
|
||
// 输出给父组件的坐标必须是 PDFBox 期望的坐标:左下为原点
|
||
// 当前 stampXTopLeft / stampYTopLeft 是左上原点
|
||
const xPx = this.stampXTopLeft
|
||
const yPx = this.viewportHeight - this.stampYTopLeft - this.stampHeight
|
||
|
||
this.$emit('change', {
|
||
pageNo: this.pageNo,
|
||
xPx: Math.round(xPx),
|
||
yPx: Math.round(yPx),
|
||
// 位置换算需要:当前渲染后的 viewport 尺寸(像素)
|
||
viewportWidth: Math.round(this.viewportWidth),
|
||
viewportHeight: Math.round(this.viewportHeight),
|
||
// 方案B:后端严格用原图大小,这里不再强依赖 widthPx/heightPx(可留给预览)
|
||
widthPx: Math.round(this.stampWidth),
|
||
heightPx: Math.round(this.stampHeight),
|
||
ready: this.hasStamp
|
||
})
|
||
}
|
||
},
|
||
computed: {
|
||
stampBoxStyle() {
|
||
return {
|
||
width: this.stampWidth + 'px',
|
||
height: this.stampHeight + 'px',
|
||
left: this.stampXTopLeft + 'px',
|
||
top: this.stampYTopLeft + 'px'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pdf-stamper {
|
||
width: 100%;
|
||
}
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.page {
|
||
font-weight: 700;
|
||
color: #2b2f36;
|
||
}
|
||
.split {
|
||
width: 1px;
|
||
height: 16px;
|
||
background: #e6e8ed;
|
||
margin: 0 6px;
|
||
}
|
||
.wh {
|
||
font-size: 12px;
|
||
color: #606266;
|
||
}
|
||
.hint {
|
||
font-size: 12px;
|
||
color: #8a8f99;
|
||
}
|
||
.canvas-wrap {
|
||
position: relative;
|
||
border: 1px solid #e6e8ed;
|
||
border-radius: 8px;
|
||
overflow: auto;
|
||
background: #fff;
|
||
}
|
||
canvas {
|
||
display: block;
|
||
}
|
||
.stamp-box {
|
||
position: absolute;
|
||
border: 2px dashed #e6a23c;
|
||
box-sizing: border-box;
|
||
pointer-events: none;
|
||
background: rgba(230, 162, 60, 0.08);
|
||
}
|
||
</style>
|
||
|