poops-images 1.0.0
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/LICENSE +21 -0
- package/README.md +598 -0
- package/lib/analyze.js +177 -0
- package/lib/cache.js +91 -0
- package/lib/config.js +159 -0
- package/lib/discover.js +34 -0
- package/lib/formats.js +107 -0
- package/lib/processor.js +463 -0
- package/lib/sizes.js +97 -0
- package/lib/svg.js +81 -0
- package/lib/utils/format.js +43 -0
- package/lib/utils/log.js +49 -0
- package/package.json +50 -0
- package/poops-images.js +169 -0
package/lib/processor.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import sharp from 'sharp'
|
|
4
|
+
import chokidar from 'chokidar'
|
|
5
|
+
import { validateConfig, configHash } from './config.js'
|
|
6
|
+
import { toSharpOptions, filterByUpscale } from './sizes.js'
|
|
7
|
+
import { convertFormat, resolveTargetFormats } from './formats.js'
|
|
8
|
+
import { discoverAll } from './discover.js'
|
|
9
|
+
import { analyzeImage } from './analyze.js'
|
|
10
|
+
import Cache from './cache.js'
|
|
11
|
+
import { processSvg } from './svg.js'
|
|
12
|
+
import log from './utils/log.js'
|
|
13
|
+
import { formatBytes, formatTime } from './utils/format.js'
|
|
14
|
+
|
|
15
|
+
export default class ImageProcessor {
|
|
16
|
+
constructor(config) {
|
|
17
|
+
this.config = validateConfig(config)
|
|
18
|
+
this.cache = new Cache(this.config.out, this.config.cache)
|
|
19
|
+
this.stats = { processed: 0, variants: 0, skipped: 0, bytes: 0, startTime: 0 }
|
|
20
|
+
this.watcher = null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async processAll(options = {}) {
|
|
24
|
+
const { force = false, dryRun = false } = options
|
|
25
|
+
this.stats = { processed: 0, variants: 0, skipped: 0, bytes: 0, startTime: Date.now() }
|
|
26
|
+
|
|
27
|
+
this.cache.load()
|
|
28
|
+
|
|
29
|
+
const hash = configHash(this.config)
|
|
30
|
+
if (this.cache.getConfigHash() !== hash) {
|
|
31
|
+
if (this.cache.getConfigHash() !== null) {
|
|
32
|
+
log({ tag: 'image', text: 'Config changed, reprocessing all images' })
|
|
33
|
+
}
|
|
34
|
+
this.cache.invalidateAll()
|
|
35
|
+
this.cache.setConfigHash(hash)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Discover all file types in parallel
|
|
39
|
+
const { raster: sourceFiles, svg: svgFiles, gif: gifFiles } = await discoverAll(this.config)
|
|
40
|
+
|
|
41
|
+
if (sourceFiles.length > 0) {
|
|
42
|
+
log({ tag: 'image', text: `Found ${sourceFiles.length} source image(s)` })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Raster pipeline — resize and format conversion
|
|
46
|
+
if (dryRun) {
|
|
47
|
+
for (const file of sourceFiles) {
|
|
48
|
+
log({ tag: 'image', text: 'Would process:', link: file })
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
const queue = [...sourceFiles]
|
|
52
|
+
const workers = []
|
|
53
|
+
for (let i = 0; i < this.config.concurrency; i++) {
|
|
54
|
+
workers.push(this._worker(queue, force))
|
|
55
|
+
}
|
|
56
|
+
await Promise.all(workers)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// SVG + GIF pipelines — run concurrently
|
|
60
|
+
await Promise.all([
|
|
61
|
+
this._processSvgPipeline(svgFiles, { force, dryRun }),
|
|
62
|
+
this._processGifPipeline(gifFiles, { force, dryRun })
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
if (!dryRun) this.cache.save()
|
|
66
|
+
|
|
67
|
+
const elapsed = formatTime(Date.now() - this.stats.startTime)
|
|
68
|
+
log({
|
|
69
|
+
tag: 'image',
|
|
70
|
+
text: `\u2713 ${this.stats.processed} image(s) \u2192 ${this.stats.variants} variant(s)`,
|
|
71
|
+
size: this.stats.skipped > 0 ? `(${this.stats.skipped} skipped)` : '',
|
|
72
|
+
time: elapsed
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return this.getStats()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async processImage(inputPath, force = false) {
|
|
79
|
+
const relativePath = path.relative(this.config.in, inputPath)
|
|
80
|
+
|
|
81
|
+
// Stat + cache check
|
|
82
|
+
let stat
|
|
83
|
+
try {
|
|
84
|
+
stat = fs.statSync(inputPath)
|
|
85
|
+
} catch {
|
|
86
|
+
log({ tag: 'error', text: 'Cannot read file:', link: inputPath })
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!force && this.cache.shouldSkip(relativePath, stat.mtimeMs, stat.size)) {
|
|
91
|
+
this.stats.skipped++
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
log({ tag: 'image', text: 'Processing:', link: relativePath })
|
|
96
|
+
|
|
97
|
+
// Analyze — metadata, transparency, format normalization, effective sizes
|
|
98
|
+
const job = await analyzeImage(inputPath, this.config, stat)
|
|
99
|
+
if (!job) return
|
|
100
|
+
|
|
101
|
+
const startTime = Date.now()
|
|
102
|
+
const outputs = []
|
|
103
|
+
|
|
104
|
+
// Get previous outputs before processing (for stale cleanup)
|
|
105
|
+
const previousOutputs = this.cache.getOutputs(relativePath)
|
|
106
|
+
|
|
107
|
+
// Filter sizes by upscale constraints
|
|
108
|
+
const eligibleSizes = filterByUpscale(
|
|
109
|
+
job.effectiveSizes, job.sourceWidth, job.sourceHeight
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for (const sizeDef of eligibleSizes) {
|
|
113
|
+
const conversionOnly = sizeDef.width === 0 && sizeDef.height === 0
|
|
114
|
+
|
|
115
|
+
// Resize
|
|
116
|
+
const sharpOpts = toSharpOptions(sizeDef)
|
|
117
|
+
const pipeline = sharpOpts ? sharp(inputPath).rotate().resize(sharpOpts) : sharp(inputPath).rotate()
|
|
118
|
+
const resizedBuffer = await pipeline.toBuffer({ resolveWithObject: true })
|
|
119
|
+
const actualWidth = resizedBuffer.info.width
|
|
120
|
+
const actualHeight = resizedBuffer.info.height
|
|
121
|
+
|
|
122
|
+
// Build output path
|
|
123
|
+
const outDir = path.join(this.config.out, job.parsed.dir)
|
|
124
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
125
|
+
|
|
126
|
+
let baseName
|
|
127
|
+
if (conversionOnly) {
|
|
128
|
+
baseName = job.parsed.name
|
|
129
|
+
} else if (sizeDef.name) {
|
|
130
|
+
baseName = `${job.parsed.name}-${sizeDef.name}-${actualWidth}w`
|
|
131
|
+
} else {
|
|
132
|
+
baseName = `${job.parsed.name}-${actualWidth}w`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Resolve which formats to generate
|
|
136
|
+
const { formats: targetFormats, preEncoded } = await resolveTargetFormats(
|
|
137
|
+
job.outputExt, job.transparent, resizedBuffer.data,
|
|
138
|
+
this.config.format, this.config.quality
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// Encode + write each target format
|
|
142
|
+
for (const targetExt of targetFormats) {
|
|
143
|
+
let buffer
|
|
144
|
+
try {
|
|
145
|
+
// Reuse pre-encoded buffer from smart format comparison when available
|
|
146
|
+
buffer = preEncoded.get(targetExt) || await convertFormat(
|
|
147
|
+
sharp(resizedBuffer.data), targetExt, this.config.quality
|
|
148
|
+
)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log({ tag: 'error', text: `Format conversion failed (${targetExt}): ${err.message}`, link: relativePath })
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const filename = `${baseName}.${targetExt}`
|
|
155
|
+
const outputPath = path.join(outDir, filename)
|
|
156
|
+
const relPath = path.join(job.parsed.dir, filename)
|
|
157
|
+
|
|
158
|
+
fs.writeFileSync(outputPath, buffer)
|
|
159
|
+
outputs.push({ path: relPath, width: actualWidth, height: actualHeight })
|
|
160
|
+
this.stats.variants++
|
|
161
|
+
this.stats.bytes += buffer.length
|
|
162
|
+
|
|
163
|
+
log({
|
|
164
|
+
tag: 'image',
|
|
165
|
+
text: 'Compiled:',
|
|
166
|
+
link: relPath,
|
|
167
|
+
size: formatBytes(buffer.length),
|
|
168
|
+
time: formatTime(Date.now() - startTime)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Clean up stale outputs from previous runs of this source file
|
|
174
|
+
const newPaths = new Set(outputs.map(o => o.path))
|
|
175
|
+
for (const prev of previousOutputs) {
|
|
176
|
+
const prevPath = typeof prev === 'string' ? prev : prev.path
|
|
177
|
+
if (!newPaths.has(prevPath)) {
|
|
178
|
+
const fullPath = path.join(this.config.out, prevPath)
|
|
179
|
+
if (fs.existsSync(fullPath)) {
|
|
180
|
+
fs.unlinkSync(fullPath)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.cache.setEntry(relativePath, {
|
|
186
|
+
mtime: job.mtime,
|
|
187
|
+
size: job.fileSize,
|
|
188
|
+
width: job.sourceWidth,
|
|
189
|
+
height: job.sourceHeight,
|
|
190
|
+
exif: job.exif,
|
|
191
|
+
outputs
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
this.stats.processed++
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
watch() {
|
|
198
|
+
const watchPath = path.resolve(this.config.in)
|
|
199
|
+
|
|
200
|
+
log({ tag: 'image', text: 'Watching for changes in', link: this.config.in })
|
|
201
|
+
|
|
202
|
+
this._watchQueue = []
|
|
203
|
+
this._watchProcessing = false
|
|
204
|
+
|
|
205
|
+
this.watcher = chokidar.watch(watchPath, {
|
|
206
|
+
ignoreInitial: true,
|
|
207
|
+
ignored: this.config.exclude
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
this.watcher.on('add', (filePath) => {
|
|
211
|
+
this._enqueueWatch(filePath, false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
this.watcher.on('change', (filePath) => {
|
|
215
|
+
this._enqueueWatch(filePath, true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
this.watcher.on('unlink', (filePath) => {
|
|
219
|
+
const relativePath = path.relative(this.config.in, filePath)
|
|
220
|
+
const outputs = this.cache.getOutputs(relativePath)
|
|
221
|
+
|
|
222
|
+
for (const output of outputs) {
|
|
223
|
+
const outputPath = typeof output === 'string' ? output : output.path
|
|
224
|
+
const fullPath = path.join(this.config.out, outputPath)
|
|
225
|
+
if (fs.existsSync(fullPath)) {
|
|
226
|
+
fs.unlinkSync(fullPath)
|
|
227
|
+
log({ tag: 'image', text: 'Removed:', link: outputPath })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.cache.removeEntry(relativePath)
|
|
232
|
+
this.cache.save()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
return this.watcher
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
stopWatch() {
|
|
239
|
+
if (this.watcher) {
|
|
240
|
+
this.watcher.close()
|
|
241
|
+
this.watcher = null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getStats() {
|
|
246
|
+
return {
|
|
247
|
+
processed: this.stats.processed,
|
|
248
|
+
variants: this.stats.variants,
|
|
249
|
+
skipped: this.stats.skipped,
|
|
250
|
+
bytes: this.stats.bytes,
|
|
251
|
+
elapsed: Date.now() - this.stats.startTime
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async _processSvgPipeline(svgFiles, { force, dryRun }) {
|
|
256
|
+
for (const svgFile of svgFiles) {
|
|
257
|
+
const relativePath = path.relative(this.config.in, svgFile)
|
|
258
|
+
|
|
259
|
+
if (dryRun) {
|
|
260
|
+
log({ tag: 'image', text: 'Would minify:', link: relativePath })
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let stat
|
|
265
|
+
try {
|
|
266
|
+
stat = fs.statSync(svgFile)
|
|
267
|
+
} catch {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!force && this.cache.shouldSkip(relativePath, stat.mtimeMs, stat.size)) {
|
|
272
|
+
this.stats.skipped++
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result = await processSvg(svgFile, this.config.out, this.config.in)
|
|
277
|
+
if (result) {
|
|
278
|
+
this.cache.setEntry(relativePath, {
|
|
279
|
+
mtime: stat.mtimeMs,
|
|
280
|
+
size: stat.size,
|
|
281
|
+
outputs: [{ path: result.relativePath, width: result.width, height: result.height }]
|
|
282
|
+
})
|
|
283
|
+
this.stats.processed++
|
|
284
|
+
this.stats.variants++
|
|
285
|
+
this.stats.bytes += result.outputSize
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async _processGifPipeline(gifFiles, { force, dryRun }) {
|
|
291
|
+
for (const gifFile of gifFiles) {
|
|
292
|
+
const relativePath = path.relative(this.config.in, gifFile)
|
|
293
|
+
|
|
294
|
+
if (dryRun) {
|
|
295
|
+
log({ tag: 'image', text: 'Would process:', link: relativePath })
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let stat
|
|
300
|
+
try {
|
|
301
|
+
stat = fs.statSync(gifFile)
|
|
302
|
+
} catch {
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!force && this.cache.shouldSkip(relativePath, stat.mtimeMs, stat.size)) {
|
|
307
|
+
this.stats.skipped++
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Detect animation: pages > 1 means animated GIF
|
|
312
|
+
let meta
|
|
313
|
+
try {
|
|
314
|
+
meta = await sharp(gifFile).metadata()
|
|
315
|
+
} catch (err) {
|
|
316
|
+
log({ tag: 'error', text: `Failed to read GIF metadata: ${err.message}`, link: relativePath })
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if ((meta.pages || 1) > 1) {
|
|
321
|
+
// Animated GIF — copy as-is
|
|
322
|
+
const outPath = path.join(this.config.out, relativePath)
|
|
323
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
324
|
+
fs.copyFileSync(gifFile, outPath)
|
|
325
|
+
|
|
326
|
+
this.cache.setEntry(relativePath, {
|
|
327
|
+
mtime: stat.mtimeMs,
|
|
328
|
+
size: stat.size,
|
|
329
|
+
outputs: [{ path: relativePath, width: meta.width, height: meta.height }]
|
|
330
|
+
})
|
|
331
|
+
this.stats.processed++
|
|
332
|
+
this.stats.variants++
|
|
333
|
+
this.stats.bytes += stat.size
|
|
334
|
+
|
|
335
|
+
log({ tag: 'image', text: 'Copied:', link: relativePath, size: formatBytes(stat.size) })
|
|
336
|
+
} else {
|
|
337
|
+
// Static GIF — route through raster pipeline
|
|
338
|
+
await this.processImage(gifFile, force)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async _worker(queue, force) {
|
|
344
|
+
while (queue.length > 0) {
|
|
345
|
+
const file = queue.shift()
|
|
346
|
+
await this.processImage(file, force)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_enqueueWatch(filePath, force) {
|
|
351
|
+
this._watchQueue.push({ filePath, force })
|
|
352
|
+
this._drainWatchQueue()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async _drainWatchQueue() {
|
|
356
|
+
if (this._watchProcessing) return
|
|
357
|
+
this._watchProcessing = true
|
|
358
|
+
|
|
359
|
+
while (this._watchQueue.length > 0) {
|
|
360
|
+
const { filePath, force } = this._watchQueue.shift()
|
|
361
|
+
try {
|
|
362
|
+
if (this._isSvg(filePath)) {
|
|
363
|
+
await this._watchProcessSvg(filePath)
|
|
364
|
+
} else if (this._isGif(filePath)) {
|
|
365
|
+
await this._watchProcessGif(filePath, force)
|
|
366
|
+
} else if (this._matchesInclude(filePath)) {
|
|
367
|
+
await this.processImage(filePath, force)
|
|
368
|
+
this.cache.save()
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
log({ tag: 'error', text: `Watch processing failed: ${err.message}`, link: filePath })
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this._watchProcessing = false
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_matchesInclude(filePath) {
|
|
379
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '')
|
|
380
|
+
// Input formats for raster pipeline — these get normalized to web formats during processing
|
|
381
|
+
const supportedExts = ['jpg', 'jpeg', 'png', 'tiff', 'tif', 'webp', 'heic', 'heif']
|
|
382
|
+
return supportedExts.includes(ext)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_isSvg(filePath) {
|
|
386
|
+
return path.extname(filePath).toLowerCase() === '.svg'
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
_isGif(filePath) {
|
|
390
|
+
return path.extname(filePath).toLowerCase() === '.gif'
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async _watchProcessGif(filePath, force) {
|
|
394
|
+
const relativePath = path.relative(this.config.in, filePath)
|
|
395
|
+
|
|
396
|
+
let meta
|
|
397
|
+
try {
|
|
398
|
+
meta = await sharp(filePath).metadata()
|
|
399
|
+
} catch (err) {
|
|
400
|
+
log({ tag: 'error', text: `Failed to read GIF metadata: ${err.message}`, link: relativePath })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if ((meta.pages || 1) > 1) {
|
|
405
|
+
await this._watchCopyGif(filePath)
|
|
406
|
+
} else {
|
|
407
|
+
await this.processImage(filePath, force)
|
|
408
|
+
this.cache.save()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async _watchCopyGif(filePath) {
|
|
413
|
+
const relativePath = path.relative(this.config.in, filePath)
|
|
414
|
+
let stat
|
|
415
|
+
try {
|
|
416
|
+
stat = fs.statSync(filePath)
|
|
417
|
+
} catch {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const outPath = path.join(this.config.out, relativePath)
|
|
422
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
423
|
+
fs.copyFileSync(filePath, outPath)
|
|
424
|
+
|
|
425
|
+
let gifWidth = null
|
|
426
|
+
let gifHeight = null
|
|
427
|
+
try {
|
|
428
|
+
const gifMeta = await sharp(filePath).metadata()
|
|
429
|
+
gifWidth = gifMeta.width
|
|
430
|
+
gifHeight = gifMeta.height
|
|
431
|
+
} catch (err) {
|
|
432
|
+
log({ tag: 'error', text: `Failed to read GIF metadata: ${err.message}`, link: relativePath })
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.cache.setEntry(relativePath, {
|
|
436
|
+
mtime: stat.mtimeMs,
|
|
437
|
+
size: stat.size,
|
|
438
|
+
outputs: [{ path: relativePath, width: gifWidth, height: gifHeight }]
|
|
439
|
+
})
|
|
440
|
+
this.cache.save()
|
|
441
|
+
log({ tag: 'image', text: 'Copied:', link: relativePath, size: formatBytes(stat.size) })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async _watchProcessSvg(filePath) {
|
|
445
|
+
const relativePath = path.relative(this.config.in, filePath)
|
|
446
|
+
let stat
|
|
447
|
+
try {
|
|
448
|
+
stat = fs.statSync(filePath)
|
|
449
|
+
} catch {
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const result = await processSvg(filePath, this.config.out, this.config.in)
|
|
454
|
+
if (result) {
|
|
455
|
+
this.cache.setEntry(relativePath, {
|
|
456
|
+
mtime: stat.mtimeMs,
|
|
457
|
+
size: stat.size,
|
|
458
|
+
outputs: [{ path: result.relativePath, width: result.width, height: result.height }]
|
|
459
|
+
})
|
|
460
|
+
this.cache.save()
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
package/lib/sizes.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const VALID_X_POSITIONS = ['left', 'center', 'right']
|
|
2
|
+
const VALID_Y_POSITIONS = ['top', 'center', 'bottom']
|
|
3
|
+
|
|
4
|
+
export function validateSize(size) {
|
|
5
|
+
if (size.name !== undefined && typeof size.name !== 'string') {
|
|
6
|
+
throw new Error('Size "name" must be a string if provided')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const name = size.name || ''
|
|
10
|
+
const width = size.width || 0
|
|
11
|
+
const height = size.height || 0
|
|
12
|
+
|
|
13
|
+
const label = name || `${width}x${height}`
|
|
14
|
+
|
|
15
|
+
// width=0 && height=0 is valid: conversion-only mode (no resize)
|
|
16
|
+
|
|
17
|
+
if (typeof width !== 'number' || width < 0) {
|
|
18
|
+
throw new Error(`Size "${label}": width must be a non-negative number`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof height !== 'number' || height < 0) {
|
|
22
|
+
throw new Error(`Size "${label}": height must be a non-negative number`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (size.crop !== undefined && size.crop !== false && size.crop !== true && !Array.isArray(size.crop)) {
|
|
26
|
+
throw new Error(`Size "${label}": crop must be boolean or [x, y] array`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (Array.isArray(size.crop)) {
|
|
30
|
+
if (size.crop.length !== 2) {
|
|
31
|
+
throw new Error(`Size "${label}": crop array must have exactly 2 elements [x, y]`)
|
|
32
|
+
}
|
|
33
|
+
if (!VALID_X_POSITIONS.includes(size.crop[0])) {
|
|
34
|
+
throw new Error(`Size "${label}": crop x must be one of: ${VALID_X_POSITIONS.join(', ')}`)
|
|
35
|
+
}
|
|
36
|
+
if (!VALID_Y_POSITIONS.includes(size.crop[1])) {
|
|
37
|
+
throw new Error(`Size "${label}": crop y must be one of: ${VALID_Y_POSITIONS.join(', ')}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
crop: size.crop || false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// sharp accepts: 'top', 'right top', 'right', 'right bottom', 'bottom',
|
|
50
|
+
// 'left bottom', 'left', 'left top', 'centre'/'center'
|
|
51
|
+
// When center is one axis, drop it: ['center','top'] → 'top', ['left','center'] → 'left'
|
|
52
|
+
// When both are center: ['center','center'] → 'centre'
|
|
53
|
+
function cropArrayToPosition([x, y]) {
|
|
54
|
+
if (x === 'center' && y === 'center') return 'centre'
|
|
55
|
+
if (x === 'center') return y
|
|
56
|
+
if (y === 'center') return x
|
|
57
|
+
return `${x} ${y}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function filterByUpscale(sizes, sourceWidth, sourceHeight) {
|
|
61
|
+
return sizes.filter(sizeDef => {
|
|
62
|
+
if (sizeDef.width === 0 && sizeDef.height === 0) return true
|
|
63
|
+
|
|
64
|
+
const tooNarrow = sizeDef.width > 0 && sourceWidth < sizeDef.width
|
|
65
|
+
const tooShort = sizeDef.height > 0 && sourceHeight < sizeDef.height
|
|
66
|
+
|
|
67
|
+
if (sizeDef.crop) return !(tooNarrow || tooShort)
|
|
68
|
+
|
|
69
|
+
if (sizeDef.width > 0 && sizeDef.height === 0 && tooNarrow) return false
|
|
70
|
+
if (sizeDef.height > 0 && sizeDef.width === 0 && tooShort) return false
|
|
71
|
+
if (sizeDef.width > 0 && sizeDef.height > 0 && tooNarrow && tooShort) return false
|
|
72
|
+
return true
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function toSharpOptions(size) {
|
|
77
|
+
// Conversion-only: no resize needed
|
|
78
|
+
if (size.width === 0 && size.height === 0) return null
|
|
79
|
+
|
|
80
|
+
const opts = {}
|
|
81
|
+
|
|
82
|
+
if (size.width > 0) opts.width = size.width
|
|
83
|
+
if (size.height > 0) opts.height = size.height
|
|
84
|
+
|
|
85
|
+
if (size.crop === false) {
|
|
86
|
+
opts.fit = 'inside'
|
|
87
|
+
opts.withoutEnlargement = true
|
|
88
|
+
} else if (size.crop === true) {
|
|
89
|
+
opts.fit = 'cover'
|
|
90
|
+
opts.position = 'centre'
|
|
91
|
+
} else if (Array.isArray(size.crop)) {
|
|
92
|
+
opts.fit = 'cover'
|
|
93
|
+
opts.position = cropArrayToPosition(size.crop)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return opts
|
|
97
|
+
}
|
package/lib/svg.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { optimize } from 'svgo'
|
|
4
|
+
import log from './utils/log.js'
|
|
5
|
+
import { formatBytes } from './utils/format.js'
|
|
6
|
+
|
|
7
|
+
export function parseSvgDimensions(svgContent) {
|
|
8
|
+
// Match the opening <svg> tag (may span multiple lines)
|
|
9
|
+
const svgTagMatch = svgContent.match(/<svg\s[^>]*>/is)
|
|
10
|
+
if (!svgTagMatch) return { width: null, height: null }
|
|
11
|
+
|
|
12
|
+
const tag = svgTagMatch[0]
|
|
13
|
+
|
|
14
|
+
// Try explicit width/height attributes (unitless or px only)
|
|
15
|
+
const wMatch = tag.match(/\bwidth=["'](\d+(?:\.\d+)?)(px)?["']/i)
|
|
16
|
+
const hMatch = tag.match(/\bheight=["'](\d+(?:\.\d+)?)(px)?["']/i)
|
|
17
|
+
|
|
18
|
+
if (wMatch && hMatch) {
|
|
19
|
+
return {
|
|
20
|
+
width: Math.round(parseFloat(wMatch[1])),
|
|
21
|
+
height: Math.round(parseFloat(hMatch[1]))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fall back to viewBox
|
|
26
|
+
const vbMatch = tag.match(/\bviewBox=["']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["']/i)
|
|
27
|
+
if (vbMatch) {
|
|
28
|
+
return {
|
|
29
|
+
width: Math.round(parseFloat(vbMatch[3])),
|
|
30
|
+
height: Math.round(parseFloat(vbMatch[4]))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { width: null, height: null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function processSvg(inputPath, outputDir, inputDir) {
|
|
38
|
+
const relativePath = path.relative(inputDir, inputPath)
|
|
39
|
+
const outPath = path.join(outputDir, relativePath)
|
|
40
|
+
const outDir = path.dirname(outPath)
|
|
41
|
+
|
|
42
|
+
let source
|
|
43
|
+
try {
|
|
44
|
+
source = fs.readFileSync(inputPath, 'utf-8')
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log({ tag: 'error', text: `Cannot read SVG: ${err.message}`, link: relativePath })
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const originalSize = Buffer.byteLength(source, 'utf-8')
|
|
51
|
+
|
|
52
|
+
let result
|
|
53
|
+
try {
|
|
54
|
+
result = optimize(source, {
|
|
55
|
+
path: inputPath,
|
|
56
|
+
multipass: true
|
|
57
|
+
})
|
|
58
|
+
} catch (err) {
|
|
59
|
+
log({ tag: 'error', text: `SVGO failed: ${err.message}`, link: relativePath })
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const optimizedSize = Buffer.byteLength(result.data, 'utf-8')
|
|
64
|
+
const saved = originalSize - optimizedSize
|
|
65
|
+
const pct = originalSize > 0 ? Math.round((saved / originalSize) * 100) : 0
|
|
66
|
+
|
|
67
|
+
// Parse dimensions from the optimized SVG
|
|
68
|
+
const { width, height } = parseSvgDimensions(result.data)
|
|
69
|
+
|
|
70
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
71
|
+
fs.writeFileSync(outPath, result.data, 'utf-8')
|
|
72
|
+
|
|
73
|
+
log({
|
|
74
|
+
tag: 'image',
|
|
75
|
+
text: 'Minified:',
|
|
76
|
+
link: relativePath,
|
|
77
|
+
size: `${formatBytes(optimizedSize)} (${pct > 0 ? '-' + pct + '%' : 'same'})`
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return { relativePath, outputSize: optimizedSize, saved, width, height }
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert degrees/minutes/seconds to decimal degrees.
|
|
3
|
+
* @param {[number, number, number]} dms - [degrees, minutes, seconds]
|
|
4
|
+
* @param {string} ref - Cardinal direction: "N", "S", "E", or "W"
|
|
5
|
+
* @returns {number} Decimal degrees (negative for S/W)
|
|
6
|
+
* @example dmsToDecimal([35, 42, 55.01], 'N') // => 35.715281
|
|
7
|
+
* @example dmsToDecimal([139, 46, 16.85], 'E') // => 139.771347
|
|
8
|
+
* @example dmsToDecimal([33, 51, 54.55], 'S') // => -33.865153
|
|
9
|
+
*/
|
|
10
|
+
export function dmsToDecimal(dms, ref) {
|
|
11
|
+
const [degrees, minutes, seconds] = dms
|
|
12
|
+
let decimal = degrees + minutes / 60 + seconds / 3600
|
|
13
|
+
if (ref === 'S' || ref === 'W') decimal = -decimal
|
|
14
|
+
return Math.round(decimal * 1000000) / 1000000
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format degrees/minutes/seconds as a human-readable string.
|
|
19
|
+
* @param {[number, number, number]} dms - [degrees, minutes, seconds]
|
|
20
|
+
* @param {string} ref - Cardinal direction: "N", "S", "E", or "W"
|
|
21
|
+
* @returns {string} Formatted string like `35° 42' 55.01" N`
|
|
22
|
+
* @example formatDms([35, 42, 55.01], 'N') // => '35° 42\' 55.01" N'
|
|
23
|
+
* @example formatDms([139, 46, 16.85], 'E') // => '139° 46\' 16.85" E'
|
|
24
|
+
*/
|
|
25
|
+
export function formatDms(dms, ref) {
|
|
26
|
+
return `${dms[0]}° ${dms[1]}' ${dms[2]}" ${ref}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatBytes(bytes) {
|
|
30
|
+
if (bytes < 1024) return `${bytes}B`
|
|
31
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
32
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
33
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatTime(ms) {
|
|
37
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
38
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
|
39
|
+
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
|
|
40
|
+
const h = Math.floor(ms / 3600000)
|
|
41
|
+
const m = Math.round((ms % 3600000) / 60000)
|
|
42
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
|
43
|
+
}
|