前端实现PDF与图片添加自定义中文水印的详细流程
作者:puhaha
这篇文章主要介绍了为PDF和图片添加中文水印的两种方案,方案一因字体库问题被弃用,方案二采用canvas生成水印并结合pdf-lib实现,支持自定义参数及高分辨率、自动间隔、旋转适配等关键技术,需要的朋友可以参考下
前言
在日常开发中,为文档和图片添加版权水印是一项常见需求。本文将详细介绍如何为PDF和图片添加自定义中文水印
的思路。
如图所示,水印需要两行,第一行是动态变化的,第二行是固定文案。所以还需要考虑文字长短从而改变每个水印之间的间距。
功能概述
这个方案提供了两个核心函数:
addWatermarkToPDF
: 为PDF文件添加倾斜水印addWatermarkToImage
: 为图片文件添加网格状水印
两个函数都支持自定义文本内容、字体大小、透明度、旋转角度等参数,特别优化了对中文文本的处理。
核心实现原理
方案一:采用pdf-lib
实现水印,但是它要支持中文水印的话,还需借助和@pdf-lib/fontkit
库来加载一个中文字体集实现。一般小一点的中文字体集都有5M左右,并且这种方案生成的水印可以复制,不是我想要的效果,于是pass了。
方案二:采用canvas生成水印,再结合pdf-lib绘制图片的方式,嵌入到pdf中。这种方案不用导入字体库,减去了加载字体的时间,所以速度比方案一快很多。
1. PDF水印实现
export async function addWatermarkToPDF(pdfUrl, companyName, personName, options = {}) { // 参数配置 const { fontSize = 30, // PDF点单位 opacity = 0.18, angle = -45, color = 'rgba(120,120,120,1)', pixelRatio = Math.max(window.devicePixelRatio || 1, 2), baseGap = 500, // 最小间隔 lineHeight = 1.4 // 行距 } = options; // 构建水印文本 const firstLine = companyName && personName ? `${companyName}-${personName}` : companyName || personName || ''; const secondLine = '仅用于xxxx使用,他用无效'; const lines = [firstLine, secondLine]; // 单位转换函数 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); // 动态计算水印间隔 const xGap = Math.max(baseGap, firstLineWidthPt * 1.2); const yGap = Math.max(250, fontSize * (lines.length + 1)); // 创建水印图片 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; // 根据页面旋转调整水印角度 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 }); } } } // 导出并下载 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. 图片水印实现
思路和pdf的类似,只不过不需要pdf-lib库了,先生成和上面一样的水印图,再创建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; // 动态间隔(比例 + 最小间隔限制) 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); }
效果展示
关键技术点
1. 高分辨率处理
通过pixelRatio
参数确保在高DPI屏幕上水印依然清晰,这是通过将canvas尺寸放大再缩放实现的。
2. 自动间隔计算
水印间隔不是固定值,而是基于文本长度动态计算:
const xGap = Math.max(baseGap, firstLineWidthPt * 1.2);
这确保了水印既不会过于密集也不会过于稀疏。
3. 页面旋转适配
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), })
上面这行代码,在每页高度>宽度的情况下,这段代码水印显示没什么问题。 反之,就会出现下面这种情况,水印倒转过来了
于是在添加水印前,应先判断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处理时自动检测页面旋转角度并相应调整水印方向,保证水印始终以正确角度显示。
4. 跨域图片处理
图片水印函数支持crossOrigin
参数,可以正确处理需要CORS的图片资源。