This commit is contained in:
2025-12-30 13:47:53 +08:00
parent f1637501b2
commit a623c5673f
137 changed files with 11031 additions and 4043 deletions

View File

@@ -0,0 +1,231 @@
<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 的可访问 URLOSS 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>