UNPKG

24.5 kBJavaScriptView Raw
1const querystring = require('querystring')
2const { AliImgError } = require('./error')
3
4// 文字水印的最大字节数
5const MAX_BYTE_SIZE = 64
6// 接口支持的图片输出格式
7const TYPE_LIST = ['jpg', 'png', 'webp', 'bmp', 'gif', 'tiff']
8// 接口支持的字体列表
9const FONTS_LIST = ['wqy-zenhei', 'wqy-microhei', 'fangzhengshusong', 'fangzhengkaiti', 'fangzhengheiti', 'fangzhengfangsong', 'droidsansfallback']
10// 字体列表中文
11const FONTS_LIST_CN = ['文泉驿正黑', '文泉微米黑', '方正书宋', '方正楷体', '方正黑体', '方正仿宋', 'DroidSansFallback']
12// 接口支持的原点位置
13const ORIGIN_LIST = ['nw', 'north', 'ne', 'west', 'center', 'east', 'sw', 'south', 'se']
14// 可选的缩放模式
15const RESIZE_MODE = ['lfit', 'mfit', 'fill', 'pad', 'fixed']
16
17class Img {
18 constructor (path) {
19 this.data = {path, objectName: this.getObjectName()}
20 this.child = [this.data]
21
22 // 保存图片的处理参数,键为操作命令,如:resize, crop等
23 // 值为命令对应的参数,类型为数字、对象、对象数组,对象类型用于保存复杂的参数表,对象数组用于保存多个水印操作
24 this.query = {}
25
26 // 保存设置的文字样式,当使用文字水印时,会将配置复制到query对象
27 this.style = {}
28 }
29
30 /**
31 * 调整图片尺寸大小
32 * @param {Object} opt 配置对象
33 */
34 resize (opt) {
35 if (typeof opt !== 'object') {
36 throw new AliImgError('resize() opt 应为对象')
37 }
38
39 let param = {}
40 // 下限和上限
41 let lowerLimit = 1
42 let upperLimit = 4096
43 // 校验宽度值
44 if (typeof opt.width === 'number') {
45 // 超过边界,取边界值
46 param.w = Math.max(Math.min(opt.width, upperLimit), lowerLimit)
47 } else if (opt.width) {
48 throw new AliImgError('resize() width 应为数字类型')
49 }
50
51 // 校验高度值
52 if (typeof opt.height === 'number') {
53 // 超过边界,取边界值
54 param.h = Math.max(Math.min(opt.height, upperLimit), lowerLimit)
55 } else if (opt.height) {
56 throw new AliImgError('resize() height 应为数字类型')
57 }
58
59 // 校验最长边
60 if (typeof opt.longest === 'number') {
61 // 超过边界,取边界值
62 param.l = Math.max(Math.min(opt.longest, upperLimit), lowerLimit)
63 } else if (opt.longest) {
64 throw new AliImgError('resize() longest 应为数字类型')
65 }
66
67 // 校验最短边
68 if (typeof opt.shortest === 'number') {
69 // 超过边界,取边界值
70 param.s = Math.max(Math.min(opt.shortest, upperLimit), lowerLimit)
71 } else if (opt.shortest) {
72 throw new AliImgError('resize() shortest 应为数字类型')
73 }
74
75 // 校验 limit
76 if (typeof opt.limit === 'number') {
77 // 接口只支持 0 和 1
78 param.limit = Number(Boolean(opt.limit))
79 } else if (opt.limit) {
80 throw new AliImgError('resize() limit 应为数字类型')
81 }
82
83 // 校验 mode
84 if (RESIZE_MODE.includes(opt.mode)) {
85 param.m = opt.mode
86 } else if (opt.mode) {
87 throw new AliImgError('resize() mode 值不支持')
88 }
89
90 // 校验 color
91 if (typeof opt.color === 'string') {
92 param.color = opt.color
93 } else if (opt.color) {
94 throw new AliImgError('resize() color 应为字符串')
95 }
96
97 // 校验 percent
98 if (typeof opt.percent === 'number') {
99 let lowerLimit = 1
100 let upperLimit = 1000
101 // 超过边界,则取边界值
102 param.p = Math.max(Math.min(opt.percent, upperLimit), lowerLimit)
103 } else if (opt.percent) {
104 throw new AliImgError('resize() percent 应为数字类型')
105 }
106
107 this.query.resize = param
108 return this
109 }
110
111 /**
112 * 图片内切圆。如果图片的最终格式是 png、webp、 bmp 等支持透明通道的图片,那么图片非圆形区域的地方将会以透明填充。如果图片的最终格式是 jpg,那么非圆形区域是以白色进行填充。推荐保存成 png 格式。如果指定半径大于原图最大内切圆的半径,则圆的大小仍然是图片的最大内切圆。
113 * @param {number} radius 从图片取出的圆形区域的半径,半径不能超过原图的最小边的一半。如果超过,则圆的大小仍然是原圆的最大内切圆。
114 */
115 circle (radius) {
116 if (typeof radius !== 'number') {
117 throw new AliImgError('circle() radius 应为数字类型')
118 }
119
120 this.query.circle = { r: radius }
121 return this
122 }
123
124 /**
125 * 裁剪图片,指定裁剪的起始点以及裁剪的宽高来决定裁剪的区域。如果指定的起始横纵坐标大于原图,将会返回错误。
126 * 如果从起点开始指定的宽度和高度超过了原图,将会直接裁剪到原图结尾。
127 * @param {number} x 指定裁剪起点横坐标(默认左上角为原点)
128 * @param {number} y 指定裁剪起点纵坐标(默认左上角为原点)
129 * @param {number} w 指定裁剪宽度
130 * @param {number} h 指定裁剪高度
131 * @param {string} origin 设置裁剪的原点位置,由九宫格的格式,一共有九个地方可以设置,每个位置位于每个九宫格的左上角
132 */
133 crop (x, y, w, h, origin) {
134 let argsMap = ['x', 'y', 'width', 'height']
135 for (let i = 0; i < 4; i++) {
136 if (typeof arguments[i] !== 'number') {
137 throw new AliImgError(`crop() ${argsMap[i]} 应为数字类型`)
138 }
139 }
140
141 // 校验 origin 值是否支持,origin 也可以是 undefined
142 let originOpts = ORIGIN_LIST.concat(undefined)
143 if (!originOpts.includes(origin)) {
144 throw new AliImgError('crop() origin 不支持该值')
145 }
146
147 // 超过边界,则取边界值
148 let lowerLimit = 0
149 w = Math.max(w, lowerLimit)
150 h = Math.max(h, lowerLimit)
151 x = Math.max(x, lowerLimit)
152 y = Math.max(y, lowerLimit)
153
154 let param = { x, y, w, h }
155 if (origin) {
156 param.g = origin
157 }
158 this.query.crop = param
159 return this
160 }
161
162 /**
163 * 索引切割(横向或纵向)。将图片分成 x 轴和 y 轴,按指定长度 (length) 切割,指定索引 (index),取出指定的区域。
164 * @param {Object} opt 配置对象
165 */
166 indexCrop (opt) {
167 if (typeof opt !== 'object') {
168 throw new AliImgError('indexCrop() opt 应为配置对象')
169 }
170 if (typeof opt.i !== 'number') {
171 throw new AliImgError('indexCrop() 索引应为数字类型')
172 }
173 if (typeof opt.x !== 'number' && typeof opt.y !== 'number') {
174 throw new AliImgError('indexCrop() x 或 y 应为数字类型')
175 }
176
177 let newOpt = {...opt}
178 // 索引值的下限
179 let lowerLimit = 0
180 // 索引值超过下限,则取下限值
181 newOpt.i = Math.max(newOpt.i, lowerLimit)
182 // 索引值应为整数
183 newOpt.i = parseInt(newOpt.i)
184 // 键 indexcrop 应该全小写
185 this.query.indexcrop = newOpt
186 return this
187 }
188
189 /**
190 * 圆角矩形裁剪。
191 * 如果图片的最终格式是 png、webp、bmp 等支持透明通道的图片,那么图片非圆形区域的地方将会以透明填充。
192 * 如果图片的最终格式是 jpg, 那么非圆形区域是以白色进行填充 。推荐保存成 png 格式。
193 * 如果指定半径大于原图最大内切圆的半径,则圆角的大小仍然是图片的最大内切圆。
194 * @param {number} radius 圆角的半径。半径最大不能超过原图的最小边的一半。
195 */
196 roundedCorners (radius) {
197 if (typeof radius !== 'number') {
198 throw new AliImgError('roundedCorners() radius 应为数字类型')
199 }
200
201 let lowerLimit = 1
202 let upperLimit = 4096
203 // 超过下限,则取下限值
204 radius = Math.max(Math.min(radius, upperLimit), lowerLimit)
205
206 this.query['rounded-corners'] = { r: radius }
207 return this
208 }
209
210 /**
211 * 自适应方向。某些手机拍摄出来的照片可能带有旋转参数(存放在照片exif信息里面)。可以设置是否对这些图片进行旋转。
212 * 默认是设置自适应方向。进行自适应方向旋转,要求原图的宽度和高度必须小于 4096。
213 * 如果原图没有旋转参数,加上auto-orient参数不会对图有影响。
214 * @param {number} value 0:表示按原图默认方向,不进行自动旋转。1:先进行图片进行旋转,然后再进行缩略
215 */
216 autoOrient (value) {
217 if (typeof value !== 'number') {
218 throw new AliImgError('autoOrient() value 应为数字类型')
219 }
220
221 // 接口只支持值为 0 和 1
222 value = Number(Boolean(value))
223 this.query['auto-orient'] = value
224 return this
225 }
226
227 /**
228 * 图像旋转。重复调用,在原值上面叠加效果
229 * @param {number} angle 旋转的角度。正值为顺时针旋转,负值为逆时针旋转
230 */
231 rotate (angle) {
232 if (typeof angle !== 'number') {
233 throw new AliImgError('rotate() angle 应为数字类型')
234 }
235 // 上次设置的值
236 let origin = this.query.rotate || 0
237 // 原接口不支持负值和大于360的值
238 // 重复调用,在原值上面叠加效果
239 this.query.rotate = ((origin + angle) % 360 + 360) % 360
240 return this
241 }
242
243 /**
244 * 图片模糊操作。重复调用,在原值上面叠加效果
245 * @param {number} radius 模糊半径。值越大图片越模糊,取值[1, 50]。超出边界则取边界值
246 * @param {number} standard 正态分布的标准差。值越大图片越模糊。取值[1, 50]。超出边界则取边界值
247 */
248 blur (radius, standard) {
249 if (typeof radius !== 'number') {
250 throw new AliImgError('blur() radius 应为数字类型')
251 }
252 if (typeof standard !== 'number') {
253 throw new AliImgError('blur() standard 应为数字类型')
254 }
255
256 // 重复调用,在原值上面叠加效果
257 radius += this.query.blur ? this.query.blur.r : 0
258 standard += this.query.blur ? this.query.blur.s : 0
259
260 // 上限和下限
261 let lowerLimit = 1
262 let upperLimit = 50
263 // 传入值超过边界值时,取边界值
264 radius = Math.max(Math.min(radius, upperLimit), lowerLimit)
265 standard = Math.max(Math.min(standard, upperLimit), lowerLimit)
266
267 this.query.blur = {r: radius, s: standard}
268 return this
269 }
270
271 /**
272 * 调节图片亮度。重复调用,在原值上面叠加效果
273 * @param {number} brightness 亮度。0 表示原图亮度,小于 0 表示低于原图亮度,大于 0 表示高于原图亮度。取值[-100, 100],超出边界则取边界值
274 */
275 bright(brightness) {
276 if (typeof brightness !== 'number') {
277 throw new AliImgError('bright() brightness 应为数字类型')
278 }
279 let origin = this.query.bright || 0
280 // 重复调用,在原值上面叠加效果
281 brightness += origin
282 let lowerLimit = -100
283 let upperLimit = 100
284 // 传入值超过边界值时,取边界值
285 brightness = Math.max(Math.min(brightness, upperLimit), lowerLimit)
286 this.query.bright = brightness
287 return this
288 }
289
290 /**
291 * 调节图片对比度。重复调用,在原值上面叠加效果
292 * @param {number} value 对比度。0 表示原图对比度,小于 0 表示低于原图对比度,大于 0 表示高于原图对比度。取值[-100, 100],超出边界则取边界值
293 */
294 contrast (value) {
295 if (typeof value !== 'number') {
296 throw new AliImgError('contrast() value 应为数字类型')
297 }
298
299 let origin = this.query.contrast || 0
300 // 重复调用,在原值上面叠加效果
301 value += origin
302 let lowerLimit = -100
303 let upperLimit = 100
304 // 传入值超过边界值时,取边界值
305 value = Math.max(Math.min(value, upperLimit), lowerLimit)
306 this.query.contrast = value
307 return this
308 }
309
310 /**
311 * 图片锐化操作,使图片变得清晰。重复调用,在原值上面叠加效果
312 * @param {number} value 锐化值。参数越大,越清晰。取值[50, 399],超出边界则取边界值。
313 */
314 sharpen (value) {
315 if (typeof value !== 'number') {
316 throw new AliImgError('sharpen() value 应为数字类型')
317 }
318
319 let origin = this.query.sharpen || 0
320 // 重复调用,在原值上面叠加效果
321 value += origin
322
323 // 上限和下限
324 let lowerLimit = 50
325 let upperLimit = 399
326 // 传入值超过边界值时,取边界值
327 value = Math.max(Math.min(value, upperLimit), lowerLimit)
328 this.query.sharpen = value
329 return this
330 }
331
332 /**
333 * 将图片转换成对应格式。 默认不填格式,是按原图格式返回。
334 * @param {string} type 输出的图片格式
335 */
336 format (type) {
337 if (!TYPE_LIST.includes(type)) {
338 throw new AliImgError('format() 不支持该输出格式')
339 }
340 this.query.format = type
341 return this
342 }
343
344 /**
345 * 调节 jpg 格式图片的呈现方式
346 * @param {number} type 0 表示保存成普通的 jpg 格式,1 表示保存成渐进显示的 jpg 格式
347 */
348 interlace (type) {
349 if (typeof type !== 'number') {
350 throw new AliImgError('interlace() type 应为数字类型')
351 }
352 // 接口只接受值为 0 和 1
353 type = Number(Boolean(type))
354 this.query.interlace = type
355 return this
356 }
357
358 /**
359 * 图片保存成 jpg 或 webp, 可以支持质量变换。重复调用,在原值上面叠加效果
360 * @param {number} value 百分比,决定图片的相对质量,对原图按照 value% 进行质量压缩。如果原图质量是 100%,使用 90 会得到质量为 90% 的图片;如果原图质量是 80%,使用 90 会得到质量72%的图片。只能在原图是 jpg 格式的图片上使用,才有相对压缩的概念。如果原图为 webp,那么相对质量就相当于绝对质量。传入值超过边界值时,取边界值
361 */
362 quality (value) {
363 if (typeof value !== 'number') {
364 throw new AliImgError('quality() value 应为数字类型')
365 }
366
367 let origin = this.query.quality ? this.query.quality.q : 100
368 // 重复调用,在原值上面叠加效果
369 value = parseInt(origin / 100 * value)
370
371 // 上限和下限
372 let lowerLimit = 1
373 let upperLimit = 100
374 // 传入值超过边界值时,取边界值
375 value = Math.max(Math.min(value, upperLimit), lowerLimit)
376 this.query.quality = { q: value }
377 return this
378 }
379
380 /**
381 * 图片保存成 jpg 或 webp, 可以支持质量变换。
382 * @param {number} value 百分比,决定图片的绝对质量,把原图质量压到 value%,如果原图质量小于指定数字,则不压缩。如果原图质量是100%,使用”90”会得到质量90%的图片;如果原图质量是95%,使用“90”还会得到质量90%的图片;如果原图质量是80%,使用“90”不会压缩,返回质量80%的原图。只能在保存格式为jpg/webp效果上使用,其他格式无效果。 如果同时指定了相对和绝对,按绝对值来处理。传入值超过边界值时,取边界值
383 */
384 absQual (value) {
385 if (typeof value !== 'number') {
386 throw new AliImgError('absQual() value 应为数字类型')
387 }
388
389 // 上限和下限
390 let lowerLimit = 1
391 let upperLimit = 100
392 // 传入值超过边界值时,取边界值
393 value = Math.max(Math.min(value, upperLimit), lowerLimit)
394 this.query.quality = { Q: value }
395 return this
396 }
397
398 /**
399 * 图片水印。水印操作可以在图片上设置另外一张图片或一段文字做为水印。可重复调用
400 * @param {string|Img} content 字符串或Img实例
401 * @param {Object} opt 配置对象
402 */
403 watermark (content, opt) {
404 let param = {}
405 opt = opt || {}
406
407 if (typeof content === 'string') {
408 if (Buffer.byteLength(content) > MAX_BYTE_SIZE) {
409 throw new AliImgError(`watermark() content 文本最多支持${MAX_BYTE_SIZE}字节`)
410 }
411 param.text = this.toBase64(content)
412 } else if (content instanceof Img) {
413 this.addChild(content.child)
414 param.image = this.toBase64(content.toString())
415 } else {
416 throw new AliImgError('watermark() content 应为字符串或 Img 实例')
417 }
418 if (typeof opt !== 'object') {
419 throw new AliImgError('watermark() opt 应为对象')
420 }
421
422 // x 和 y 的下限和上限
423 let lowerLimit = 0
424 let upperLimit = 4096
425
426 // x 可传入数字或不传,默认为10。超过边界,则取边界值
427 if (typeof opt.x === 'number') {
428 param.x = Math.max(Math.min(opt.x, upperLimit), lowerLimit)
429 } else if (opt.x) {
430 throw new AliImgError('watermark() x 应为数字类型')
431 }
432
433 // y 可传入数字或不传,默认为10。超过边界,则取边界值
434 if (typeof opt.y === 'number') {
435 param.y = Math.max(Math.min(opt.y, upperLimit), lowerLimit)
436 } else if (opt.y) {
437 throw new AliImgError('watermark() y 应为数字类型')
438 }
439
440 // 校验位置值
441 if (ORIGIN_LIST.includes(opt.position)) {
442 param.g = opt.position
443 } else if (opt.position) {
444 throw new AliImgError('watermark() position 值不支持')
445 }
446
447 // 校验透明度
448 if (typeof opt.transparency === 'number') {
449 // 下限和上限
450 let lowerLimit = 0
451 let upperLimit = 100
452 // 超过边界,则取边界值
453 param.t = Math.max(Math.min(opt.transparency, upperLimit), lowerLimit)
454 } else if (opt.transparency) {
455 throw new AliImgError('watermark() transparency 应为数字类型')
456 }
457
458 // 校验中线偏移值
459 if (typeof opt.voffset === 'number') {
460 // 下限和上限
461 let lowerLimit = -1000
462 let upperLimit = 1000
463 // 超过边界,则取边界值
464 param.voffset = Math.max(Math.min(opt.voffset, upperLimit), lowerLimit)
465 } else if (opt.voffset) {
466 throw new AliImgError('watermark() voffset 应为数字类型')
467 }
468
469 // 校验水印的铺满效果
470 if (typeof opt.fill === 'number') {
471 // 只支持值为 0 和 1
472 param.fill = Number(Boolean(opt.fill))
473 } else if (opt.fill) {
474 throw new AliImgError('watermark() fill 应为数字类型')
475 }
476
477 // 将单独设置的文字效果复制到配置对象
478 ['color', 'size', 'type', 'shadow', 'rotate'].forEach(key => {
479 if (this.style[key]) {
480 param[key] = this.style[key]
481 }
482 })
483
484 this.query.watermark = this.query.watermark || []
485 this.query.watermark.push(param)
486 return this
487 }
488
489 /**
490 * 设置文字填充颜色
491 * @param {string} color 文字水印的颜色。6位16进制数 如:000000表示黑色。FFFFFF表示的是白色。默认值:黑色
492 */
493 fill (color) {
494 if (typeof color !== 'string') {
495 throw new AliImgError('fill() color 应为字符串')
496 }
497 // 允许字符串以井号开头
498 if (color[0] === '#') {
499 color = color.slice(1)
500 }
501 // 允许颜色值简写形式
502 if (color.length === 3) {
503 color = color.split('').map(char => char + char).join('')
504 }
505 if (!/[0-9A-F]{6}/i.test(color)) {
506 throw new AliImgError('fill() color 应为6位16进制数')
507 }
508 this.style.color = color
509 return this
510 }
511
512 /**
513 * 设置文字字体
514 * @param {string} name 文字水印的字体类型。默认值:文泉驿正黑。可输入参数对应的中文
515 */
516 font (name) {
517 if (FONTS_LIST.includes(name)) {
518 // 如果输入值为英文列表内的选项,则直接转 base64
519 this.style.type = this.toBase64(name)
520 } else if (FONTS_LIST_CN.includes(name)) {
521 // 如果输入值为中文表内的选项,则先转换成英文表内的对应项,再转 base64
522 const index = FONTS_LIST_CN.indexOf(name)
523 this.style.type = this.toBase64(FONTS_LIST[index])
524 } else {
525 throw new AliImgError(`font() 不支持该字体 ${name}`)
526 }
527 return this
528 }
529
530 /**
531 * 设置文字大小
532 * @param {number} size 文字水印大小(px)。取值范围:(0,1000],传入值超过边界值时,取边界值。默认值:40
533 */
534 fontSize (size) {
535 if (typeof size !== 'number') {
536 throw new AliImgError('fontSize() size 应为数字类型')
537 }
538
539 // 上限和下限
540 let lowerLimit = 1
541 let upperLimit = 1000
542 // 传入值超过边界值时,取边界值
543 size = Math.max(Math.min(size, upperLimit), lowerLimit)
544 // 字体大小只支持整数
545 size = parseInt(size)
546 this.style.size = size
547 return this
548 }
549
550 /**
551 * 设置文字水印的阴影透明度
552 * @param {number} transparency 文字阴影的透明度
553 */
554 textShadow(transparency) {
555 if (typeof transparency !== 'number') {
556 throw new AliImgError('textShadow() transparency 应为数字类型')
557 }
558
559 // 下限和上限
560 let lowerLimit = 1
561 let upperLimit = 100
562 // 超过边界,则取边界值
563 this.style.shadow = Math.max(Math.min(transparency, upperLimit), lowerLimit)
564 return this
565 }
566
567 /**
568 * 设置文字的旋转角度
569 * @param {number} angle 文字的旋转角度
570 */
571 textRotate(angle) {
572 if (typeof angle !== 'number') {
573 throw new AliImgError('textRotate() angle 应为数字类型')
574 }
575
576 // 原接口不支持负值和大于360的值
577 this.style.rotate = (angle % 360 + 360) % 360
578 return this
579 }
580
581 // 将传入的字符串转成URL可用的BASE64字符串
582 toBase64 (str) {
583 return Buffer.from(str).toString('base64').replace(/[+/]/g, (match) => {
584 return match == '+' ? '-' : '_'
585 })
586 }
587
588 // 获取随机的临时OSS对象名
589 getObjectName () {
590 return 'temp/' + Date.now() + parseInt(Math.random() * 8999 + 1000) + '.jpg'
591 }
592
593 // 将图片的依赖资源放到列表
594 addChild (arr) {
595 this.child = this.child.concat(arr)
596 }
597
598 // 将对图片的操作命令序列化成URL请求参数
599 stringify () {
600 // 格式化键值对
601 let fmt = function(key, value) {
602 let param = ''
603 if (typeof value === 'object') {
604 param = querystring.stringify(value, ',', '_', {
605 encodeURIComponent: encodeURI
606 })
607 } else {
608 param = value
609 }
610 return `image/${key},${param}`
611 }
612
613 return Object.keys(this.query).map((key) => {
614 let value = this.query[key]
615 if (Array.isArray(value)) {
616 return value.map(fmt.bind(null, key)).join(',')
617 } else {
618 return fmt(key, value)
619 }
620 }).join(',')
621 }
622
623 toString () {
624 return this.data.objectName + '?x-oss-process=' + this.stringify()
625 }
626}
627
628Object.assign(Img, { TYPE_LIST, FONTS_LIST, FONTS_LIST_CN, ORIGIN_LIST, RESIZE_MODE })
629
630module.exports = Img
\No newline at end of file