@studio-kit/utils-browser 1.0.1 → 1.0.3

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/src/Img.js ADDED
@@ -0,0 +1,482 @@
1
+ export default class Img {
2
+ /**
3
+ * 从 url / File / Blob / base64 获取图片宽高
4
+ * @param {{ url?: string, file?: File, blob?: Blob, base64?: string }} options
5
+ * @returns {Promise<{ width: number, height: number }>}
6
+ */
7
+ static async getSize({ url, file, blob, base64 } = {}) {
8
+ if (url != null && url !== '') {
9
+ return this.getSizeFromUrl(url)
10
+ }
11
+ if (file != null) {
12
+ return this.getSizeFromFile(file)
13
+ }
14
+ if (blob != null) {
15
+ return this.getSizeFromBlob(blob)
16
+ }
17
+ if (base64 != null && base64 !== '') {
18
+ return this.getSizeFromBase64(base64)
19
+ }
20
+ throw new Error('Img.getSize: 请传入 url / file / blob / base64 之一')
21
+ }
22
+
23
+ /**
24
+ * 将图片转为 base64,支持缩放与体积压缩
25
+ * @param {{
26
+ * url?: string,
27
+ * file?: File,
28
+ * blob?: Blob,
29
+ * base64?: string,
30
+ * credentials?: RequestCredentials,
31
+ * maxWidth?: number,
32
+ * maxHeight?: number,
33
+ * scale?: number,
34
+ * quality?: number,
35
+ * mimeType?: string,
36
+ * maxBytes?: number,
37
+ * }} options
38
+ * @returns {Promise<{
39
+ * base64: string,
40
+ * dataUrl: string,
41
+ * mimeType: string,
42
+ * width: number,
43
+ * height: number,
44
+ * byteLength: number,
45
+ * }>}
46
+ */
47
+ static async toBase64({
48
+ url,
49
+ file,
50
+ blob,
51
+ base64,
52
+ credentials = 'include',
53
+ maxWidth,
54
+ maxHeight,
55
+ scale,
56
+ quality = 0.92,
57
+ mimeType,
58
+ maxBytes,
59
+ } = {}) {
60
+ const source = await this.#resolveToBlob({
61
+ url,
62
+ file,
63
+ blob,
64
+ base64,
65
+ credentials,
66
+ })
67
+ const { image, objectUrl } = await this.#loadHtmlImageFromBlob(source.blob)
68
+ try {
69
+ const {
70
+ width: targetWidth,
71
+ height: targetHeight,
72
+ } = this.#calcTargetSize({
73
+ width: image.naturalWidth,
74
+ height: image.naturalHeight,
75
+ maxWidth,
76
+ maxHeight,
77
+ scale,
78
+ })
79
+ const outputMime = this.#normalizeOutputMime(
80
+ mimeType || source.mimeType || 'image/jpeg',
81
+ { preferLossy: maxBytes != null || quality < 1 },
82
+ )
83
+ let result = await this.#encodeCanvas({
84
+ image,
85
+ width: targetWidth,
86
+ height: targetHeight,
87
+ mimeType: outputMime,
88
+ quality,
89
+ })
90
+
91
+ if (maxBytes != null && maxBytes > 0 && result.byteLength > maxBytes) {
92
+ result = await this.#compressToMaxBytes({
93
+ image,
94
+ width: targetWidth,
95
+ height: targetHeight,
96
+ mimeType: outputMime,
97
+ quality,
98
+ maxBytes,
99
+ })
100
+ }
101
+
102
+ return {
103
+ base64: result.base64,
104
+ dataUrl: result.dataUrl,
105
+ mimeType: result.mimeType,
106
+ width: result.width,
107
+ height: result.height,
108
+ byteLength: result.byteLength,
109
+ }
110
+ } finally {
111
+ URL.revokeObjectURL(objectUrl)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {string} url
117
+ * @returns {Promise<{ width: number, height: number }>}
118
+ */
119
+ static getSizeFromUrl(url) {
120
+ return this.#measureFromSrc(url)
121
+ }
122
+
123
+ /**
124
+ * @param {File} file
125
+ * @returns {Promise<{ width: number, height: number }>}
126
+ */
127
+ static getSizeFromFile(file) {
128
+ return this.getSizeFromBlob(file)
129
+ }
130
+
131
+ /**
132
+ * @param {Blob} blob
133
+ * @returns {Promise<{ width: number, height: number }>}
134
+ */
135
+ static async getSizeFromBlob(blob) {
136
+ const objectUrl = URL.createObjectURL(blob)
137
+ try {
138
+ return await this.#measureFromSrc(objectUrl)
139
+ } finally {
140
+ URL.revokeObjectURL(objectUrl)
141
+ }
142
+ }
143
+
144
+ /**
145
+ * @param {string} base64 - data URL(data:image/...;base64,...)或纯 base64 字符串
146
+ * @returns {Promise<{ width: number, height: number }>}
147
+ */
148
+ static async getSizeFromBase64(base64) {
149
+ const value = String(base64).trim()
150
+ if (/^data:/i.test(value)) {
151
+ return this.#measureFromSrc(value)
152
+ }
153
+ const binary = atob(value.replace(/\s/g, ''))
154
+ const bytes = new Uint8Array(binary.length)
155
+ for (let i = 0; i < binary.length; i++) {
156
+ bytes[i] = binary.charCodeAt(i)
157
+ }
158
+ const mime = this.#detectMime(bytes) || 'image/png'
159
+ return this.getSizeFromBlob(new Blob([bytes], { type: mime }))
160
+ }
161
+
162
+ /**
163
+ * @param {{
164
+ * url?: string,
165
+ * file?: File,
166
+ * blob?: Blob,
167
+ * base64?: string,
168
+ * credentials?: RequestCredentials,
169
+ * }} options
170
+ * @returns {Promise<{ blob: Blob, mimeType: string }>}
171
+ */
172
+ static async #resolveToBlob({ url, file, blob, base64, credentials } = {}) {
173
+ if (url != null && url !== '') {
174
+ const response = await fetch(url, { credentials })
175
+ if (!response.ok) {
176
+ throw new Error(`Img.toBase64: 下载图片失败 (${response.status})`)
177
+ }
178
+ const remoteBlob = await response.blob()
179
+ return {
180
+ blob: remoteBlob,
181
+ mimeType: remoteBlob.type || 'image/png',
182
+ }
183
+ }
184
+ if (file != null) {
185
+ return {
186
+ blob: file,
187
+ mimeType: file.type || 'image/png',
188
+ }
189
+ }
190
+ if (blob != null) {
191
+ return {
192
+ blob,
193
+ mimeType: blob.type || 'image/png',
194
+ }
195
+ }
196
+ if (base64 != null && base64 !== '') {
197
+ const parsed = this.#base64ToBlob(base64)
198
+ return {
199
+ blob: parsed.blob,
200
+ mimeType: parsed.mimeType,
201
+ }
202
+ }
203
+ throw new Error('Img.toBase64: 请传入 url / file / blob / base64 之一')
204
+ }
205
+
206
+ /**
207
+ * @param {string} base64
208
+ * @returns {{ blob: Blob, mimeType: string }}
209
+ */
210
+ static #base64ToBlob(base64) {
211
+ const value = String(base64).trim()
212
+ let mimeType = 'image/png'
213
+ let pure = value
214
+ const dataUrlMatch = /^data:([^;,]+)?(;base64)?,(.*)$/i.exec(value)
215
+ if (dataUrlMatch) {
216
+ mimeType = dataUrlMatch[1] || mimeType
217
+ pure = dataUrlMatch[3] || ''
218
+ }
219
+ const binary = atob(pure.replace(/\s/g, ''))
220
+ const bytes = new Uint8Array(binary.length)
221
+ for (let i = 0; i < binary.length; i++) {
222
+ bytes[i] = binary.charCodeAt(i)
223
+ }
224
+ if (!dataUrlMatch) {
225
+ mimeType = this.#detectMime(bytes) || mimeType
226
+ }
227
+ return {
228
+ blob: new Blob([bytes], { type: mimeType }),
229
+ mimeType,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * @param {Blob} blob
235
+ * @returns {Promise<{ image: HTMLImageElement, objectUrl: string }>}
236
+ */
237
+ static #loadHtmlImageFromBlob(blob) {
238
+ const objectUrl = URL.createObjectURL(blob)
239
+ return new Promise((resolve, reject) => {
240
+ const image = new Image()
241
+ image.onload = () => {
242
+ resolve({ image, objectUrl })
243
+ }
244
+ image.onerror = () => {
245
+ URL.revokeObjectURL(objectUrl)
246
+ reject(new Error('Img.toBase64: 无法解码图片'))
247
+ }
248
+ image.src = objectUrl
249
+ })
250
+ }
251
+
252
+ /**
253
+ * @param {{
254
+ * width: number,
255
+ * height: number,
256
+ * maxWidth?: number,
257
+ * maxHeight?: number,
258
+ * scale?: number,
259
+ * }} options
260
+ * @returns {{ width: number, height: number }}
261
+ */
262
+ static #calcTargetSize({ width, height, maxWidth, maxHeight, scale } = {}) {
263
+ let w = Math.max(1, Number(width) || 1)
264
+ let h = Math.max(1, Number(height) || 1)
265
+
266
+ if (scale != null && Number(scale) > 0 && Number(scale) !== 1) {
267
+ w = Math.max(1, Math.round(w * Number(scale)))
268
+ h = Math.max(1, Math.round(h * Number(scale)))
269
+ }
270
+
271
+ let ratio = 1
272
+ if (maxWidth != null && maxWidth > 0 && w > maxWidth) {
273
+ ratio = Math.min(ratio, maxWidth / w)
274
+ }
275
+ if (maxHeight != null && maxHeight > 0 && h > maxHeight) {
276
+ ratio = Math.min(ratio, maxHeight / h)
277
+ }
278
+ if (ratio < 1) {
279
+ w = Math.max(1, Math.round(w * ratio))
280
+ h = Math.max(1, Math.round(h * ratio))
281
+ }
282
+ return { width: w, height: h }
283
+ }
284
+
285
+ /**
286
+ * @param {string} mimeType
287
+ * @param {{ preferLossy?: boolean }} options
288
+ * @returns {string}
289
+ */
290
+ static #normalizeOutputMime(mimeType, { preferLossy = false } = {}) {
291
+ const mime = String(mimeType || '').toLowerCase()
292
+ if (mime === 'image/jpeg' || mime === 'image/jpg' || mime === 'image/webp') {
293
+ return mime === 'image/jpg' ? 'image/jpeg' : mime
294
+ }
295
+ if (mime === 'image/png' || mime === 'image/gif' || mime === 'image/bmp') {
296
+ // 需要体积压缩时,PNG/GIF 改用 jpeg,便于 quality 生效
297
+ return preferLossy ? 'image/jpeg' : mime === 'image/bmp' ? 'image/png' : mime
298
+ }
299
+ return preferLossy ? 'image/jpeg' : 'image/png'
300
+ }
301
+
302
+ /**
303
+ * @param {{
304
+ * image: HTMLImageElement,
305
+ * width: number,
306
+ * height: number,
307
+ * mimeType: string,
308
+ * quality: number,
309
+ * }} options
310
+ * @returns {Promise<{
311
+ * base64: string,
312
+ * dataUrl: string,
313
+ * mimeType: string,
314
+ * width: number,
315
+ * height: number,
316
+ * byteLength: number,
317
+ * }>}
318
+ */
319
+ static async #encodeCanvas({ image, width, height, mimeType, quality }) {
320
+ const canvas = document.createElement('canvas')
321
+ canvas.width = width
322
+ canvas.height = height
323
+ const ctx = canvas.getContext('2d')
324
+ if (!ctx) {
325
+ throw new Error('Img.toBase64: 无法创建 canvas 上下文')
326
+ }
327
+ if (mimeType === 'image/jpeg') {
328
+ ctx.fillStyle = '#ffffff'
329
+ ctx.fillRect(0, 0, width, height)
330
+ }
331
+ ctx.drawImage(image, 0, 0, width, height)
332
+
333
+ const supportsQuality = mimeType === 'image/jpeg' || mimeType === 'image/webp'
334
+ const dataUrl = supportsQuality
335
+ ? canvas.toDataURL(mimeType, this.#clampQuality(quality))
336
+ : canvas.toDataURL(mimeType)
337
+ const base64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl
338
+ const byteLength = Math.floor((base64.length * 3) / 4)
339
+ - (base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0)
340
+
341
+ return {
342
+ base64,
343
+ dataUrl,
344
+ mimeType,
345
+ width,
346
+ height,
347
+ byteLength,
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 先降 quality,仍超体积再等比缩小尺寸,直到不超过 maxBytes
353
+ * @param {{
354
+ * image: HTMLImageElement,
355
+ * width: number,
356
+ * height: number,
357
+ * mimeType: string,
358
+ * quality: number,
359
+ * maxBytes: number,
360
+ * }} options
361
+ */
362
+ static async #compressToMaxBytes({
363
+ image,
364
+ width,
365
+ height,
366
+ mimeType,
367
+ quality,
368
+ maxBytes,
369
+ }) {
370
+ let outputMime = this.#normalizeOutputMime(mimeType, { preferLossy: true })
371
+ let currentQuality = this.#clampQuality(quality)
372
+ let currentWidth = width
373
+ let currentHeight = height
374
+ let result = await this.#encodeCanvas({
375
+ image,
376
+ width: currentWidth,
377
+ height: currentHeight,
378
+ mimeType: outputMime,
379
+ quality: currentQuality,
380
+ })
381
+
382
+ // 1) 逐步降低质量
383
+ while (result.byteLength > maxBytes && currentQuality > 0.4) {
384
+ currentQuality = Math.max(0.4, Number((currentQuality - 0.1).toFixed(2)))
385
+ result = await this.#encodeCanvas({
386
+ image,
387
+ width: currentWidth,
388
+ height: currentHeight,
389
+ mimeType: outputMime,
390
+ quality: currentQuality,
391
+ })
392
+ }
393
+
394
+ // 2) 仍超限则等比缩小
395
+ let guard = 0
396
+ while (result.byteLength > maxBytes && (currentWidth > 32 || currentHeight > 32) && guard < 12) {
397
+ guard += 1
398
+ currentWidth = Math.max(32, Math.round(currentWidth * 0.85))
399
+ currentHeight = Math.max(32, Math.round(currentHeight * 0.85))
400
+ result = await this.#encodeCanvas({
401
+ image,
402
+ width: currentWidth,
403
+ height: currentHeight,
404
+ mimeType: outputMime,
405
+ quality: currentQuality,
406
+ })
407
+ }
408
+
409
+ if (result.byteLength > maxBytes) {
410
+ throw new Error(
411
+ `Img.toBase64: 无法压缩到目标大小(当前 ${result.byteLength}B,目标 ${maxBytes}B)`,
412
+ )
413
+ }
414
+ return result
415
+ }
416
+
417
+ /**
418
+ * @param {number} quality
419
+ * @returns {number}
420
+ */
421
+ static #clampQuality(quality) {
422
+ const value = Number(quality)
423
+ if (!Number.isFinite(value)) {
424
+ return 0.92
425
+ }
426
+ return Math.min(1, Math.max(0.1, value))
427
+ }
428
+
429
+ /**
430
+ * @param {Uint8Array} bytes
431
+ * @returns {string | null}
432
+ */
433
+ static #detectMime(bytes) {
434
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
435
+ return 'image/jpeg'
436
+ }
437
+ if (
438
+ bytes.length >= 8 &&
439
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
440
+ ) {
441
+ return 'image/png'
442
+ }
443
+ if (
444
+ bytes.length >= 6 &&
445
+ bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 &&
446
+ bytes[3] === 0x38 && (bytes[4] === 0x39 || bytes[4] === 0x37) && bytes[5] === 0x61
447
+ ) {
448
+ return 'image/gif'
449
+ }
450
+ if (
451
+ bytes.length >= 12 &&
452
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
453
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
454
+ ) {
455
+ return 'image/webp'
456
+ }
457
+ if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
458
+ return 'image/bmp'
459
+ }
460
+ return null
461
+ }
462
+
463
+ /**
464
+ * @param {string} src
465
+ * @returns {Promise<{ width: number, height: number }>}
466
+ */
467
+ static #measureFromSrc(src) {
468
+ return new Promise((resolve, reject) => {
469
+ const image = new Image()
470
+ image.onload = () => {
471
+ resolve({
472
+ width: image.naturalWidth,
473
+ height: image.naturalHeight,
474
+ })
475
+ }
476
+ image.onerror = () => {
477
+ reject(new Error(`Img: 无法加载图片 (${src.slice(0, 64)})`))
478
+ }
479
+ image.src = src
480
+ })
481
+ }
482
+ }
package/src/Message.js ADDED
@@ -0,0 +1,7 @@
1
+ export default class Message {
2
+ static emitParentIframe({ data, origin = '*' }) {
3
+ console.log('emitParentIframe', data)
4
+ if (window.parent) window.parent.postMessage(data, origin);
5
+ else console.error('对应父级iframe不存在')
6
+ }
7
+ }
package/src/Perf.js ADDED
@@ -0,0 +1,3 @@
1
+ import CommonPerf from '@studio-kit/utils-common/Perf.js'
2
+ export default class Perf extends CommonPerf {
3
+ }