@zhaoshijun/compress 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -21
- package/bin/compress.js +140 -0
- package/package.json +2 -2
- package/src/config/defaults.js +7 -0
- package/src/config/loader.js +4 -1
- package/src/core/compressor.js +8 -1
- package/src/core/watermark.js +152 -0
- package/src/utils/cache.js +22 -4
- package/vite/index.js +9 -2
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- ⚙️ **灵活配置**:支持配置文件 (`compress.config.js`),可精细控制压缩参数。
|
|
12
12
|
- 🛡 **安全可靠**:支持备份原文件,默认不覆盖源文件(CLI 模式输出到指定目录)。
|
|
13
13
|
- 📦 **智能缓存**:Vite 插件基于内容 Hash 缓存,避免重复压缩,提升构建速度。
|
|
14
|
+
- 💧 **水印功能**:支持添加文字水印,可自定义透明度、密度、颜色等参数。
|
|
14
15
|
|
|
15
16
|
## 安装
|
|
16
17
|
|
|
@@ -30,6 +31,7 @@ CLI 工具用于批量扫描并压缩本地图片文件。
|
|
|
30
31
|
|
|
31
32
|
1. **初始化配置** (可选)
|
|
32
33
|
生成默认的 `compress.config.js` 文件:
|
|
34
|
+
|
|
33
35
|
```bash
|
|
34
36
|
npx @zhaoshijun/compress init
|
|
35
37
|
```
|
|
@@ -42,13 +44,18 @@ CLI 工具用于批量扫描并压缩本地图片文件。
|
|
|
42
44
|
|
|
43
45
|
### 命令行选项
|
|
44
46
|
|
|
45
|
-
| 选项
|
|
46
|
-
|
|
|
47
|
-
| `--config <path>` | `-c` | 指定配置文件路径
|
|
48
|
-
| `--backup`
|
|
49
|
-
| `--dry-run`
|
|
50
|
-
| `--quiet`
|
|
51
|
-
| `--
|
|
47
|
+
| 选项 | 简写 | 描述 |
|
|
48
|
+
| ----------------- | ---- | ---------------------------------------------------- |
|
|
49
|
+
| `--config <path>` | `-c` | 指定配置文件路径 |
|
|
50
|
+
| `--backup` | | 启用备份模式(将原文件复制为 `.backup`) |
|
|
51
|
+
| `--dry-run` | | 演练模式,仅列出将要处理的文件,不进行实际压缩和写入 |
|
|
52
|
+
| `--quiet` | `-q` | 静默模式,仅输出错误信息 |
|
|
53
|
+
| `--watermark` | `-w` | 启用水印 |
|
|
54
|
+
| `--watermark-text <text>` | | 水印文本内容 |
|
|
55
|
+
| `--watermark-opacity <opacity>` | | 水印透明度 (0-1) |
|
|
56
|
+
| `--watermark-density <density>` | | 水印密度 (1-10) |
|
|
57
|
+
| `--watermark-color <color>` | | 水印颜色 (hex 或 rgba) |
|
|
58
|
+
| `--help` | `-h` | 显示帮助信息 |
|
|
52
59
|
|
|
53
60
|
### 示例
|
|
54
61
|
|
|
@@ -58,8 +65,41 @@ npx @zhaoshijun/compress --dry-run
|
|
|
58
65
|
|
|
59
66
|
# 使用指定配置文件并开启静默模式
|
|
60
67
|
npx @zhaoshijun/compress -c ./config/compress.js -q
|
|
68
|
+
|
|
69
|
+
# 压缩图片并添加水印
|
|
70
|
+
npx @zhaoshijun/compress -w --watermark-text "Copyright 2024"
|
|
71
|
+
|
|
72
|
+
# 压缩图片并添加自定义水印
|
|
73
|
+
npx @zhaoshijun/compress -w --watermark-text "My Watermark" --watermark-opacity 0.3 --watermark-color "#ff0000"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 水印功能
|
|
77
|
+
|
|
78
|
+
### 单独添加水印
|
|
79
|
+
|
|
80
|
+
如果只需要给图片添加水印而不进行压缩,可以使用 `watermark` 命令:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# 给图片添加水印(输出到 ./watermarked 目录)
|
|
84
|
+
npx @zhaoshijun/compress watermark --watermark-text "Copyright 2024"
|
|
85
|
+
|
|
86
|
+
# 自定义水印参数
|
|
87
|
+
npx @zhaoshijun/compress watermark --watermark-text "Confidential" --watermark-opacity 0.7 --watermark-density 5 --watermark-color "rgba(255,0,0,0.5)"
|
|
88
|
+
|
|
89
|
+
# 指定输出目录
|
|
90
|
+
npx @zhaoshijun/compress watermark --watermark-text "Watermark" --output ./output
|
|
61
91
|
```
|
|
62
92
|
|
|
93
|
+
### 水印参数说明
|
|
94
|
+
|
|
95
|
+
| 参数 | 说明 | 默认值 | 范围 |
|
|
96
|
+
| ---- | ---- | ------ | ---- |
|
|
97
|
+
| `text` | 水印文本内容 | '' | 必填 |
|
|
98
|
+
| `opacity` | 水印透明度 | 0.5 | 0-1 |
|
|
99
|
+
| `density` | 水印密度(平铺数量) | 3 | 1-10 |
|
|
100
|
+
| `color` | 水印颜色 | '#ffffff' | hex 或 rgba |
|
|
101
|
+
| `fontSize` | 水印字体大小 | 24 | 正整数 |
|
|
102
|
+
|
|
63
103
|
## Vite 插件
|
|
64
104
|
|
|
65
105
|
该插件仅在 `vite build` 生产构建模式下生效,会自动压缩项目中引用的图片资源,并替换构建产物中的引用路径。
|
|
@@ -69,17 +109,43 @@ npx @zhaoshijun/compress -c ./config/compress.js -q
|
|
|
69
109
|
在 `vite.config.js` 中引入并添加插件:
|
|
70
110
|
|
|
71
111
|
```javascript
|
|
72
|
-
import { defineConfig } from
|
|
73
|
-
import { compressVitePlugin } from
|
|
112
|
+
import { defineConfig } from "vite";
|
|
113
|
+
import { compressVitePlugin } from "@zhaoshijun/compress";
|
|
74
114
|
|
|
75
115
|
export default defineConfig({
|
|
76
116
|
plugins: [
|
|
77
117
|
compressVitePlugin({
|
|
78
|
-
//
|
|
79
|
-
quality:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
// 1. 覆盖通用质量
|
|
119
|
+
quality: 75,
|
|
120
|
+
|
|
121
|
+
// 2. 覆盖 PNG 特定选项 (注意:如果覆盖对象,需要提供完整属性,或者插件逻辑里做深度合并)
|
|
122
|
+
// 根据当前代码实现是浅合并,所以如果只写 palette,compressionLevel 可能会丢失(如果默认值是在loader里处理的)
|
|
123
|
+
// 实际上 loader.js 做了深度合并,但 vite 插件里的 options 是顶层覆盖。
|
|
124
|
+
// 建议:如果只改一项,尽量在 compress.config.js 里改。
|
|
125
|
+
// 如果必须在这里改,传入完整对象:
|
|
126
|
+
pngOptions: {
|
|
127
|
+
compressionLevel: 6,
|
|
128
|
+
palette: false,
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// 3. 甚至可以开启尺寸限制(虽然通常不建议在构建时改变原图尺寸)
|
|
132
|
+
resize: {
|
|
133
|
+
maxWidth: 1024,
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// 4. 控制缓存
|
|
137
|
+
cache: false,
|
|
138
|
+
|
|
139
|
+
// 5. 启用水印
|
|
140
|
+
watermark: {
|
|
141
|
+
text: 'Copyright 2024',
|
|
142
|
+
opacity: 0.5,
|
|
143
|
+
density: 3,
|
|
144
|
+
color: '#ffffff',
|
|
145
|
+
fontSize: 24
|
|
146
|
+
}
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
83
149
|
});
|
|
84
150
|
```
|
|
85
151
|
|
|
@@ -98,16 +164,16 @@ export default defineConfig({
|
|
|
98
164
|
module.exports = {
|
|
99
165
|
// [CLI专用] 输入文件模式,支持 glob 数组
|
|
100
166
|
// 默认扫描所有子目录下的 jpg, jpeg, png, webp
|
|
101
|
-
input: [
|
|
167
|
+
input: ["**/*.{jpg,jpeg,png,webp}"],
|
|
102
168
|
|
|
103
169
|
// [CLI专用] 输出目录
|
|
104
170
|
// 默认输出到当前目录下的 compressed 文件夹,保持原目录结构
|
|
105
|
-
output:
|
|
171
|
+
output: "./compressed",
|
|
106
172
|
|
|
107
173
|
// 尺寸调整 (可选)
|
|
108
174
|
// 注意:scale 与 maxWidth/maxHeight 互斥,优先使用 maxWidth/maxHeight
|
|
109
175
|
resize: {
|
|
110
|
-
maxWidth: 1920,
|
|
176
|
+
maxWidth: 1920, // 最大宽度,保持纵横比
|
|
111
177
|
// maxHeight: 1080, // 最大高度
|
|
112
178
|
// scale: 0.8 // 缩放比例 (0-1)
|
|
113
179
|
},
|
|
@@ -119,20 +185,29 @@ module.exports = {
|
|
|
119
185
|
// PNG 专属配置
|
|
120
186
|
pngOptions: {
|
|
121
187
|
compressionLevel: 9, // 压缩等级 0-9,默认 9
|
|
122
|
-
palette: true
|
|
188
|
+
palette: true, // 是否启用调色板量化,默认 true (显著减小体积)
|
|
123
189
|
},
|
|
124
190
|
|
|
125
191
|
// 输出文件名后缀
|
|
126
192
|
// 默认 '.min',例如 image.jpg -> image.min.jpg
|
|
127
|
-
suffix:
|
|
193
|
+
suffix: ".min",
|
|
128
194
|
|
|
129
195
|
// [CLI专用] 是否备份原文件
|
|
130
196
|
backup: false,
|
|
131
197
|
|
|
198
|
+
// 水印配置
|
|
199
|
+
watermark: {
|
|
200
|
+
text: '', // 水印文本内容,留空则不添加水印
|
|
201
|
+
opacity: 0.5, // 水印透明度 (0-1)
|
|
202
|
+
density: 3, // 水印密度 (1-10),数值越大水印越多
|
|
203
|
+
color: '#ffffff', // 水印颜色,支持 hex 或 rgba 格式
|
|
204
|
+
fontSize: 24 // 水印字体大小
|
|
205
|
+
},
|
|
206
|
+
|
|
132
207
|
// [Vite插件专用] 配置
|
|
133
208
|
vite: {
|
|
134
|
-
cache: true // 是否启用构建缓存,默认 true
|
|
135
|
-
}
|
|
209
|
+
cache: true, // 是否启用构建缓存,默认 true
|
|
210
|
+
},
|
|
136
211
|
};
|
|
137
212
|
```
|
|
138
213
|
|
|
@@ -145,6 +220,7 @@ module.exports = {
|
|
|
145
220
|
## 默认排除
|
|
146
221
|
|
|
147
222
|
CLI 工具默认会排除以下目录:
|
|
223
|
+
|
|
148
224
|
- `node_modules/`
|
|
149
225
|
- `dist/`
|
|
150
226
|
- `.git/`
|
|
@@ -157,3 +233,15 @@ A: 工具会自动检测并跳过损坏的图片,输出警告信息,不会
|
|
|
157
233
|
|
|
158
234
|
**Q: Vite 插件会修改源代码中的图片吗?**
|
|
159
235
|
A: 不会。Vite 插件仅在构建过程中处理资源,生成的压缩图片位于构建输出目录(如 `dist/assets`),源代码保持不变。
|
|
236
|
+
|
|
237
|
+
**Q: 水印会影响图片质量吗?**
|
|
238
|
+
A: 水印是在图片压缩后添加的,不会影响图片本身的压缩质量。水印的透明度可以通过 `opacity` 参数调整,建议设置为 0.3-0.7 之间以获得最佳效果。
|
|
239
|
+
|
|
240
|
+
**Q: 水印密度如何设置?**
|
|
241
|
+
A: 水印密度 `density` 参数控制水印的平铺数量,范围是 1-10。数值越大,水印越密集。默认值为 3,适合大多数场景。
|
|
242
|
+
|
|
243
|
+
**Q: 可以只添加水印不压缩吗?**
|
|
244
|
+
A: 可以。使用 `watermark` 命令可以单独给图片添加水印而不进行压缩,输出到 `./watermarked` 目录。
|
|
245
|
+
|
|
246
|
+
**Q: 水印颜色支持哪些格式?**
|
|
247
|
+
A: 水印颜色支持十六进制格式(如 `#ffffff`)和 RGBA 格式(如 `rgba(255,255,255,0.5)`)。
|
package/bin/compress.js
CHANGED
|
@@ -25,6 +25,11 @@ program
|
|
|
25
25
|
.option('--backup', '启用备份')
|
|
26
26
|
.option('--dry-run', '仅列出将处理的文件,不写入磁盘')
|
|
27
27
|
.option('-q, --quiet', '静默模式,仅输出错误')
|
|
28
|
+
.option('-w, --watermark', '启用水印')
|
|
29
|
+
.option('--watermark-text <text>', '水印文本')
|
|
30
|
+
.option('--watermark-opacity <opacity>', '水印透明度 (0-1)', parseFloat)
|
|
31
|
+
.option('--watermark-density <density>', '水印密度 (1-10)', parseInt)
|
|
32
|
+
.option('--watermark-color <color>', '水印颜色 (hex 或 rgba)')
|
|
28
33
|
.action(async (options) => {
|
|
29
34
|
try {
|
|
30
35
|
const startTime = Date.now();
|
|
@@ -35,6 +40,15 @@ program
|
|
|
35
40
|
// 合并命令行选项
|
|
36
41
|
if (options.backup !== undefined) config.backup = options.backup;
|
|
37
42
|
|
|
43
|
+
// 合并水印选项
|
|
44
|
+
if (options.watermark) {
|
|
45
|
+
config.watermark = config.watermark || {};
|
|
46
|
+
if (options.watermarkText) config.watermark.text = options.watermarkText;
|
|
47
|
+
if (options.watermarkOpacity !== undefined) config.watermark.opacity = options.watermarkOpacity;
|
|
48
|
+
if (options.watermarkDensity !== undefined) config.watermark.density = options.watermarkDensity;
|
|
49
|
+
if (options.watermarkColor) config.watermark.color = options.watermarkColor;
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
// 确定输入源
|
|
39
53
|
// 如果 config.input 未定义,则默认为当前目录 '**/*.{jpg,jpeg,png,webp}'
|
|
40
54
|
// 需要结合排除规则
|
|
@@ -187,6 +201,13 @@ program
|
|
|
187
201
|
},
|
|
188
202
|
suffix: '.min',
|
|
189
203
|
backup: false,
|
|
204
|
+
watermark: {
|
|
205
|
+
text: '',
|
|
206
|
+
opacity: 0.5,
|
|
207
|
+
density: 3,
|
|
208
|
+
color: '#ffffff',
|
|
209
|
+
fontSize: 24
|
|
210
|
+
},
|
|
190
211
|
vite: {
|
|
191
212
|
cache: true
|
|
192
213
|
}
|
|
@@ -197,3 +218,122 @@ program
|
|
|
197
218
|
});
|
|
198
219
|
|
|
199
220
|
program.parse();
|
|
221
|
+
|
|
222
|
+
// Watermark command
|
|
223
|
+
program
|
|
224
|
+
.command('watermark')
|
|
225
|
+
.description('给图片添加水印(不压缩)')
|
|
226
|
+
.option('-c, --config <path>', '配置文件路径')
|
|
227
|
+
.option('--watermark-text <text>', '水印文本')
|
|
228
|
+
.option('--watermark-opacity <opacity>', '水印透明度 (0-1)', parseFloat)
|
|
229
|
+
.option('--watermark-density <density>', '水印密度 (1-10)', parseInt)
|
|
230
|
+
.option('--watermark-color <color>', '水印颜色 (hex 或 rgba)')
|
|
231
|
+
.option('--output <dir>', '输出目录(默认为 ./watermarked)')
|
|
232
|
+
.action(async (options) => {
|
|
233
|
+
try {
|
|
234
|
+
const startTime = Date.now();
|
|
235
|
+
|
|
236
|
+
// 加载配置
|
|
237
|
+
const config = await loadConfig(options.config);
|
|
238
|
+
|
|
239
|
+
// 合并水印选项
|
|
240
|
+
if (options.watermarkText) config.watermark.text = options.watermarkText;
|
|
241
|
+
if (options.watermarkOpacity !== undefined) config.watermark.opacity = options.watermarkOpacity;
|
|
242
|
+
if (options.watermarkDensity !== undefined) config.watermark.density = options.watermarkDensity;
|
|
243
|
+
if (options.watermarkColor) config.watermark.color = options.watermarkColor;
|
|
244
|
+
|
|
245
|
+
if (!config.watermark.text) {
|
|
246
|
+
console.error(chalk.red('Error: Watermark text is required. Use --watermark-text option or set it in config file.'));
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 确定输入源
|
|
251
|
+
const inputPattern = config.input || ['**/*.{jpg,jpeg,png,webp}'];
|
|
252
|
+
const ignorePattern = [...DEFAULT_EXCLUDES];
|
|
253
|
+
|
|
254
|
+
// 查找文件
|
|
255
|
+
const spinner = ora('Scanning files...').start();
|
|
256
|
+
const files = await fg(inputPattern, {
|
|
257
|
+
ignore: ignorePattern,
|
|
258
|
+
absolute: true,
|
|
259
|
+
onlyFiles: true
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const targetFiles = files.filter(file => isSupported(file));
|
|
263
|
+
|
|
264
|
+
spinner.succeed(`Found ${targetFiles.length} images.`);
|
|
265
|
+
|
|
266
|
+
if (targetFiles.length === 0) {
|
|
267
|
+
console.log(chalk.yellow('No images found to add watermark.'));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 准备进度条
|
|
272
|
+
const multibar = new MultiBar({
|
|
273
|
+
clearOnComplete: false,
|
|
274
|
+
hideCursor: true,
|
|
275
|
+
format: '{bar} | {percentage}% | {value}/{total} Files | Current: {file}'
|
|
276
|
+
}, Presets.shades_classic);
|
|
277
|
+
|
|
278
|
+
let processedCount = 0;
|
|
279
|
+
let errors = [];
|
|
280
|
+
|
|
281
|
+
const progressBar = multibar.create(targetFiles.length, 0, { file: 'Starting...' });
|
|
282
|
+
|
|
283
|
+
for (const file of targetFiles) {
|
|
284
|
+
try {
|
|
285
|
+
const relativePath = path.relative(process.cwd(), file);
|
|
286
|
+
progressBar.update(processedCount, { file: relativePath });
|
|
287
|
+
|
|
288
|
+
const originalStat = await fs.stat(file);
|
|
289
|
+
const originalSize = originalStat.size;
|
|
290
|
+
|
|
291
|
+
// 备份
|
|
292
|
+
if (config.backup) {
|
|
293
|
+
await fs.copy(file, `${file}.backup`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 读取并添加水印
|
|
297
|
+
const outputDir = path.resolve(process.cwd(), options.output || './watermarked');
|
|
298
|
+
const relDir = path.dirname(relativePath);
|
|
299
|
+
const fileName = path.basename(file);
|
|
300
|
+
const outDir = path.join(outputDir, relDir);
|
|
301
|
+
const outFilePath = path.join(outDir, fileName);
|
|
302
|
+
|
|
303
|
+
const watermarkedBuffer = await compressImage(file, config, file);
|
|
304
|
+
|
|
305
|
+
// 写入文件
|
|
306
|
+
await fs.ensureDir(outDir);
|
|
307
|
+
await fs.writeFile(outFilePath, watermarkedBuffer);
|
|
308
|
+
|
|
309
|
+
const newSize = watermarkedBuffer.length;
|
|
310
|
+
const sizeDiff = newSize - originalSize;
|
|
311
|
+
const sizeDiffPercent = ((sizeDiff / originalSize) * 100).toFixed(2);
|
|
312
|
+
|
|
313
|
+
multibar.log(`${chalk.green('✔')} ${relativePath}: ${prettyBytes(originalSize)} → ${prettyBytes(newSize)} (${sizeDiff >= 0 ? '+' : ''}${prettyBytes(sizeDiff)}, ${sizeDiff >= 0 ? '+' : ''}${sizeDiffPercent}%)\n`);
|
|
314
|
+
|
|
315
|
+
processedCount++;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
errors.push({ file: path.relative(process.cwd(), file), error: err.message });
|
|
318
|
+
multibar.log(`${chalk.red('✖')} ${path.relative(process.cwd(), file)}: ${err.message}\n`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
multibar.stop();
|
|
323
|
+
|
|
324
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
325
|
+
|
|
326
|
+
console.log('\n' + chalk.bold('Watermark Summary:'));
|
|
327
|
+
console.log(`Total Processed: ${processedCount}/${targetFiles.length}`);
|
|
328
|
+
console.log(`Time Taken: ${duration}s`);
|
|
329
|
+
|
|
330
|
+
if (errors.length > 0) {
|
|
331
|
+
console.log(chalk.yellow(`\nSkipped ${errors.length} files due to errors:`));
|
|
332
|
+
errors.forEach(e => console.log(`- ${e.file}: ${e.error}`));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error(chalk.red('Error:'), error.message);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhaoshijun/compress",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Image compression CLI and Vite plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"sharp": "^0.33.2"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"vite": "
|
|
29
|
+
"vite": ">=5.0.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependenciesMeta": {
|
|
32
32
|
"vite": {
|
package/src/config/defaults.js
CHANGED
package/src/config/loader.js
CHANGED
|
@@ -26,11 +26,14 @@ export async function loadConfig(configPath) {
|
|
|
26
26
|
...defaultConfig.pngOptions,
|
|
27
27
|
...(userConfig.pngOptions || {})
|
|
28
28
|
},
|
|
29
|
+
watermark: {
|
|
30
|
+
...defaultConfig.watermark,
|
|
31
|
+
...(userConfig.watermark || {})
|
|
32
|
+
},
|
|
29
33
|
vite: {
|
|
30
34
|
...defaultConfig.vite,
|
|
31
35
|
...(userConfig.vite || {})
|
|
32
36
|
},
|
|
33
|
-
// resize 如果存在则覆盖
|
|
34
37
|
resize: userConfig.resize || undefined
|
|
35
38
|
};
|
|
36
39
|
}
|
package/src/core/compressor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { addWatermark } from './watermark.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 压缩图片
|
|
@@ -61,5 +62,11 @@ export async function compressImage(input, options, filePath) {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
let result = await instance.toBuffer();
|
|
66
|
+
|
|
67
|
+
if (options.watermark && options.watermark.text) {
|
|
68
|
+
result = await addWatermark(result, options.watermark);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
65
72
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 创建单个水印文本的 SVG
|
|
5
|
+
* @param {string} text - 水印文本
|
|
6
|
+
* @param {Object} options - 水印选项
|
|
7
|
+
* @param {number} options.opacity - 透明度 (0-1)
|
|
8
|
+
* @param {string} options.color - 颜色 (hex 或 rgba)
|
|
9
|
+
* @param {number} options.fontSize - 字体大小
|
|
10
|
+
* @returns {string} SVG 字符串
|
|
11
|
+
*/
|
|
12
|
+
function createWatermarkSVG(text, options) {
|
|
13
|
+
const { opacity, color, fontSize } = options;
|
|
14
|
+
const rgbaColor = convertToRGBA(color, opacity);
|
|
15
|
+
|
|
16
|
+
return `
|
|
17
|
+
<svg width="300" height="150" xmlns="http://www.w3.org/2000/svg">
|
|
18
|
+
<text x="50%" y="50%"
|
|
19
|
+
font-family="Arial, sans-serif"
|
|
20
|
+
font-size="${fontSize}"
|
|
21
|
+
font-weight="bold"
|
|
22
|
+
fill="${rgbaColor}"
|
|
23
|
+
text-anchor="middle"
|
|
24
|
+
dominant-baseline="middle"
|
|
25
|
+
transform="rotate(-30, 150, 75)">
|
|
26
|
+
${text}
|
|
27
|
+
</text>
|
|
28
|
+
</svg>
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 将颜色转换为 RGBA 格式
|
|
34
|
+
* @param {string} color - 颜色值 (hex 或 rgba)
|
|
35
|
+
* @param {number} opacity - 透明度
|
|
36
|
+
* @returns {string} RGBA 颜色字符串
|
|
37
|
+
*/
|
|
38
|
+
function convertToRGBA(color, opacity) {
|
|
39
|
+
if (color.startsWith('rgba')) {
|
|
40
|
+
return color.replace(/[\d.]+\)$/, `${opacity})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (color.startsWith('rgb')) {
|
|
44
|
+
return color.replace(')', `, ${opacity})`).replace('rgb', 'rgba');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (color.startsWith('#')) {
|
|
48
|
+
const hex = color.replace('#', '');
|
|
49
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
50
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
51
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
52
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return `rgba(128, 128, 128, ${opacity})`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 创建平铺水印层
|
|
60
|
+
* @param {number} width - 图片宽度
|
|
61
|
+
* @param {number} height - 图片高度
|
|
62
|
+
* @param {string} text - 水印文本
|
|
63
|
+
* @param {Object} options - 水印选项
|
|
64
|
+
* @returns {Promise<Buffer>} 水印层 Buffer
|
|
65
|
+
*/
|
|
66
|
+
async function createTiledWatermark(width, height, text, options) {
|
|
67
|
+
const { density, opacity, color, fontSize } = options;
|
|
68
|
+
|
|
69
|
+
const watermarkSVG = createWatermarkSVG(text, { opacity, color, fontSize });
|
|
70
|
+
const watermarkBuffer = Buffer.from(watermarkSVG);
|
|
71
|
+
|
|
72
|
+
const watermarkImage = sharp(watermarkBuffer);
|
|
73
|
+
const watermarkMeta = await watermarkImage.metadata();
|
|
74
|
+
|
|
75
|
+
const tileSize = Math.max(watermarkMeta.width, watermarkMeta.height);
|
|
76
|
+
const spacing = Math.floor(tileSize / density);
|
|
77
|
+
|
|
78
|
+
const cols = Math.ceil(width / spacing) + 1;
|
|
79
|
+
const rows = Math.ceil(height / spacing) + 1;
|
|
80
|
+
|
|
81
|
+
const tiledWidth = cols * tileSize;
|
|
82
|
+
const tiledHeight = rows * tileSize;
|
|
83
|
+
|
|
84
|
+
const composites = [];
|
|
85
|
+
for (let row = 0; row < rows; row++) {
|
|
86
|
+
for (let col = 0; col < cols; col++) {
|
|
87
|
+
composites.push({
|
|
88
|
+
input: watermarkBuffer,
|
|
89
|
+
left: col * spacing,
|
|
90
|
+
top: row * spacing
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tiledWatermark = await sharp({
|
|
96
|
+
create: {
|
|
97
|
+
width: tiledWidth,
|
|
98
|
+
height: tiledHeight,
|
|
99
|
+
channels: 4,
|
|
100
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
.composite(composites)
|
|
104
|
+
.extract({ left: 0, top: 0, width, height })
|
|
105
|
+
.png()
|
|
106
|
+
.toBuffer();
|
|
107
|
+
|
|
108
|
+
return tiledWatermark;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 给图片添加水印
|
|
113
|
+
* @param {string|Buffer} input - 图片路径或 Buffer
|
|
114
|
+
* @param {Object} watermarkOptions - 水印配置
|
|
115
|
+
* @param {string} watermarkOptions.text - 水印文本
|
|
116
|
+
* @param {number} [watermarkOptions.opacity=0.5] - 透明度 (0-1)
|
|
117
|
+
* @param {number} [watermarkOptions.density=3] - 水印密度 (1-10)
|
|
118
|
+
* @param {string} [watermarkOptions.color='#ffffff'] - 水印颜色
|
|
119
|
+
* @param {number} [watermarkOptions.fontSize=24] - 字体大小
|
|
120
|
+
* @returns {Promise<Buffer>} 添加水印后的图片 Buffer
|
|
121
|
+
*/
|
|
122
|
+
export async function addWatermark(input, watermarkOptions) {
|
|
123
|
+
const { text, opacity = 0.5, density = 3, color = '#ffffff', fontSize = 24 } = watermarkOptions;
|
|
124
|
+
|
|
125
|
+
if (!text) {
|
|
126
|
+
throw new Error('Watermark text is required');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (opacity < 0 || opacity > 1) {
|
|
130
|
+
throw new Error('Opacity must be between 0 and 1');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (density < 1 || density > 10) {
|
|
134
|
+
throw new Error('Density must be between 1 and 10');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const image = sharp(input);
|
|
138
|
+
const metadata = await image.metadata();
|
|
139
|
+
|
|
140
|
+
const watermarkBuffer = await createTiledWatermark(
|
|
141
|
+
metadata.width,
|
|
142
|
+
metadata.height,
|
|
143
|
+
text,
|
|
144
|
+
{ opacity, density, color, fontSize }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const result = await image
|
|
148
|
+
.composite([{ input: watermarkBuffer, blend: 'over' }])
|
|
149
|
+
.toBuffer();
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
package/src/utils/cache.js
CHANGED
|
@@ -4,9 +4,18 @@ import crypto from 'crypto';
|
|
|
4
4
|
|
|
5
5
|
const CACHE_DIR = path.resolve('node_modules/.cache/@zhaoshijun/compress');
|
|
6
6
|
|
|
7
|
-
export async function getCache(content) {
|
|
7
|
+
export async function getCache(content, watermarkOptions) {
|
|
8
8
|
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
let cacheKey = hash;
|
|
11
|
+
if (watermarkOptions && watermarkOptions.text) {
|
|
12
|
+
const watermarkHash = crypto.createHash('sha256')
|
|
13
|
+
.update(JSON.stringify(watermarkOptions))
|
|
14
|
+
.digest('hex');
|
|
15
|
+
cacheKey = `${hash}-${watermarkHash}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cachePath = path.join(CACHE_DIR, cacheKey);
|
|
10
19
|
|
|
11
20
|
if (await fs.pathExists(cachePath)) {
|
|
12
21
|
return await fs.readFile(cachePath);
|
|
@@ -14,9 +23,18 @@ export async function getCache(content) {
|
|
|
14
23
|
return null;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
|
-
export async function setCache(content, compressedContent) {
|
|
26
|
+
export async function setCache(content, compressedContent, watermarkOptions) {
|
|
18
27
|
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
19
|
-
|
|
28
|
+
|
|
29
|
+
let cacheKey = hash;
|
|
30
|
+
if (watermarkOptions && watermarkOptions.text) {
|
|
31
|
+
const watermarkHash = crypto.createHash('sha256')
|
|
32
|
+
.update(JSON.stringify(watermarkOptions))
|
|
33
|
+
.digest('hex');
|
|
34
|
+
cacheKey = `${hash}-${watermarkHash}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cachePath = path.join(CACHE_DIR, cacheKey);
|
|
20
38
|
|
|
21
39
|
await fs.ensureDir(CACHE_DIR);
|
|
22
40
|
await fs.writeFile(cachePath, compressedContent);
|
package/vite/index.js
CHANGED
|
@@ -36,6 +36,13 @@ export function compressVitePlugin(options = {}) {
|
|
|
36
36
|
if (options.quality) config.quality = options.quality;
|
|
37
37
|
// 如果 options 里有 cache,覆盖 config.vite.cache
|
|
38
38
|
if (options.cache !== undefined) config.vite.cache = options.cache;
|
|
39
|
+
// 特殊处理:如果 options 里有 watermark,深度合并
|
|
40
|
+
if (options.watermark) {
|
|
41
|
+
config.watermark = {
|
|
42
|
+
...fileConfig.watermark,
|
|
43
|
+
...options.watermark
|
|
44
|
+
};
|
|
45
|
+
}
|
|
39
46
|
},
|
|
40
47
|
|
|
41
48
|
async generateBundle(outputOptions, bundle) {
|
|
@@ -62,7 +69,7 @@ export function compressVitePlugin(options = {}) {
|
|
|
62
69
|
|
|
63
70
|
// 缓存检查
|
|
64
71
|
if (config.vite.cache) {
|
|
65
|
-
const cached = await getCache(originalBuffer);
|
|
72
|
+
const cached = await getCache(originalBuffer, config.watermark);
|
|
66
73
|
if (cached) {
|
|
67
74
|
compressedBuffer = cached;
|
|
68
75
|
isCached = true;
|
|
@@ -76,7 +83,7 @@ export function compressVitePlugin(options = {}) {
|
|
|
76
83
|
|
|
77
84
|
// 写入缓存
|
|
78
85
|
if (config.vite.cache) {
|
|
79
|
-
await setCache(originalBuffer, compressedBuffer);
|
|
86
|
+
await setCache(originalBuffer, compressedBuffer, config.watermark);
|
|
80
87
|
}
|
|
81
88
|
} catch (error) {
|
|
82
89
|
console.error(chalk.red(`[compress] Failed to compress ${fileName}: ${error.message}`));
|