前端實(shí)現(xiàn)PDF與圖片添加自定義中文水印的詳細(xì)流程
前言
在日常開發(fā)中,為文檔和圖片添加版權(quán)水印是一項常見需求。本文將詳細(xì)介紹如何為PDF和圖片添加自定義中文水印的思路。

如圖所示,水印需要兩行,第一行是動態(tài)變化的,第二行是固定文案。所以還需要考慮文字長短從而改變每個水印之間的間距。
功能概述
這個方案提供了兩個核心函數(shù):
addWatermarkToPDF: 為PDF文件添加傾斜水印addWatermarkToImage: 為圖片文件添加網(wǎng)格狀水印
兩個函數(shù)都支持自定義文本內(nèi)容、字體大小、透明度、旋轉(zhuǎn)角度等參數(shù),特別優(yōu)化了對中文文本的處理。
核心實(shí)現(xiàn)原理
方案一:采用pdf-lib實(shí)現(xiàn)水印,但是它要支持中文水印的話,還需借助和@pdf-lib/fontkit庫來加載一個中文字體集實(shí)現(xiàn)。一般小一點(diǎn)的中文字體集都有5M左右,并且這種方案生成的水印可以復(fù)制,不是我想要的效果,于是pass了。
方案二:采用canvas生成水印,再結(jié)合pdf-lib繪制圖片的方式,嵌入到pdf中。這種方案不用導(dǎo)入字體庫,減去了加載字體的時間,所以速度比方案一快很多。
1. PDF水印實(shí)現(xiàn)
export async function addWatermarkToPDF(pdfUrl, companyName, personName, options = {}) {
// 參數(shù)配置
const {
fontSize = 30, // PDF點(diǎn)單位
opacity = 0.18,
angle = -45,
color = 'rgba(120,120,120,1)',
pixelRatio = Math.max(window.devicePixelRatio || 1, 2),
baseGap = 500, // 最小間隔
lineHeight = 1.4 // 行距
} = options;
// 構(gòu)建水印文本
const firstLine = companyName && personName ? `${companyName}-${personName}` : companyName || personName || '';
const secondLine = '僅用于xxxx使用,他用無效';
const lines = [firstLine, secondLine];
// 單位轉(zhuǎn)換函數(shù)
const ptToPx = pt => (pt * 96) / 72;
const pxToPt = px => (px * 72) / 96;
// 測量文本寬度
const cssFontSize = ptToPx(fontSize);
const measureCanvas = document.createElement('canvas');
const mctx = measureCanvas.getContext('2d');
mctx.font = `${cssFontSize}px sans-serif`;
const firstLineWidthCss = mctx.measureText(firstLine).width;
const firstLineWidthPt = pxToPt(firstLineWidthCss);
// 動態(tài)計算水印間隔
const xGap = Math.max(baseGap, firstLineWidthPt * 1.2);
const yGap = Math.max(250, fontSize * (lines.length + 1));
// 創(chuàng)建水印圖片
const padding = 10;
const lineHeighCss = cssFontSize * lineHeight;
const cssWidth = Math.max(...lines.map(l => mctx.measureText(l).width)) + padding * 2;
const cssHeight = lines.length * lineHeighCss + padding * 2;
// 避免生成的水印模糊
const canvas = document.createElement('canvas');
canvas.width = cssWidth * pixelRatio;
canvas.height = cssHeight * pixelRatio;
const ctx = canvas.getContext('2d');
ctx.scale(pixelRatio, pixelRatio);
ctx.font = `${cssFontSize}px sans-serif`;
ctx.fillStyle = color;
ctx.textBaseline = 'top';
// 繪制文本行
lines.forEach((line, i) => {
ctx.fillText(line, padding, padding + i * lineHeighCss);
});
const dataUrl = canvas.toDataURL('image/png');
// 使用pdf-lib處理PDF
const existingPdfBytes = await fetch(pdfUrl).then(r => r.arrayBuffer());
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pngImage = await pdfDoc.embedPng(dataUrl);
const pages = pdfDoc.getPages();
const imgWPt = pxToPt(cssWidth);
const imgHPt = pxToPt(cssHeight);
// 為每頁添加水印
for (const page of pages) {
const { width: pageW, height: pageH } = page.getSize();
const rotation = page.getRotation().angle || 0;
// 根據(jù)頁面旋轉(zhuǎn)調(diào)整水印角度
let finalAngle = angle;
if (rotation === 90) finalAngle = angle - 90;
else if (rotation === 270) finalAngle = angle + 90;
else if (rotation === 180) finalAngle = angle + 180;
// 平鋪水印
for (let x = -pageW; x < pageW * 2; x += xGap) {
for (let y = -pageH; y < pageH * 2; y += yGap) {
page.drawImage(pngImage, {
x,
y,
width: imgWPt,
height: imgHPt,
rotate: degrees(finalAngle),
opacity
});
}
}
}
// 導(dǎo)出并下載
const pdfBytes = await pdfDoc.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = pdfUrl.split('/').pop() || 'watermarked.pdf';
link.click();
URL.revokeObjectURL(link.href);
}
效果展示


2. 圖片水印實(shí)現(xiàn)
思路和pdf的類似,只不過不需要pdf-lib庫了,先生成和上面一樣的水印圖,再創(chuàng)建canvas加載原圖,遍歷添加水印圖即可。
export async function addWatermarkToImage(imageUrl, companyName, personName, options = {}) {
if (!imageUrl) return '';
const {
opacity = 0.38,
angle = 45,
color = 'rgba(120,120,120,1)',
pixelRatio = Math.max(window.devicePixelRatio || 1, 2),
lineHeight = 1.4,
crossOrigin = 'anonymous',
mimeType = 'image/png',
quality = 0.92,
fontRatio = 0.02, // 字體比例
gapRatio = { x: 0.25, y: 0.2 } // 間隔比例
} = options;
const firstLine = companyName && personName ? `${companyName}-${personName}` : companyName || personName || '';
const secondLine = '僅用于xxxx,他用無效';
const lines = [firstLine, secondLine];
const loadImage = (src, needCO) =>
new Promise((resolve, reject) => {
const img = new Image();
if (needCO) img.crossOrigin = crossOrigin;
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
const baseImg = await loadImage(imageUrl, true);
const W = baseImg.naturalWidth || baseImg.width;
const H = baseImg.naturalHeight || baseImg.height;
const minSide = Math.min(W, H);
const adaptiveFontSize = Math.max(12, Math.round(minSide * fontRatio));
const measureCanvas = document.createElement('canvas');
const mctx = measureCanvas.getContext('2d');
mctx.font = `${adaptiveFontSize}px sans-serif`;
const firstLineWidthPx = mctx.measureText(firstLine).width;
// 動態(tài)間隔(比例 + 最小間隔限制)
let xGap = W * gapRatio.x;
let yGap = H * gapRatio.y;
const minXGap = Math.max(firstLineWidthPx * 3, 200); // 至少 200px
const minYGap = Math.max(adaptiveFontSize * 4, 150); // 至少 150px
xGap = Math.max(xGap, minXGap);
yGap = Math.max(yGap, minYGap);
const padding = 10;
const lineHeightPx = adaptiveFontSize * lineHeight;
const cssWidth = Math.max(...lines.map(l => mctx.measureText(l).width)) + padding * 2;
const cssHeight = lines.length * lineHeightPx + padding * 2;
const tileCanvas = document.createElement('canvas');
tileCanvas.width = cssWidth * pixelRatio;
tileCanvas.height = cssHeight * pixelRatio;
const tctx = tileCanvas.getContext('2d');
tctx.scale(pixelRatio, pixelRatio);
tctx.font = `${adaptiveFontSize}px sans-serif`;
tctx.fillStyle = color;
tctx.textBaseline = 'top';
tctx.textAlign = 'left';
lines.forEach((line, i) => {
tctx.fillText(line, padding, padding + i * lineHeightPx);
});
const tileDataUrl = tileCanvas.toDataURL('image/png');
const tileImg = await loadImage(tileDataUrl, false);
const outCanvas = document.createElement('canvas');
outCanvas.width = W * pixelRatio;
outCanvas.height = H * pixelRatio;
const ctx = outCanvas.getContext('2d');
ctx.scale(pixelRatio, pixelRatio);
ctx.drawImage(baseImg, 0, 0, W, H);
ctx.globalAlpha = opacity;
const rad = (angle * Math.PI) / 180;
for (let x = -W; x < W * 2; x += xGap) {
for (let y = -H; y < H * 2; y += yGap) {
ctx.save();
ctx.translate(x + cssWidth / 2, y + cssHeight / 2);
ctx.rotate(rad);
ctx.drawImage(tileImg, -cssWidth / 2, -cssHeight / 2, cssWidth, cssHeight);
ctx.restore();
}
}
ctx.globalAlpha = 1;
return outCanvas.toDataURL(mimeType, quality);
}
效果展示


關(guān)鍵技術(shù)點(diǎn)
1. 高分辨率處理
通過pixelRatio參數(shù)確保在高DPI屏幕上水印依然清晰,這是通過將canvas尺寸放大再縮放實(shí)現(xiàn)的。
2. 自動間隔計算
水印間隔不是固定值,而是基于文本長度動態(tài)計算:
const xGap = Math.max(baseGap, firstLineWidthPt * 1.2);
這確保了水印既不會過于密集也不會過于稀疏。
3. 頁面旋轉(zhuǎn)適配
firstPage.drawText('This text was added with JavaScript!', {
x: 5,
y: height / 2 + 300,
size: 50,
font: helveticaFont,
color: rgb(0.95, 0.1, 0.1),
rotate: degrees(0),
})
上面這行代碼,在每頁高度>寬度的情況下,這段代碼水印顯示沒什么問題。 反之,就會出現(xiàn)下面這種情況,水印倒轉(zhuǎn)過來了

于是在添加水印前,應(yīng)先判斷pdf的方向
const { width: pageW, height: pageH } = page.getSize();
const rotation = page.getRotation().angle || 0;
let finalAngle = angle;
if (rotation === 90) finalAngle = angle - 90;
else if (rotation === 270) finalAngle = angle + 90;
else if (rotation === 180) finalAngle = angle + 180;
PDF處理時自動檢測頁面旋轉(zhuǎn)角度并相應(yīng)調(diào)整水印方向,保證水印始終以正確角度顯示。
4. 跨域圖片處理
圖片水印函數(shù)支持crossOrigin參數(shù),可以正確處理需要CORS的圖片資源。
相關(guān)文章
詳解Webpack + ES6 最新環(huán)境搭建與配置
這篇文章主要介紹了詳解Webpack + ES6 最新環(huán)境搭建與配置,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-06-06
js的.innerHTML = ""IE9下顯示有錯誤的解決方法
js的.innerHTML= "……"在ie9- 的版本顯示不正常,使用jquery可以解決,有類似問題的朋友可以參考下2013-09-09
基于JavaScript實(shí)現(xiàn)根據(jù)手機(jī)定位獲取當(dāng)前具體位置(X省X市X縣X街道X號)
這篇文章主要介紹了基于JavaScript實(shí)現(xiàn)根據(jù)手機(jī)定位獲取當(dāng)前具體位置(X省X市X縣X街道X號)的相關(guān)資料,需要的朋友可以參考下2015-12-12
JavaScript中實(shí)現(xiàn)跨標(biāo)簽頁通信的方法詳解
跨標(biāo)簽頁通信是指在瀏覽器中的不同標(biāo)簽頁之間進(jìn)行數(shù)據(jù)傳遞和通信的過程,這篇文章為大家介紹了一下常見的跨標(biāo)簽頁通信方式,感興趣的小伙伴可以了解下2023-11-11

