gotodev-image-optimizer 0.1.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.
@@ -0,0 +1,189 @@
1
+ import sharp from 'sharp'
2
+ import { computeCenterWeight, computeEdgeDensity, computeEntropy } from '../utils/entropy.ts'
3
+ import type { TileAnalysis, TileQuality } from './types.ts'
4
+
5
+ export interface AnalysisResult {
6
+ tiles: TileAnalysis[]
7
+ tileQualities: TileQuality[]
8
+ overallImportance: number
9
+ tileSize: number
10
+ width: number
11
+ height: number
12
+ }
13
+
14
+ function computeSkinRatio(pixels: Uint8Array, width: number, height: number): number {
15
+ let skin = 0
16
+ const total = width * height
17
+
18
+ for (let i = 0; i < total; i++) {
19
+ const r = pixels[i * 3] ?? 0
20
+ const g = pixels[i * 3 + 1] ?? 0
21
+ const b = pixels[i * 3 + 2] ?? 0
22
+
23
+ const cr = (r - 128) * 0.713 + (g - 128) * -0.287 + (b - 128) * -0.426 + 128
24
+ const cb = (r - 128) * -0.169 + (g - 128) * -0.331 + (b - 128) * 0.5 + 128
25
+
26
+ if (cr >= 133 && cr <= 173 && cb >= 77 && cb <= 127) {
27
+ skin++
28
+ }
29
+ }
30
+
31
+ return total > 0 ? skin / total : 0
32
+ }
33
+
34
+ export async function analyzeImage(
35
+ imagePath: string,
36
+ tileSize = 64,
37
+ detectFaces = false,
38
+ ): Promise<AnalysisResult> {
39
+ const metadata = await sharp(imagePath).metadata()
40
+ const width = metadata.width ?? 0
41
+ const height = metadata.height ?? 0
42
+
43
+ if (width === 0 || height === 0) {
44
+ throw new Error('Could not read image dimensions')
45
+ }
46
+
47
+ const tilesX = Math.ceil(width / tileSize)
48
+ const tilesY = Math.ceil(height / tileSize)
49
+ const tiles: TileAnalysis[] = []
50
+
51
+ let totalImportance = 0
52
+
53
+ for (let ty = 0; ty < tilesY; ty++) {
54
+ for (let tx = 0; tx < tilesX; tx++) {
55
+ const left = tx * tileSize
56
+ const top = ty * tileSize
57
+ const tileW = Math.min(tileSize, width - left)
58
+ const tileH = Math.min(tileSize, height - top)
59
+
60
+ const buffer = await sharp(imagePath)
61
+ .extract({ left, top, width: tileW, height: tileH })
62
+ .raw()
63
+ .toBuffer()
64
+
65
+ const raw = new Uint8Array(buffer)
66
+ const pixelCount = tileW * tileH
67
+ const gray = new Uint8Array(pixelCount)
68
+
69
+ for (let i = 0; i < pixelCount; i++) {
70
+ const r = raw[i * 3] ?? 0
71
+ const g = raw[i * 3 + 1] ?? 0
72
+ const b = raw[i * 3 + 2] ?? 0
73
+ gray[i] = Math.round(r * 0.299 + g * 0.587 + b * 0.114)
74
+ }
75
+
76
+ const entropy = computeEntropy(gray)
77
+ const edgeDensity = computeEdgeDensity(gray, tileW, tileH)
78
+ const centerWeight = computeCenterWeight(tx, ty, tilesX, tilesY)
79
+
80
+ const normalizedEntropy = entropy / 8.0
81
+ let importance = Math.min(1, normalizedEntropy * 0.4 + edgeDensity * 0.6)
82
+
83
+ if (detectFaces && importance < 0.9) {
84
+ const skinRatio = computeSkinRatio(raw, tileW, tileH)
85
+ if (skinRatio > 0.05) {
86
+ importance = Math.min(1, importance + 0.3)
87
+ }
88
+ }
89
+
90
+ tiles.push({
91
+ x: tx,
92
+ y: ty,
93
+ entropy,
94
+ edgeDensity,
95
+ importance,
96
+ centerWeight,
97
+ })
98
+
99
+ totalImportance += importance
100
+ }
101
+ }
102
+
103
+ const tileQualities = computeTileQualities(tiles)
104
+
105
+ return {
106
+ tiles,
107
+ tileQualities,
108
+ overallImportance: tiles.length > 0 ? totalImportance / tiles.length : 0,
109
+ tileSize,
110
+ width,
111
+ height,
112
+ }
113
+ }
114
+
115
+ function computeTileQualities(tiles: TileAnalysis[]): TileQuality[] {
116
+ const tileQualities: TileQuality[] = []
117
+
118
+ for (const tile of tiles) {
119
+ let importance = tile.importance
120
+
121
+ // Edge-density bonus: protect text and fine details
122
+ if (tile.edgeDensity > 0.3) {
123
+ const bonus = Math.min(0.2, (tile.edgeDensity - 0.3) * 0.5)
124
+ importance = Math.min(1, importance + bonus)
125
+ }
126
+
127
+ // Center-weight bonus: photo subjects tend to be centered
128
+ if (tile.centerWeight > 0.7) {
129
+ importance = Math.min(1, importance + (tile.centerWeight - 0.7) * 0.3)
130
+ }
131
+
132
+ tileQualities.push({
133
+ x: tile.x,
134
+ y: tile.y,
135
+ saliency: importance,
136
+ quality: 0,
137
+ weight: importance ** 2 + 0.1,
138
+ })
139
+ }
140
+
141
+ return tileQualities
142
+ }
143
+
144
+ export function computeTargetQuality(
145
+ analysis: AnalysisResult,
146
+ baseQuality = 80,
147
+ options?: {
148
+ minQuality?: number
149
+ maxQuality?: number
150
+ },
151
+ ): number {
152
+ if (analysis.tiles.length === 0) return baseQuality
153
+
154
+ const minQuality = options?.minQuality ?? Math.max(20, baseQuality - 30)
155
+ const maxQuality = options?.maxQuality ?? Math.min(95, baseQuality + 10)
156
+
157
+ const tileQualities = analysis.tileQualities
158
+
159
+ // Compute per-tile quality and weight
160
+ const weighted = tileQualities.map((tq) => ({
161
+ quality: minQuality + tq.saliency * (maxQuality - minQuality),
162
+ weight: tq.weight,
163
+ }))
164
+
165
+ // Sort by quality descending
166
+ weighted.sort((a, b) => b.quality - a.quality)
167
+
168
+ // Top 30% tiles get 70% weight — prioritizes important content
169
+ const splitIndex = Math.max(1, Math.ceil(weighted.length * 0.3))
170
+ const highPriority = weighted.slice(0, splitIndex)
171
+ const lowPriority = weighted.slice(splitIndex)
172
+
173
+ const highW = highPriority.reduce((s, t) => s + t.weight, 0)
174
+ const lowW = lowPriority.reduce((s, t) => s + t.weight, 0)
175
+
176
+ const highAvg = highPriority.reduce((s, t) => s + t.quality * t.weight, 0) / (highW || 1)
177
+ const lowAvg = lowPriority.reduce((s, t) => s + t.quality * t.weight, 0) / (lowW || 1)
178
+
179
+ const weightedQuality = highAvg * 0.7 + lowAvg * 0.3
180
+
181
+ // Variance boost: high variance = mixed content → protect detail
182
+ const qualities = weighted.map((t) => t.quality)
183
+ const mean = qualities.reduce((s, q) => s + q, 0) / qualities.length
184
+ const variance = qualities.reduce((s, q) => s + (q - mean) ** 2, 0) / qualities.length
185
+ const stdDev = Math.sqrt(variance)
186
+ const varianceBoost = stdDev > 12 ? Math.min(5, stdDev * 0.12) : 0
187
+
188
+ return Math.round(Math.min(maxQuality, Math.max(minQuality, weightedQuality + varianceBoost)))
189
+ }
@@ -0,0 +1,244 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import sharp from 'sharp'
3
+ import { contentHash, sriHash } from '../utils/hash.ts'
4
+ import type { AnalysisResult } from './analyzer.ts'
5
+ import { analyzeImage, computeTargetQuality } from './analyzer.ts'
6
+ import { isAnimatedFormat } from './formats.ts'
7
+ import { preprocessImage } from './preprocessor.ts'
8
+ import { sanitizeSvg } from './sanitizer.ts'
9
+ import { autoTuneQuality } from './tuner.ts'
10
+ import type { ImageVariant, ManifestEntry, OutputFormat, QualityTier, TierConfig } from './types.ts'
11
+ import {
12
+ validateFileSize,
13
+ validateImageContent,
14
+ validateOutputFormat,
15
+ validatePath,
16
+ } from './validator.ts'
17
+
18
+ export interface EncodeOptions {
19
+ widths: number[]
20
+ formats: OutputFormat[]
21
+ tiers: Record<QualityTier, TierConfig>
22
+ autoTune: boolean
23
+ adaptive: boolean
24
+ preprocess: boolean
25
+ faceDetection: boolean
26
+ outDir: string
27
+ }
28
+
29
+ export interface EncodeResult {
30
+ entries: Record<string, ManifestEntry>
31
+ }
32
+
33
+ function readFileSafe(filePath: string): Buffer {
34
+ validatePath(filePath)
35
+ const buffer = readFileSync(filePath)
36
+ validateFileSize(buffer.length)
37
+ return buffer
38
+ }
39
+
40
+ async function encodeVariant(
41
+ source: string | Buffer,
42
+ width: number,
43
+ format: OutputFormat,
44
+ quality: number,
45
+ outDir: string,
46
+ ): Promise<ImageVariant | null> {
47
+ try {
48
+ validateOutputFormat(format)
49
+
50
+ const img = typeof source === 'string' ? sharp(source) : sharp(source)
51
+ const metadata = await img.metadata()
52
+
53
+ if ((metadata.width ?? 0) <= width) {
54
+ const buffer = await img[format]({ quality }).toBuffer()
55
+ const hash = contentHash(buffer)
56
+ const ext = format === 'jpeg' ? 'jpg' : format
57
+ const filename = `${hash}-${width}.${ext}`
58
+ const outPath = `${outDir}/${filename}`
59
+
60
+ await sharp(buffer).toFile(outPath)
61
+
62
+ return {
63
+ src: filename,
64
+ width,
65
+ format,
66
+ size: buffer.length,
67
+ integrity: sriHash(buffer),
68
+ }
69
+ }
70
+
71
+ const resized =
72
+ typeof source === 'string'
73
+ ? await sharp(source).resize(width).toBuffer()
74
+ : await sharp(source).resize(width).toBuffer()
75
+ const encoded = await sharp(resized)[format]({ quality }).toBuffer()
76
+ const hash = contentHash(encoded)
77
+ const ext = format === 'jpeg' ? 'jpg' : format
78
+ const filename = `${hash}-${width}.${ext}`
79
+ const outPath = `${outDir}/${filename}`
80
+
81
+ await sharp(encoded).toFile(outPath)
82
+
83
+ return {
84
+ src: filename,
85
+ width,
86
+ format,
87
+ size: encoded.length,
88
+ integrity: sriHash(encoded),
89
+ }
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ export async function encodeImage(
96
+ imagePath: string,
97
+ options: EncodeOptions,
98
+ ): Promise<ManifestEntry> {
99
+ const buffer = readFileSafe(imagePath)
100
+ const ext = imagePath.split('.').pop() ?? 'jpg'
101
+ const format = validateImageContent(buffer, ext)
102
+
103
+ if (format === 'svg') {
104
+ const content = buffer.toString('utf-8')
105
+ const sanitized = sanitizeSvg(content)
106
+ const hash = contentHash(Buffer.from(sanitized))
107
+ const filename = `${hash}.svg`
108
+ const svgBuffer = Buffer.from(sanitized)
109
+
110
+ return {
111
+ src: filename,
112
+ width: 0,
113
+ height: 0,
114
+ format: 'svg',
115
+ placeholder: '',
116
+ tiers: {} as Record<QualityTier, string>,
117
+ variants: [
118
+ {
119
+ src: filename,
120
+ width: 0,
121
+ format: 'webp',
122
+ size: svgBuffer.length,
123
+ integrity: sriHash(svgBuffer),
124
+ },
125
+ ],
126
+ }
127
+ }
128
+
129
+ if (isAnimatedFormat(format)) {
130
+ const img = sharp(buffer, { animated: true })
131
+ const metadata = await img.metadata()
132
+ const width = metadata.width ?? 0
133
+ const height = metadata.height ?? 0
134
+
135
+ const webpBuffer = await img.webp({ quality: 75 }).toBuffer()
136
+ const hash = contentHash(webpBuffer)
137
+ const filename = `${hash}.webp`
138
+
139
+ return {
140
+ src: filename,
141
+ width,
142
+ height,
143
+ format: 'gif',
144
+ placeholder: '',
145
+ tiers: {} as Record<QualityTier, string>,
146
+ variants: [
147
+ {
148
+ src: filename,
149
+ width,
150
+ format: 'webp',
151
+ size: webpBuffer.length,
152
+ integrity: sriHash(webpBuffer),
153
+ },
154
+ ],
155
+ }
156
+ }
157
+
158
+ const metadata = await sharp(buffer).metadata()
159
+ const originalWidth = metadata.width ?? 0
160
+ const originalHeight = metadata.height ?? 0
161
+
162
+ let placeholder = ''
163
+ if (originalWidth > 0 && originalHeight > 0) {
164
+ const placeholderBuffer = await sharp(buffer)
165
+ .resize(32, 32, { fit: 'inside' })
166
+ .webp({ quality: 20 })
167
+ .toBuffer()
168
+ placeholder = `data:image/webp;base64,${placeholderBuffer.toString('base64')}`
169
+ }
170
+
171
+ const tiers: Partial<Record<QualityTier, string>> = {}
172
+ const variants: ImageVariant[] = []
173
+
174
+ let analysis: AnalysisResult | null = null
175
+ let preprocessedSource: Buffer | null = null
176
+
177
+ if (options.adaptive || options.preprocess) {
178
+ try {
179
+ analysis = await analyzeImage(imagePath, 64, options.faceDetection)
180
+ } catch {}
181
+ }
182
+
183
+ if (options.preprocess && analysis) {
184
+ try {
185
+ preprocessedSource = await preprocessImage(imagePath, analysis)
186
+ } catch {}
187
+ }
188
+
189
+ const source = preprocessedSource ?? imagePath
190
+
191
+ for (const [tierKey, tierConfig] of Object.entries(options.tiers)) {
192
+ const tier = tierKey as QualityTier
193
+ let quality = tierConfig.quality
194
+
195
+ if (options.adaptive && analysis) {
196
+ try {
197
+ quality = computeTargetQuality(analysis, quality, {
198
+ minQuality: Math.max(20, quality - 30),
199
+ maxQuality: Math.min(95, quality + 10),
200
+ })
201
+ } catch {}
202
+ }
203
+
204
+ if (options.autoTune) {
205
+ try {
206
+ const tuned = await autoTuneQuality(source, 'webp', {
207
+ threshold: 0.97,
208
+ minQuality: Math.max(40, quality - 10),
209
+ maxQuality: Math.min(95, quality + 5),
210
+ })
211
+ quality = tuned.quality
212
+ } catch {}
213
+ }
214
+
215
+ const widths = tierConfig.widths.filter((w) => w <= originalWidth)
216
+ if (widths.length === 0) {
217
+ widths.push(originalWidth)
218
+ }
219
+
220
+ for (const w of widths) {
221
+ for (const fmt of options.formats) {
222
+ const variant = await encodeVariant(source, w, fmt, quality, options.outDir)
223
+ if (variant) {
224
+ variants.push(variant)
225
+ }
226
+ }
227
+ }
228
+
229
+ const bestVariant = variants.find((v) => v.format === 'webp')
230
+ if (bestVariant) {
231
+ tiers[tier] = bestVariant.src
232
+ }
233
+ }
234
+
235
+ return {
236
+ src: imagePath.split('/').pop() ?? 'image',
237
+ width: originalWidth,
238
+ height: originalHeight,
239
+ format,
240
+ placeholder,
241
+ tiers: tiers as Record<QualityTier, string>,
242
+ variants,
243
+ }
244
+ }
@@ -0,0 +1,97 @@
1
+ import type { ImageFormat, OutputFormat } from './types.ts'
2
+
3
+ const MAGIC_BYTES: Record<string, Uint8Array> = {
4
+ jpeg: new Uint8Array([0xff, 0xd8, 0xff]),
5
+ png: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
6
+ webp: new Uint8Array([0x52, 0x49, 0x46, 0x46]),
7
+ gif: new Uint8Array([0x47, 0x49, 0x46, 0x38]),
8
+ bmp: new Uint8Array([0x42, 0x4d]),
9
+ tiff: new Uint8Array([0x49, 0x49, 0x2a, 0x00]),
10
+ ico: new Uint8Array([0x00, 0x00, 0x01, 0x00]),
11
+ svg: new Uint8Array([0x3c]), // '<'
12
+ }
13
+
14
+ const EXTENSION_MAP: Record<string, ImageFormat> = {
15
+ jpg: 'jpeg',
16
+ jpeg: 'jpeg',
17
+ png: 'png',
18
+ webp: 'webp',
19
+ avif: 'avif',
20
+ gif: 'gif',
21
+ svg: 'svg',
22
+ bmp: 'bmp',
23
+ tiff: 'tiff',
24
+ tif: 'tiff',
25
+ ico: 'ico',
26
+ }
27
+
28
+ export function detectFormat(buffer: Uint8Array, extension: string): ImageFormat {
29
+ const extFormat = EXTENSION_MAP[extension.toLowerCase()]
30
+ if (extFormat === 'svg') return 'svg'
31
+
32
+ for (const [format, magic] of Object.entries(MAGIC_BYTES)) {
33
+ if (buffer.length >= magic.length) {
34
+ let match = true
35
+ for (let i = 0; i < magic.length; i++) {
36
+ if (buffer[i] !== magic[i]) {
37
+ match = false
38
+ break
39
+ }
40
+ }
41
+ if (match) {
42
+ if (format === 'webp') {
43
+ const riffType = new TextDecoder().decode(buffer.slice(8, 12))
44
+ if (riffType === 'WEBP') return 'webp'
45
+ continue
46
+ }
47
+ return format as ImageFormat
48
+ }
49
+ }
50
+ }
51
+
52
+ return extFormat ?? 'jpeg'
53
+ }
54
+
55
+ export function getExtension(format: ImageFormat): string {
56
+ const map: Record<ImageFormat, string> = {
57
+ jpeg: 'jpg',
58
+ png: 'png',
59
+ webp: 'webp',
60
+ avif: 'avif',
61
+ gif: 'gif',
62
+ svg: 'svg',
63
+ bmp: 'bmp',
64
+ tiff: 'tiff',
65
+ ico: 'ico',
66
+ }
67
+ return map[format]
68
+ }
69
+
70
+ export function getMimeType(format: ImageFormat | OutputFormat): string {
71
+ const map: Record<string, string> = {
72
+ jpeg: 'image/jpeg',
73
+ png: 'image/png',
74
+ webp: 'image/webp',
75
+ avif: 'image/avif',
76
+ gif: 'image/gif',
77
+ svg: 'image/svg+xml',
78
+ bmp: 'image/bmp',
79
+ tiff: 'image/tiff',
80
+ ico: 'image/x-icon',
81
+ }
82
+ return map[format] ?? 'application/octet-stream'
83
+ }
84
+
85
+ export function isRasterFormat(format: ImageFormat): boolean {
86
+ return format !== 'svg'
87
+ }
88
+
89
+ export function isAnimatedFormat(format: ImageFormat): boolean {
90
+ return format === 'gif'
91
+ }
92
+
93
+ export function outputFormatsFrom(format: ImageFormat): OutputFormat[] {
94
+ if (format === 'svg') return []
95
+ if (format === 'gif') return ['webp', 'jpeg']
96
+ return ['avif', 'webp', 'jpeg']
97
+ }
@@ -0,0 +1,38 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import type { BuildManifest, ManifestEntry } from './types.ts'
4
+
5
+ const MANIFEST_VERSION = '1.0.0'
6
+
7
+ export function createManifest(): BuildManifest {
8
+ return {
9
+ version: MANIFEST_VERSION,
10
+ generatedAt: new Date().toISOString(),
11
+ entries: {},
12
+ }
13
+ }
14
+
15
+ export function addToManifest(manifest: BuildManifest, key: string, entry: ManifestEntry): void {
16
+ manifest.entries[key] = entry
17
+ }
18
+
19
+ export function writeManifest(manifest: BuildManifest, outPath: string): void {
20
+ const dir = dirname(outPath)
21
+ if (!existsSync(dir)) {
22
+ mkdirSync(dir, { recursive: true })
23
+ }
24
+ writeFileSync(outPath, JSON.stringify(manifest, null, 2))
25
+ }
26
+
27
+ export function loadManifest(manifestPath: string): BuildManifest | null {
28
+ try {
29
+ const content = readFileSafe(manifestPath)
30
+ return JSON.parse(content) as BuildManifest
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function readFileSafe(path: string): string {
37
+ return readFileSync(path, 'utf-8')
38
+ }
@@ -0,0 +1,58 @@
1
+ import sharp from 'sharp'
2
+ import type { AnalysisResult } from './analyzer.ts'
3
+
4
+ const OVERLAP_PX = 8
5
+
6
+ export async function preprocessImage(
7
+ imagePath: string,
8
+ analysis: AnalysisResult,
9
+ ): Promise<Buffer> {
10
+ const { width, height, tileSize, tiles } = analysis
11
+
12
+ const sorted = [...tiles].sort((a, b) => b.importance - a.importance)
13
+ const topCount = Math.max(1, Math.ceil(sorted.length * 0.2))
14
+ const importantTiles = sorted.slice(0, topCount)
15
+
16
+ const composites: { input: Buffer; top: number; left: number }[] = []
17
+
18
+ for (const tile of importantTiles) {
19
+ const tileLeft = tile.x * tileSize
20
+ const tileTop = tile.y * tileSize
21
+ const tileW = Math.min(tileSize, width - tileLeft)
22
+ const tileH = Math.min(tileSize, height - tileTop)
23
+
24
+ const padL = tileLeft > 0 ? OVERLAP_PX : 0
25
+ const padT = tileTop > 0 ? OVERLAP_PX : 0
26
+ const padR = tileLeft + tileW < width ? OVERLAP_PX : 0
27
+ const padB = tileTop + tileH < height ? OVERLAP_PX : 0
28
+
29
+ const extLeft = tileLeft - padL
30
+ const extTop = tileTop - padT
31
+ const extWidth = tileW + padL + padR
32
+ const extHeight = tileH + padT + padB
33
+
34
+ const sigma = 1 + tile.importance * 1.2
35
+
36
+ const sharpened = await sharp(imagePath)
37
+ .extract({ left: extLeft, top: extTop, width: extWidth, height: extHeight })
38
+ .sharpen(sigma)
39
+ .png()
40
+ .toBuffer()
41
+
42
+ const trimmed = await sharp(sharpened)
43
+ .extract({ left: padL, top: padT, width: tileW, height: tileH })
44
+ .png()
45
+ .toBuffer()
46
+
47
+ composites.push({ input: trimmed, top: tileTop, left: tileLeft })
48
+ }
49
+
50
+ if (composites.length === 0) {
51
+ return sharp(imagePath).png().toBuffer()
52
+ }
53
+
54
+ return sharp(imagePath)
55
+ .composite(composites.map((c) => ({ input: c.input, top: c.top, left: c.left })))
56
+ .png()
57
+ .toBuffer()
58
+ }
@@ -0,0 +1,43 @@
1
+ export function sanitizeSvg(input: string): string {
2
+ let sanitized = input
3
+
4
+ sanitized = sanitized.replace(/<script[\s\S]*?<\/script>/gi, '')
5
+ sanitized = sanitized.replace(/<\s*script[^>]*\/?>/gi, '')
6
+
7
+ sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
8
+
9
+ sanitized = sanitized.replace(/javascript\s*:/gi, '')
10
+ sanitized = sanitized.replace(/data\s*:\s*text\s*\/\s*html/gi, '')
11
+ sanitized = sanitized.replace(/document\./gi, '')
12
+ sanitized = sanitized.replace(/window\./gi, '')
13
+ sanitized = sanitized.replace(/eval\s*\(/gi, '')
14
+ sanitized = sanitized.replace(/new\s+Function\s*\(/gi, '')
15
+ sanitized = sanitized.replace(/setTimeout\s*\(/gi, '')
16
+ sanitized = sanitized.replace(/setInterval\s*\(/gi, '')
17
+
18
+ return sanitized
19
+ }
20
+
21
+ export function validateSvgContent(input: string): boolean {
22
+ const dangerous = [
23
+ 'script',
24
+ 'onerror',
25
+ 'onload',
26
+ 'onclick',
27
+ 'onmouseover',
28
+ 'javascript:',
29
+ 'data:text/html',
30
+ 'document.cookie',
31
+ '<?',
32
+ '<%',
33
+ ]
34
+
35
+ const lower = input.toLowerCase()
36
+ for (const pattern of dangerous) {
37
+ if (lower.includes(pattern)) {
38
+ return false
39
+ }
40
+ }
41
+
42
+ return true
43
+ }