paradimg
Version:
Browser-side image URL modifiers and canvas effects.
786 lines (785 loc) • 15.1 kB
JavaScript
/*! paradimg v0.1.0 | (c) 2026 Rémino Rem <https://remino.net/> | ISC Licence */
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region src/lib/image-processor.js
var DEFAULT_SELECTOR = "img[src*=\"#?\"]";
var ORIGINAL_SRC_ATTR = "data-image-processor-original-src";
var PROCESSED_SRC_ATTR = "data-image-processor-processed-src";
var PROCESSED_CLASS = "image-processor--processed";
var parseHashFlags = (src) => {
if (!src) return [];
const hash = new URL(src, window.location.href).hash.slice(1);
if (!hash) return [];
return (hash.startsWith("?") ? hash.slice(1) : hash).split("&").map((token) => {
if (!token) return null;
const eqIndex = token.indexOf("=");
const hasValue = eqIndex !== -1;
const keyword = decodeURIComponent((hasValue ? token.slice(0, eqIndex) : token).trim()).trim();
if (!keyword) return null;
return {
keyword,
rawValue: hasValue ? decodeURIComponent(token.slice(eqIndex + 1).trim()) : null
};
}).filter(Boolean);
};
var stripHash = (src) => {
const url = new URL(src, window.location.href);
url.hash = "";
return url.href;
};
var getHash = (src) => {
return new URL(src, window.location.href).hash;
};
var withOriginalHash = (src, hash) => {
if (!hash) return stripHash(src);
const url = new URL(src, window.location.href);
url.hash = hash.slice(1);
return url.href;
};
var setImageDimensions = (img) => {
if (!img.getAttribute("width")) img.setAttribute("width", img.naturalWidth);
if (!img.getAttribute("height")) img.setAttribute("height", img.naturalHeight);
};
var ImageProcessor = class {
constructor(plugins = [], selector = DEFAULT_SELECTOR) {
this.selector = selector;
this.plugins = /* @__PURE__ */ new Map();
for (const plugin of plugins) this.register(plugin);
}
register(plugin) {
if (!plugin || !plugin.keyword) throw new Error("ImageProcessor plugins must define a keyword");
this.plugins.set(plugin.keyword, plugin);
}
getPlugin(keyword) {
return this.plugins.get(keyword);
}
async processImage(img) {
const source = img.dataset.imageProcessorOriginalSrc || img.src;
const originalHash = getHash(source);
const flags = parseHashFlags(source);
const effects = flags.map((flag) => {
const plugin = this.getPlugin(flag.keyword);
if (!plugin) return null;
const value = plugin.formatValue ? plugin.formatValue(flag.rawValue, {
flag,
originalSrc: source
}) : flag.rawValue;
return {
...flag,
plugin,
value
};
}).filter(Boolean);
if (!effects.length) return false;
img.setAttribute(ORIGINAL_SRC_ATTR, source);
let currentSrc = source;
for (const effect of effects) {
img.src = currentSrc;
await img.decode();
const nextSrc = await effect.plugin.process(img, {
currentSrc,
flag: effect,
flags,
originalSrc: source,
plugin: effect.plugin,
rawValue: effect.rawValue,
value: effect.value,
processor: this
});
if (!nextSrc) continue;
currentSrc = withOriginalHash(nextSrc, originalHash);
}
img.setAttribute(PROCESSED_SRC_ATTR, currentSrc);
img.src = currentSrc;
await img.decode();
setImageDimensions(img);
img.classList.add(PROCESSED_CLASS);
for (const effect of effects) {
const { plugin } = effect;
if (plugin.processedClass) img.classList.add(plugin.processedClass);
if (plugin.afterApply) await plugin.afterApply(img, {
currentSrc,
flag: effect,
flags,
originalSrc: source,
plugin,
rawValue: effect.rawValue,
value: effect.value,
processor: this
});
}
return true;
}
enableToggle(img) {
if (img.dataset.imageProcessorToggleBound === "1") return;
img.dataset.imageProcessorToggleBound = "1";
img.addEventListener("click", () => {
const originalSrc = img.dataset.imageProcessorOriginalSrc;
const processedSrc = img.dataset.imageProcessorProcessedSrc;
if (!originalSrc || !processedSrc) return;
img.src = img.src === originalSrc ? processedSrc : originalSrc;
});
}
async processImages() {
const images = document.querySelectorAll(this.selector);
for (const img of images) {
if (img.dataset.imageProcessorOriginalSrc) img.src = img.dataset.imageProcessorOriginalSrc;
if (!parseHashFlags(img.dataset.imageProcessorOriginalSrc || img.src).some((flag) => this.getPlugin(flag.keyword))) continue;
await this.processImage(img);
}
}
};
//#endregion
//#region src/lib/adjustments.js
var canvasToBlobURL$3 = (canvas) => {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(URL.createObjectURL(blob));
else reject(/* @__PURE__ */ new Error("Failed to convert canvas to blob"));
}, "image/png");
});
};
var parseFactor = (rawValue, fallback) => {
if (!rawValue) return fallback;
const normalized = rawValue.trim().toLowerCase();
const numeric = Number.parseFloat(normalized);
if (Number.isNaN(numeric)) return fallback;
return numeric;
};
var createAdjustedBlob = async (img, adjustPixel) => {
const width = img.naturalWidth;
const height = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
const imgData = ctx.getImageData(0, 0, width, height);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b] = adjustPixel(data[i], data[i + 1], data[i + 2]);
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
}
ctx.putImageData(imgData, 0, 0);
return canvasToBlobURL$3(canvas);
};
var brightness = {
keyword: "brightness",
processedClass: "brightness--processed",
formatValue: (rawValue) => ({ factor: parseFactor(rawValue, 1.15) }),
async process(img, { value }) {
return createAdjustedBlob(img, (r, g, b) => [
Math.min(255, Math.max(0, Math.round(r * value.factor))),
Math.min(255, Math.max(0, Math.round(g * value.factor))),
Math.min(255, Math.max(0, Math.round(b * value.factor)))
]);
}
};
var contrast = {
keyword: "contrast",
processedClass: "contrast--processed",
formatValue: (rawValue) => ({ factor: parseFactor(rawValue, 1.2) }),
async process(img, { value }) {
const factor = value.factor;
const midpoint = 128;
return createAdjustedBlob(img, (r, g, b) => [
Math.min(255, Math.max(0, Math.round((r - midpoint) * factor + midpoint))),
Math.min(255, Math.max(0, Math.round((g - midpoint) * factor + midpoint))),
Math.min(255, Math.max(0, Math.round((b - midpoint) * factor + midpoint)))
]);
}
};
//#endregion
//#region src/lib/bw.js
var canvasToBlobURL$2 = (canvas) => {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(URL.createObjectURL(blob));
else reject(/* @__PURE__ */ new Error("Failed to convert canvas to blob"));
}, "image/png");
});
};
var formatRawValue$1 = (rawValue) => {
const mode = rawValue?.toLowerCase();
if (mode === "invert") return { mode: "invert" };
if (mode === "threshold" || mode === "mono") return { mode: "threshold" };
return { mode: "grayscale" };
};
var createBWBlob = async (img, mode = "grayscale") => {
const width = img.naturalWidth;
const height = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
const imgData = ctx.getImageData(0, 0, width, height);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
let gray = Math.round(.299 * r + .587 * g + .114 * b);
if (mode === "invert") gray = 255 - gray;
if (mode === "threshold") gray = gray >= 128 ? 255 : 0;
data[i] = gray;
data[i + 1] = gray;
data[i + 2] = gray;
}
ctx.putImageData(imgData, 0, 0);
return canvasToBlobURL$2(canvas);
};
var bw = {
keyword: "bw",
processedClass: "bw--processed",
formatValue: formatRawValue$1,
async process(img, { value }) {
return createBWBlob(img, value.mode);
}
};
//#endregion
//#region src/lib/dither.js
var BAYER_2x2 = [[0, 2], [3, 1]];
var BAYER_4x4 = [
[
0,
8,
2,
10
],
[
12,
4,
14,
6
],
[
3,
11,
1,
9
],
[
15,
7,
13,
5
]
];
var BAYER_8x8 = [
[
0,
48,
12,
60,
3,
51,
15,
63
],
[
32,
16,
44,
28,
35,
19,
47,
31
],
[
8,
56,
4,
52,
11,
59,
7,
55
],
[
40,
24,
36,
20,
43,
27,
39,
23
],
[
2,
50,
14,
62,
1,
49,
13,
61
],
[
34,
18,
46,
30,
33,
17,
45,
29
],
[
10,
58,
6,
54,
9,
57,
5,
53
],
[
42,
26,
38,
22,
41,
25,
37,
21
]
];
var BAYER_MATRICES = {
"2x2": BAYER_2x2,
"4x4": BAYER_4x4,
"8x8": BAYER_8x8,
"16x16": [
[
0,
128,
32,
160,
8,
136,
40,
168,
2,
130,
34,
162,
10,
138,
42,
170
],
[
192,
64,
224,
96,
200,
72,
232,
104,
194,
66,
226,
98,
202,
74,
234,
106
],
[
48,
176,
16,
144,
56,
184,
24,
152,
50,
178,
18,
146,
58,
186,
26,
154
],
[
240,
112,
208,
80,
248,
120,
216,
88,
242,
114,
210,
82,
250,
122,
218,
90
],
[
12,
140,
44,
172,
4,
132,
36,
164,
14,
142,
46,
174,
6,
134,
38,
166
],
[
204,
76,
236,
108,
196,
68,
228,
100,
206,
78,
238,
110,
198,
70,
230,
102
],
[
60,
188,
28,
156,
52,
180,
20,
148,
62,
190,
30,
158,
54,
182,
22,
150
],
[
252,
124,
220,
92,
244,
116,
212,
84,
254,
126,
222,
94,
246,
118,
214,
86
],
[
3,
131,
35,
163,
11,
139,
43,
171,
1,
129,
33,
161,
9,
137,
41,
169
],
[
195,
67,
227,
99,
203,
75,
235,
107,
193,
65,
225,
97,
201,
73,
233,
105
],
[
51,
179,
19,
147,
59,
187,
27,
155,
49,
177,
17,
145,
57,
185,
25,
153
],
[
243,
115,
211,
83,
251,
123,
219,
91,
241,
113,
209,
81,
249,
121,
217,
89
],
[
15,
143,
47,
175,
7,
135,
39,
167,
13,
141,
45,
173,
5,
133,
37,
165
],
[
207,
79,
239,
111,
199,
71,
231,
103,
205,
77,
237,
109,
197,
69,
229,
101
],
[
63,
191,
31,
159,
55,
183,
23,
151,
61,
189,
29,
157,
53,
181,
21,
149
],
[
255,
127,
223,
95,
247,
119,
215,
87,
253,
125,
221,
93,
245,
117,
213,
85
]
],
none: null
};
var MATRIX = BAYER_8x8;
var RGB_LEVELS = 8;
var canvasToBlobURL$1 = (canvas) => {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(URL.createObjectURL(blob));
else reject(/* @__PURE__ */ new Error("Failed to convert canvas to blob"));
}, "image/png");
});
};
var formatRawValue = (rawValue) => {
const options = {
matrix: MATRIX,
levels: RGB_LEVELS,
bw: false
};
if (!rawValue) return options;
for (const token of rawValue.split(",").map((part) => part.trim())) {
if (!token) continue;
const lowerToken = token.toLowerCase();
if (lowerToken === "bw") {
options.bw = true;
continue;
}
if (/^[248]c$/.test(lowerToken) && !options.bw) {
options.levels = Number(lowerToken[0]);
continue;
}
const matrix = BAYER_MATRICES[lowerToken];
if (matrix !== void 0) options.matrix = matrix;
}
if (options.bw) options.levels = 2;
return options;
};
var createDitheredBlob = async (img, scale = 1, matrix = BAYER_8x8, levels = 8, bw = false) => {
const width = Math.floor(img.naturalWidth * scale);
const height = Math.floor(img.naturalHeight * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
const imgData = ctx.getImageData(0, 0, width, height);
const data = imgData.data;
const step = 255 / (levels - 1);
for (let i = 0, y = 0; y < height; y++) for (let x = 0; x < width; x++, i += 4) {
const threshold = matrix ? (matrix[y % matrix.length][x % matrix[0].length] / (matrix.length * matrix[0].length) - .5) * step : 0;
if (bw) {
const gray = Math.round(.299 * data[i] + .587 * data[i + 1] + .114 * data[i + 2]);
const value = Math.min(255, Math.max(0, gray + threshold));
const quantized = Math.round(value / step) * step;
data[i] = quantized;
data[i + 1] = quantized;
data[i + 2] = quantized;
continue;
}
for (let c = 0; c < 3; c++) {
let value = data[i + c] + threshold;
value = Math.min(255, Math.max(0, value));
data[i + c] = Math.round(value / step) * step;
}
}
ctx.putImageData(imgData, 0, 0);
return canvasToBlobURL$1(canvas);
};
var dither = {
keyword: "dither",
processedClass: "dither--processed",
toggleable: true,
formatValue: formatRawValue,
async process(img, { value }) {
return createDitheredBlob(img, 1, value.matrix, value.levels, value.bw);
},
afterApply(img, { processor }) {
processor.enableToggle(img);
}
};
//#endregion
//#region src/lib/scale.js
var canvasToBlobURL = (canvas) => {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(URL.createObjectURL(blob));
else reject(/* @__PURE__ */ new Error("Failed to convert canvas to blob"));
}, "image/png");
});
};
var createScaledBlob = async (img, scale, smoothing = true) => {
const width = Math.max(1, Math.floor(img.naturalWidth * scale));
const height = Math.max(1, Math.floor(img.naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = smoothing;
ctx.imageSmoothingQuality = smoothing ? "high" : "low";
ctx.drawImage(img, 0, 0, width, height);
return canvasToBlobURL(canvas);
};
var half = {
keyword: "half",
processedClass: "half--processed",
async process(img) {
return createScaledBlob(img, .5);
}
};
var double = {
keyword: "double",
processedClass: "double--processed",
async process(img) {
return createScaledBlob(img, 2, false);
}
};
//#endregion
//#region src/lib/paradimg.js
var plugins = [
bw,
brightness,
contrast,
half,
double,
dither
];
var createImageProcessor = (options = {}) => new ImageProcessor(options.plugins ?? plugins, options.selector);
var processImages = (options) => createImageProcessor(options).processImages();
//#endregion
exports.ImageProcessor = ImageProcessor;
exports.brightness = brightness;
exports.bw = bw;
exports.contrast = contrast;
exports.createImageProcessor = createImageProcessor;
exports.dither = dither;
exports.double = double;
exports.half = half;
exports.parseHashFlags = parseHashFlags;
exports.plugins = plugins;
exports.processImages = processImages;
exports.stripHash = stripHash;