Files
klp-oa/klp-ui/src/components/PdfStamper/index.vue
2025-12-30 13:47:53 +08:00

232 lines
5.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>