feat: add image watermark

This commit is contained in:
2026-03-07 22:14:29 +08:00
parent 25d3b7d847
commit b091ad73d6
5 changed files with 455 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/
# ---> macOS
# General
.DS_Store

12
image-watermark/deno.json Normal file
View File

@@ -0,0 +1,12 @@
{
"tasks": {
"dev": "deno run --watch main.ts",
"watermark": "deno run --allow-read --allow-write --allow-env --allow-ffi main.ts",
"test": "deno test --allow-read --allow-write --allow-env --allow-ffi"
},
"nodeModulesDir": "auto",
"imports": {
"@std/assert": "jsr:@std/assert@1",
"sharp": "npm:sharp@^0.33.0"
}
}

260
image-watermark/deno.lock generated Normal file
View File

@@ -0,0 +1,260 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1": "1.0.13",
"jsr:@std/cli@*": "1.0.27",
"jsr:@std/internal@^1.0.6": "1.0.12",
"npm:sharp@0.33": "0.33.5"
},
"jsr": {
"@std/assert@1.0.13": {
"integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/cli@1.0.27": {
"integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de"
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
}
},
"npm": {
"@emnapi/runtime@1.8.1": {
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dependencies": [
"tslib"
]
},
"@img/sharp-darwin-arm64@0.33.5": {
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"optionalDependencies": [
"@img/sharp-libvips-darwin-arm64"
],
"os": ["darwin"],
"cpu": ["arm64"]
},
"@img/sharp-darwin-x64@0.33.5": {
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"optionalDependencies": [
"@img/sharp-libvips-darwin-x64"
],
"os": ["darwin"],
"cpu": ["x64"]
},
"@img/sharp-libvips-darwin-arm64@1.0.4": {
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@img/sharp-libvips-darwin-x64@1.0.4": {
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@img/sharp-libvips-linux-arm64@1.0.4": {
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@img/sharp-libvips-linux-arm@1.0.5": {
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"os": ["linux"],
"cpu": ["arm"]
},
"@img/sharp-libvips-linux-s390x@1.0.4": {
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@img/sharp-libvips-linux-x64@1.0.4": {
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"os": ["linux"],
"cpu": ["x64"]
},
"@img/sharp-libvips-linuxmusl-arm64@1.0.4": {
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@img/sharp-libvips-linuxmusl-x64@1.0.4": {
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"os": ["linux"],
"cpu": ["x64"]
},
"@img/sharp-linux-arm64@0.33.5": {
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"optionalDependencies": [
"@img/sharp-libvips-linux-arm64"
],
"os": ["linux"],
"cpu": ["arm64"]
},
"@img/sharp-linux-arm@0.33.5": {
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"optionalDependencies": [
"@img/sharp-libvips-linux-arm"
],
"os": ["linux"],
"cpu": ["arm"]
},
"@img/sharp-linux-s390x@0.33.5": {
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"optionalDependencies": [
"@img/sharp-libvips-linux-s390x"
],
"os": ["linux"],
"cpu": ["s390x"]
},
"@img/sharp-linux-x64@0.33.5": {
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"optionalDependencies": [
"@img/sharp-libvips-linux-x64"
],
"os": ["linux"],
"cpu": ["x64"]
},
"@img/sharp-linuxmusl-arm64@0.33.5": {
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"optionalDependencies": [
"@img/sharp-libvips-linuxmusl-arm64"
],
"os": ["linux"],
"cpu": ["arm64"]
},
"@img/sharp-linuxmusl-x64@0.33.5": {
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"optionalDependencies": [
"@img/sharp-libvips-linuxmusl-x64"
],
"os": ["linux"],
"cpu": ["x64"]
},
"@img/sharp-wasm32@0.33.5": {
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"dependencies": [
"@emnapi/runtime"
],
"cpu": ["wasm32"]
},
"@img/sharp-win32-ia32@0.33.5": {
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@img/sharp-win32-x64@0.33.5": {
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"os": ["win32"],
"cpu": ["x64"]
},
"color-convert@2.0.1": {
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": [
"color-name"
]
},
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"color-string@1.9.1": {
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dependencies": [
"color-name",
"simple-swizzle"
]
},
"color@4.2.3": {
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dependencies": [
"color-convert",
"color-string"
]
},
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
"is-arrayish@0.3.4": {
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="
},
"semver@7.7.4": {
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"bin": true
},
"sharp@0.33.5": {
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"dependencies": [
"color",
"detect-libc",
"semver"
],
"optionalDependencies": [
"@img/sharp-darwin-arm64",
"@img/sharp-darwin-x64",
"@img/sharp-libvips-darwin-arm64",
"@img/sharp-libvips-darwin-x64",
"@img/sharp-libvips-linux-arm",
"@img/sharp-libvips-linux-arm64",
"@img/sharp-libvips-linux-s390x",
"@img/sharp-libvips-linux-x64",
"@img/sharp-libvips-linuxmusl-arm64",
"@img/sharp-libvips-linuxmusl-x64",
"@img/sharp-linux-arm",
"@img/sharp-linux-arm64",
"@img/sharp-linux-s390x",
"@img/sharp-linux-x64",
"@img/sharp-linuxmusl-arm64",
"@img/sharp-linuxmusl-x64",
"@img/sharp-wasm32",
"@img/sharp-win32-ia32",
"@img/sharp-win32-x64"
],
"scripts": true
},
"simple-swizzle@0.2.4": {
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"dependencies": [
"is-arrayish"
]
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
},
"remote": {
"https://deno.land/x/imagescript@1.3.0/ImageScript.js": "cf90773c966031edd781ed176c598f7ed495e7694cd9b86c986d2d97f783cca0",
"https://deno.land/x/imagescript@1.3.0/mod.ts": "18a6cb83c55e690c873505f6fe867364c678afb64934fe7aef593a6b92f79995",
"https://deno.land/x/imagescript@1.3.0/png/src/crc.mjs": "5cf50de181d61dd00e66a240d811018ba5070afa8bba302f393604404604de84",
"https://deno.land/x/imagescript@1.3.0/png/src/mem.mjs": "4968d400dae069b4bf0ef4767c1802fd2cc7d15d90eda4cfadf5b4cd19b96c6d",
"https://deno.land/x/imagescript@1.3.0/png/src/png.mjs": "96ef0ceff1b5a6cd9304749e5f187b4ab238509fb5f9a8be8ee934240271ed8d",
"https://deno.land/x/imagescript@1.3.0/png/src/zlib.mjs": "9867dc3fab1d31b664f9344b0d7e977f493d9c912a76c760d012ed2b89f7061c",
"https://deno.land/x/imagescript@1.3.0/utils/buffer.js": "952cb1beb8827e50a493a5d1f29a4845e8c648789406d389dd51f51205ba02d8",
"https://deno.land/x/imagescript@1.3.0/utils/crc32.js": "573d6222b3605890714ebc374e687ec2aa3e9a949223ea199483e47ca4864f7d",
"https://deno.land/x/imagescript@1.3.0/utils/png.js": "fbed9117e0a70602645d70df9c103ff6e79c03e987bd5c1685dcb4200729b6de",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/font.js": "9e75d842608c057045698d6a7cdf5ffd27241b5cdea0391c89a1917b31294524",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/gif.js": "8b86f7b96486bb8ff50fbc7c7487f86cb5cef85e6acd71e1def78a1aa2f12e4f",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/jpeg.js": "75295e2fcf96b4f7bb894b3844fdaa8140d63169d28b466b5d5be89d59a7b6e6",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/png.js": "0659536a8dd8f892c8346e268b2754b4414fad0ec1e9794dfcde1ba1c804ee02",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/svg.js": "f5c8a9d1977b51a7c07549ceb6bbbaca9497321a193f28b3dc229a42d91bcf14",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/tiff.js": "c2d7bdaef094df25aae1752e75167f485e89275d76a1379e39d8949580b7af4f",
"https://deno.land/x/imagescript@1.3.0/utils/wasm/zlib.js": "749875f83abffe24d3b977475a0cbd5f9b52bee1fbdbef61ec183cbfc17805f6",
"https://deno.land/x/imagescript@1.3.0/v2/framebuffer.mjs": "add44ff184636659714b3c6d4b896f628545451abffbc30b5bcc2e8d9a73d012",
"https://deno.land/x/imagescript@1.3.0/v2/ops/blur.mjs": "80716f1ffab8a2aeb54a036f583bf51a2b9dd37e005adc000add803df8e8a12f",
"https://deno.land/x/imagescript@1.3.0/v2/ops/color.mjs": "5e72cdcbf97dc939a2795223f01e3cb0544c0c56b03ea2aa026050df58348814",
"https://deno.land/x/imagescript@1.3.0/v2/ops/crop.mjs": "69431fa6f687fd9f0c31eff0ec27d7ac925275005e53a37f0c3fab4cc4d9a9ea",
"https://deno.land/x/imagescript@1.3.0/v2/ops/fill.mjs": "cf1b9488314753fbc9ebf03410ac74c2a34ea5a69fb6892cd6e8366cd1930d93",
"https://deno.land/x/imagescript@1.3.0/v2/ops/flip.mjs": "825a34a66567dcf15e76a719f1bf2f66fb106503cd69942292b1b0ae05c5718e",
"https://deno.land/x/imagescript@1.3.0/v2/ops/index.mjs": "423ba687119be2bba8cec72890577d3afa3621b6b8108912242fe937a183f2aa",
"https://deno.land/x/imagescript@1.3.0/v2/ops/iterator.mjs": "c2adf3d90ce00719a02c48c97634574176a3501ff026676259bd71aa8f5d69b9",
"https://deno.land/x/imagescript@1.3.0/v2/ops/overlay.mjs": "7e6e2c2ffd25006d52597ab8babc5f8f503d388a3fdf2fbc0eaea02799a020c9",
"https://deno.land/x/imagescript@1.3.0/v2/ops/resize.mjs": "814e78ebce8eaf8f1f918688db7b52a141405e06a36ed4b25d04413d69e7d17b",
"https://deno.land/x/imagescript@1.3.0/v2/ops/rotate.mjs": "a1b65616717bd2eed8db406affea3263b4674dada46b56441ef38167a187455d",
"https://deno.land/x/imagescript@1.3.0/v2/util/mem.mjs": "4968d400dae069b4bf0ef4767c1802fd2cc7d15d90eda4cfadf5b4cd19b96c6d"
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1",
"npm:sharp@0.33"
]
}
}

133
image-watermark/main.ts Executable file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env -S deno run -A
import sharp from "sharp";
import { Buffer } from "node:buffer";
import {parseArgs} from "jsr:@std/cli/parse-args";
export interface WatermarkOptions {
/** 水印文字 */
text: string;
/** 字体大小 (默认 24) */
fontSize?: number;
/** 字体颜色 (默认白色,格式:#FFFFFF 或 rgba) */
color?: string;
/** 背景颜色 (默认透明,格式:#00000080 或 rgba) */
backgroundColor?: string;
/** 背景内边距 (默认 5) */
backgroundPadding?: number;
/** 距离右边距的像素 (默认 10) */
marginRight?: number;
/** 距离底边距的像素 (默认 10) */
marginBottom?: number;
/** 输出路径 (默认在原文件名后添加 _watermarked) */
outputPath?: string;
}
/**
* 在图片右下角添加文字水印
* @param inputPath 输入图片路径
* @param options 水印选项
*/
export async function addWatermark(
inputPath: string,
options: WatermarkOptions,
): Promise<void> {
const {
text,
fontSize = 24,
color = "black",
backgroundColor,
backgroundPadding = 5,
marginRight = 10,
marginBottom = 10,
} = options;
let outputPath = options.outputPath;
if (!outputPath) {
const lastDot = inputPath.lastIndexOf(".");
outputPath = lastDot !== -1
? `${inputPath.slice(0, lastDot)}_watermarked${inputPath.slice(lastDot)}`
: `${inputPath}_watermarked`;
}
// 获取原图信息
const metadata = await sharp(inputPath).metadata();
const width = metadata.width!;
const height = metadata.height!;
// 估算文字宽度(使用更精确的系数,根据常见字体调整)
// sans-serif 字体在 SVG 中的平均宽度系数约为 0.5-0.6
const textWidth = Math.ceil(text.length * fontSize * 0.55);
const textHeight = Math.ceil(fontSize * 0.9);
// 背景矩形尺寸
const bgWidth = textWidth + backgroundPadding * 2;
const bgHeight = textHeight + backgroundPadding * 2;
// 文字和背景位置(右下角对齐)
// text-anchor="end" 意味着文字的右边缘在指定的 x 坐标
const textX = width - marginRight - 20;
const textY = height - marginBottom;
// 背景矩形的左上角坐标
// 背景右边缘与文字右边缘对齐
const rectX = textX - bgWidth + 20;
const rectY = textY - bgHeight + backgroundPadding + 2;
// 创建最终 SVG 水印
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
${backgroundColor ? `<rect x="${rectX}" y="${rectY}" width="${bgWidth}" height="${bgHeight}" fill="${backgroundColor}" rx="4"/>` : ""}
<text x="${textX}" y="${textY}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" text-anchor="end" dominant-baseline="alphabetic">${escapeXml(text)}</text>
</svg>`;
// 将 SVG 水印叠加到原图
await sharp(inputPath)
.composite([
{
input: Buffer.from(svg),
top: 0,
left: 0,
},
])
.toFile(outputPath);
console.log(`Added watermark: ${outputPath}`);
}
/**
* 转义 XML 特殊字符
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
if (import.meta.main) {
const flags = parseArgs(Deno.args, {
boolean: ["help"],
string: ["watermark"],
});
const watermarkText = flags.watermark;
const imagePaths = flags._;
if (!watermarkText || imagePaths.length == 0) {
console.log("Usage: deno run -A main.ts --watermark 'watermark' <FILES>");
console.log("Example: deno run -A main.ts --watermark '© 2026 MyName' image.jpg");
Deno.exit(1);
}
for (const imagePath of imagePaths) {
await addWatermark(imagePath+'', {
fontSize: 32,
text: watermarkText,
color: 'black',
backgroundPadding: 2,
backgroundColor: 'white'
});
}
}

View File

@@ -0,0 +1,49 @@
import { assertEquals } from "@std/assert";
import { addWatermark } from "./main.ts";
Deno.test("addWatermark - should add watermark to image", async () => {
const { default: sharp } = await import("sharp");
// 创建测试图片 (200x200 红色方块)
const testImagePath = "/tmp/test_image.png";
await sharp({
create: {
width: 200,
height: 200,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
}).png().toFile(testImagePath);
const outputPath = "/tmp/test_image_watermarked.png";
try {
// 添加水印(带背景色)
await addWatermark(testImagePath, {
text: "Test Watermark",
fontSize: 16,
color: "white",
backgroundColor: "#80000000",
backgroundPadding: 5,
marginRight: 10,
marginBottom: 10,
outputPath,
});
// 验证输出文件存在
const stat = await Deno.stat(outputPath);
assertEquals(stat.isFile, true);
} finally {
// 清理
try {
await Deno.remove(testImagePath);
} catch {
// Ignore
}
try {
await Deno.remove(outputPath);
} catch {
// Ignore
}
}
});