UNPKG

paradimg

Version:

Browser-side image URL modifiers and canvas effects.

786 lines (785 loc) 15.1 kB
/*! 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;