Skip to content

Commit 5550806

Browse files
committed
feat: 打印水印功能 (Issue #73)
支持在打印时添加自定义文字水印,用于身份证等敏感文档的保护。 后端用 gofpdf 生成铺满斜向半透明水印的 PDF,再用 pdfcpu 叠加到目标文档; 前端在预览区用 Canvas 叠加层实时显示水印效果。
1 parent 7879624 commit 5550806

6 files changed

Lines changed: 182 additions & 4 deletions

File tree

cmd/server/print_handlers.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"path/filepath"
1111
"strconv"
12+
"strings"
1213
"time"
1314

1415
"cups-web/internal/auth"
@@ -62,6 +63,7 @@ func printHandler(w http.ResponseWriter, r *http.Request) {
6263
pageRange := r.FormValue("page_range")
6364
pageSet := r.FormValue("page_set")
6465
mirror := r.FormValue("mirror") == "true"
66+
watermarkText := strings.TrimSpace(r.FormValue("watermark_text"))
6567

6668
var saveHistory bool
6769
if err := appStore.WithTx(r.Context(), true, func(tx *sql.Tx) error {
@@ -211,6 +213,16 @@ func printHandler(w http.ResponseWriter, r *http.Request) {
211213
defer printCleanup()
212214
}
213215

216+
if watermarkText != "" && printMime == "application/pdf" {
217+
wmPath, wmCleanup, wmErr := applyWatermarkToPDF(printPath, watermarkText)
218+
if wmErr != nil {
219+
log.Printf("[print] watermark failed: %v", wmErr)
220+
} else {
221+
defer wmCleanup()
222+
printPath = wmPath
223+
}
224+
}
225+
214226
// Handle even-reverse: reorder PDF pages for manual duplex (even pages
215227
// in reverse order, with a blank page prepended when total is odd).
216228
if pageSet == "even-reverse" && printMime == "application/pdf" && pages > 1 {

cmd/server/watermark.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/pdfcpu/pdfcpu/pkg/api"
10+
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
11+
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
12+
"github.com/phpdave11/gofpdf"
13+
)
14+
15+
func generateWatermarkPDF(text string) (string, func(), error) {
16+
tmpDir, err := os.MkdirTemp("", "cups-web-watermark-*")
17+
if err != nil {
18+
return "", nil, fmt.Errorf("create temp dir: %w", err)
19+
}
20+
cleanup := func() { os.RemoveAll(tmpDir) }
21+
22+
pdf := gofpdf.New("P", "mm", "A4", "")
23+
pdf.SetMargins(0, 0, 0)
24+
pdf.SetAutoPageBreak(false, 0)
25+
pdf.AddPage()
26+
27+
if err := setPdfTextFont(pdf, 28); err != nil {
28+
cleanup()
29+
return "", nil, fmt.Errorf("load font: %w", err)
30+
}
31+
32+
pdf.SetTextColor(180, 180, 180)
33+
pdf.SetAlpha(0.15, "")
34+
35+
pageW, pageH := pdf.GetPageSize()
36+
textW := pdf.GetStringWidth(text)
37+
if textW < 10 {
38+
textW = 10
39+
}
40+
41+
stepX := textW + 30
42+
stepY := 45.0
43+
diag := math.Sqrt(pageW*pageW + pageH*pageH)
44+
45+
for y := -diag / 2; y < pageH+diag/2; y += stepY {
46+
for x := -diag / 2; x < pageW+diag/2; x += stepX {
47+
pdf.TransformBegin()
48+
pdf.TransformRotate(45, x, y)
49+
pdf.Text(x, y, text)
50+
pdf.TransformEnd()
51+
}
52+
}
53+
54+
outPath := filepath.Join(tmpDir, "watermark.pdf")
55+
if err := pdf.OutputFileAndClose(outPath); err != nil {
56+
cleanup()
57+
return "", nil, fmt.Errorf("write watermark pdf: %w", err)
58+
}
59+
return outPath, cleanup, nil
60+
}
61+
62+
func applyWatermarkToPDF(inputPath, watermarkText string) (string, func(), error) {
63+
wmPath, wmCleanup, err := generateWatermarkPDF(watermarkText)
64+
if err != nil {
65+
return "", nil, fmt.Errorf("generate watermark: %w", err)
66+
}
67+
defer wmCleanup()
68+
69+
wmFile, err := os.Open(wmPath)
70+
if err != nil {
71+
return "", nil, fmt.Errorf("open watermark pdf: %w", err)
72+
}
73+
defer wmFile.Close()
74+
75+
wm, err := api.PDFWatermarkForReadSeeker(wmFile, 1, "scalefactor:1 rel, opacity:1", true, false, types.POINTS)
76+
if err != nil {
77+
return "", nil, fmt.Errorf("create watermark config: %w", err)
78+
}
79+
80+
tmpDir, err := os.MkdirTemp("", "cups-web-wm-apply-*")
81+
if err != nil {
82+
return "", nil, fmt.Errorf("create output temp dir: %w", err)
83+
}
84+
outCleanup := func() { os.RemoveAll(tmpDir) }
85+
86+
outPath := filepath.Join(tmpDir, "watermarked.pdf")
87+
88+
conf := model.NewDefaultConfiguration()
89+
conf.ValidationMode = model.ValidationRelaxed
90+
91+
if err := api.AddWatermarksFile(inputPath, outPath, nil, wm, conf); err != nil {
92+
outCleanup()
93+
return "", nil, fmt.Errorf("apply watermark: %w", err)
94+
}
95+
96+
return outPath, outCleanup, nil
97+
}

frontend/src/components/print/PdfCanvas.vue

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
</div>
1010
<div v-show="!loading && !error" class="relative w-full h-full flex items-center justify-center">
1111
<canvas ref="canvas" class="max-w-full max-h-full" />
12+
<canvas ref="watermarkCanvas" class="absolute top-0 left-0 pointer-events-none" />
1213
<div v-if="totalPages > 1 && !loading && !error" class="absolute bottom-2 left-1/2 -translate-x-1/2 flex flex-nowrap items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 bg-black/40 rounded-full w-max max-w-[90%] whitespace-nowrap" style="backdrop-filter: blur(4px)">
1314
<UButton size="xs" variant="ghost" color="white" icon="i-lucide-chevron-left" :disabled="currentPage <= 1" class="flex-shrink-0" @click="prevPage" />
1415
<span class="text-xs text-white whitespace-nowrap flex-shrink-0">{{ currentPage }} / {{ totalPages }}</span>
@@ -26,13 +27,15 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
2627
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
2728
2829
const props = defineProps({
29-
src: { type: String, required: true }
30+
src: { type: String, required: true },
31+
watermarkText: { type: String, default: '' }
3032
})
3133
3234
// 预览失败时通知父组件,便于在外层展示"不影响打印"的提示
3335
const emit = defineEmits(['preview-failed'])
3436
3537
const canvas = ref(null)
38+
const watermarkCanvas = ref(null)
3639
const container = ref(null)
3740
const loading = ref(true)
3841
const error = ref(false)
@@ -52,6 +55,49 @@ let lastHeight = 0
5255
// 规则:renderPdf 入口自增 token,仅当捕获异常时 token 仍为当前值才写状态;过期请求静默丢弃。
5356
let requestToken = 0
5457
58+
function drawWatermark() {
59+
const wc = watermarkCanvas.value
60+
const pc = canvas.value
61+
if (!wc || !pc) return
62+
63+
const cssW = parseFloat(pc.style.width)
64+
const cssH = parseFloat(pc.style.height)
65+
if (!cssW || !cssH) return
66+
67+
const dpr = window.devicePixelRatio || 1
68+
wc.width = cssW * dpr
69+
wc.height = cssH * dpr
70+
wc.style.width = cssW + 'px'
71+
wc.style.height = cssH + 'px'
72+
73+
const ctx = wc.getContext('2d')
74+
ctx.clearRect(0, 0, wc.width, wc.height)
75+
76+
if (!props.watermarkText) return
77+
78+
ctx.save()
79+
ctx.scale(dpr, dpr)
80+
ctx.globalAlpha = 0.15
81+
ctx.fillStyle = '#b4b4b4'
82+
const fontSize = Math.max(14, cssW * 0.06)
83+
ctx.font = `${fontSize}px sans-serif`
84+
85+
const textWidth = ctx.measureText(props.watermarkText).width
86+
const stepX = textWidth + 30
87+
const stepY = fontSize * 2.5
88+
const diag = Math.sqrt(cssW * cssW + cssH * cssH)
89+
90+
ctx.translate(cssW / 2, cssH / 2)
91+
ctx.rotate(-45 * Math.PI / 180)
92+
93+
for (let y = -diag; y < diag; y += stepY) {
94+
for (let x = -diag; x < diag; x += stepX) {
95+
ctx.fillText(props.watermarkText, x, y)
96+
}
97+
}
98+
ctx.restore()
99+
}
100+
55101
async function renderPage(pageNum) {
56102
if (!pdfDoc || !canvas.value) return
57103
@@ -95,6 +141,7 @@ async function renderPage(pageNum) {
95141
96142
await renderTask.promise
97143
renderTask = null
144+
drawWatermark()
98145
} catch (e) {
99146
if (e?.name === 'RenderingCancelledException') return
100147
throw e
@@ -178,6 +225,10 @@ watch(() => props.src, () => {
178225
}
179226
})
180227
228+
watch(() => props.watermarkText, () => {
229+
drawWatermark()
230+
})
231+
181232
onMounted(() => {
182233
if (props.src) renderPdf()
183234

frontend/src/components/print/PrintOptions.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@
111111
<span class="text-sm">水平镜像翻转</span>
112112
</label>
113113
</UFormField>
114+
115+
<!-- 水印文字 -->
116+
<UFormField label="水印文字" hint="留空=不添加水印;如:仅供XX使用">
117+
<UInput
118+
:model-value="watermarkText"
119+
placeholder="留空=不添加水印"
120+
class="w-full"
121+
@update:model-value="$emit('update:watermarkText', $event)"
122+
/>
123+
</UFormField>
114124
</div>
115125
</div>
116126
</div>
@@ -132,13 +142,14 @@ const props = defineProps({
132142
pageRange: { type: String, default: '' },
133143
pageSet: { type: String, default: 'all' },
134144
mirror: { type: Boolean, default: false },
145+
watermarkText: { type: String, default: '' },
135146
printing: { type: Boolean, default: false }
136147
})
137148
138149
const emit = defineEmits([
139150
'update:isColor', 'update:duplex', 'update:copies',
140151
'update:paperSize', 'update:paperType', 'update:printScaling', 'update:pageRange',
141-
'update:pageSet', 'update:mirror'
152+
'update:pageSet', 'update:mirror', 'update:watermarkText'
142153
])
143154
144155
const showAdvanced = ref(localStorage.getItem('print_options_expanded') === '1')
@@ -154,6 +165,7 @@ const advancedSummary = computed(() => {
154165
const pageSetLabel = pageSetItems.find(i => i.value === props.pageSet)?.label
155166
if (props.pageSet && props.pageSet !== 'all' && pageSetLabel) parts.push(pageSetLabel)
156167
if (props.mirror) parts.push('镜像')
168+
if (props.watermarkText) parts.push(`水印: ${props.watermarkText}`)
157169
return parts.join(' / ')
158170
})
159171

frontend/src/components/print/PrintPreview.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
class="bg-white shadow-lg border border-default overflow-hidden transition-all duration-300 ease-in-out relative mx-auto"
3838
>
3939
<img v-if="previewType === 'image'" :src="previewUrl" class="w-full h-full object-contain" />
40-
<PdfCanvas v-else-if="previewType === 'pdf'" :src="previewUrl" @preview-failed="onPreviewFailed" />
40+
<PdfCanvas v-else-if="previewType === 'pdf'" :src="previewUrl" :watermark-text="watermarkText" @preview-failed="onPreviewFailed" />
4141
<div
4242
v-else-if="previewType === 'text'"
4343
class="p-3 text-[8px] leading-tight overflow-hidden h-full text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
@@ -72,7 +72,8 @@ const props = defineProps({
7272
orientation: { type: String, default: 'portrait' },
7373
orientationLabel: { type: String, default: '' },
7474
paperDimText: { type: String, default: '' },
75-
paperPreviewStyle: { type: Object, default: () => ({}) }
75+
paperPreviewStyle: { type: Object, default: () => ({}) },
76+
watermarkText: { type: String, default: '' }
7677
})
7778
7879
defineEmits(['update:orientation'])

frontend/src/views/PrintView.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@
190190
v-model:pageRange="pageRange"
191191
v-model:pageSet="pageSet"
192192
v-model:mirror="mirror"
193+
v-model:watermarkText="watermarkText"
193194
:printing="printing"
194195
/>
195196

@@ -222,6 +223,7 @@
222223
:orientation-label="orientationLabel"
223224
:paper-dim-text="paperDimText"
224225
:paper-preview-style="paperPreviewStyle"
226+
:watermark-text="watermarkText"
225227
/>
226228
</div>
227229
<PrintRecordList ref="recordListRef" :records="printRecords" :loading="loadingRecords" :printers="printers" :current-printer="printer" @refresh="loadPrintRecords" @reprint="handleReprint" />
@@ -283,6 +285,7 @@ const printScaling = ref('fit')
283285
const pageRange = ref('')
284286
const pageSet = ref('all')
285287
const mirror = ref(false)
288+
const watermarkText = ref('')
286289
287290
// ─── 打印模式 ─────────────────────────────────────────────
288291
const printMode = ref(localStorage.getItem('print_mode') || 'standard')
@@ -662,6 +665,7 @@ async function uploadAndPrintBatch() {
662665
if (pageRange.value.trim()) form.append('page_range', pageRange.value.trim())
663666
if (pageSet.value && pageSet.value !== 'all') form.append('page_set', pageSet.value)
664667
if (mirror.value) form.append('mirror', 'true')
668+
if (watermarkText.value.trim()) form.append('watermark_text', watermarkText.value.trim())
665669
666670
const resp = await apiFetch('/api/print', { method: 'POST', body: form }, () => emit('logout'))
667671
if (!resp.ok) throw new Error(await readError(resp))
@@ -830,6 +834,7 @@ async function uploadAndPrint() {
830834
if (pageRange.value.trim()) form.append('page_range', pageRange.value.trim())
831835
if (pageSet.value && pageSet.value !== 'all') form.append('page_set', pageSet.value)
832836
if (mirror.value) form.append('mirror', 'true')
837+
if (watermarkText.value.trim()) form.append('watermark_text', watermarkText.value.trim())
833838
834839
printing.value = true
835840
try {

0 commit comments

Comments
 (0)