Highlight Scaled Images

A useful tool for frontend developers to detect bad image quality issues on HTML pages. The script scans all images and highlights problematic images: adds a tint and overlay text to images scaled by the browser (upscaled, downscaled).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Highlight Scaled Images
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  A useful tool for frontend developers to detect bad image quality issues on HTML pages. The script scans all images and highlights problematic images: adds a tint and overlay text to images scaled by the browser (upscaled, downscaled).
// @license      MIT
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function () {
    'use strict';

    // Default settings
    const DEFAULTS = {
        showDownscale: true,
        showProportional: true,
        // enabledDomains holds an array of hostnames where the script is active.
        // Empty array = disabled everywhere by default.
        enabledDomains: [
            'murznn.github.io',
        ]
    };

    // Utility to get and set settings
    function getSetting(key) {
        const val = GM_getValue(key);
        if (typeof val === 'undefined') return DEFAULTS[key];
        return val;
    }
    function setSetting(key, value) {
        GM_setValue(key, value);
    }

    // Domain helpers
    function getHost() {
        try { return location.hostname; } catch (e) { return ''; }
    }
    function isEnabledForCurrentDomain() {
        const enabled = getSetting('enabledDomains') || [];
        const host = getHost();
        return enabled.indexOf(host) !== -1;
    }
    function enableForCurrentDomain() {
        const host = getHost();
        if (!host) return;
        const enabled = Array.isArray(getSetting('enabledDomains')) ? getSetting('enabledDomains') : [];
        if (enabled.indexOf(host) === -1) {
            enabled.push(host);
            setSetting('enabledDomains', enabled);
        }
    }
    function disableForCurrentDomain() {
        const host = getHost();
        if (!host) return;
        const enabled = Array.isArray(getSetting('enabledDomains')) ? getSetting('enabledDomains') : [];
        const idx = enabled.indexOf(host);
        if (idx !== -1) {
            enabled.splice(idx, 1);
            setSetting('enabledDomains', enabled);
        }
    }
    function listEnabledDomains() {
        return Array.isArray(getSetting('enabledDomains')) ? getSetting('enabledDomains') : [];
    }
    function clearEnabledDomains() {
        setSetting('enabledDomains', []);
    }

    // Overlay logic
    let __overlayIdCounter = 1;
    function createOverlay(text, color, img) {
        const overlay = document.createElement('div');
        overlay.textContent = text;
        overlay.style.position = 'absolute';
        overlay.style.left = '50%';
        overlay.style.top = '50%';
        overlay.style.transform = 'translate(-50%, -50%)';
        overlay.style.display = 'flex';
        overlay.style.alignItems = 'center';
        overlay.style.justifyContent = 'center';
        overlay.style.color = '#fff';
        overlay.style.background = color;
        overlay.style.padding = '2px 6px';
        overlay.style.borderRadius = '6px';
        overlay.style.fontSize = '0.8em';
        overlay.style.fontWeight = 'bold';
        overlay.style.pointerEvents = 'none';
        overlay.style.zIndex = '10';
        overlay.className = 'img-scale-overlay';
        // link overlay to image via dataset for reliable removal
        if (!img.dataset.scaleOverlayId) img.dataset.scaleOverlayId = String(__overlayIdCounter++);
        overlay.dataset.forImgId = img.dataset.scaleOverlayId;
        overlay.style.boxShadow = '0 1px 4px rgba(0,0,0,0.2)';
        overlay.style.opacity = '0.85';
        overlay.style.border = '1px solid #fff';
        overlay.style.whiteSpace = 'nowrap';
        overlay.style.minWidth = img.offsetWidth + 'px';
        return overlay;
    }

    function removeOverlays(img) {
        if (!img.parentElement) return;
        const id = img.dataset.scaleOverlayId;
        Array.from(img.parentElement.querySelectorAll('.img-scale-overlay')).forEach(function (overlay) {
            // remove overlays that belong to this image (by dataset) or that are directly adjacent
            if ((id && overlay.dataset.forImgId === id) || overlay.previousSibling === img || overlay.nextSibling === img) {
                overlay.remove();
            }
        });
    }

    function getScalePercent(img) {
        const widthScale = img.offsetWidth / img.naturalWidth;
        const heightScale = img.offsetHeight / img.naturalHeight;
        const scale = Math.max(widthScale, heightScale);
        const percent = scale * 100;
        if (Math.abs(percent - Math.round(percent)) > 0.01) {
            return percent.toFixed(2);
        } else {
            return Math.round(percent).toString();
        }
    }

    function getDownsizeLabelAndTint(img, scalePercent) {
        const percentNum = parseFloat(scalePercent);
        const origSize = `${img.naturalWidth}x${img.naturalHeight}`;
        const realSize = `${img.offsetWidth}x${img.offsetHeight}`;
        let sizeText = `[${origSize} → ${realSize}]`;
        if (Math.abs(percentNum - 50) < 0.01) {
            return {
                text: `Downsized 2x (${scalePercent}%) ${sizeText}`,
                color: 'rgba(0,180,60,0.7)',
                tint: 'brightness(0.7) sepia(1) hue-rotate(90deg) saturate(5)',
                isProportional: true
            };
        } else if (Math.abs(percentNum - 25) < 0.01) {
            return {
                text: `Downsized 4x (${scalePercent}%) ${sizeText}`,
                color: 'rgba(0,180,60,0.7)',
                tint: 'brightness(0.7) sepia(1) hue-rotate(90deg) saturate(5)',
                isProportional: true
            };
        } else {
            return {
                text: `Downsized ${scalePercent}% ${sizeText}`,
                color: 'rgba(0, 80, 255, 0.7)',
                tint: 'brightness(0.7) sepia(1) hue-rotate(180deg) saturate(5)',
                isProportional: false
            };
        }
    }

    function getUpsizeLabel(img, scalePercent) {
        const origSize = `${img.naturalWidth}x${img.naturalHeight}`;
        const realSize = `${img.offsetWidth}x${img.offsetHeight}`;
        let sizeText = `[${origSize} → ${realSize}]`;
        return {
            text: `Upsized ${scalePercent}% ${sizeText}`,
            color: 'rgba(255, 40, 40, 0.7)',
            tint: 'brightness(0.7) sepia(1) hue-rotate(-50deg) saturate(5)'
        };
    }

    function applyTintToImages() {
        const showDownscale = getSetting('showDownscale');
        const showProportional = getSetting('showProportional');

        document.querySelectorAll('img').forEach(function (img) {
            // always clear previous overlays for this image to avoid duplicates
            removeOverlays(img);
            // keep a small transition for filter changes
            img.style.transition = 'filter 0.3s';

            if (img.naturalWidth > 0 && img.naturalHeight > 0 && img.offsetWidth > 0 && img.offsetHeight > 0) {
                const isDownsized = img.offsetWidth < img.naturalWidth || img.offsetHeight < img.naturalHeight;
                const isUpsized = img.offsetWidth > img.naturalWidth || img.offsetHeight > img.naturalHeight;
                if (isDownsized || isUpsized) {
                    const parent = img.parentElement;
                    if (parent) {
                        // only set position if computed style is static and remember original value
                        const parentStyle = window.getComputedStyle(parent);
                        if (parentStyle.position === 'static') {
                            if (typeof parent.dataset.originalPosition === 'undefined') {
                                parent.dataset.originalPosition = parent.style.position || '';
                            }
                            parent.style.position = 'relative';
                        }
                    }
                    let overlay, scalePercent, label;
                    scalePercent = getScalePercent(img);
                    if (isDownsized) {
                        label = getDownsizeLabelAndTint(img, scalePercent);
                        // Only show if allowed by settings
                        if ((label.isProportional && showProportional) || (!label.isProportional && showDownscale)) {
                            img.style.filter = label.tint;
                            overlay = createOverlay(label.text, label.color, img);
                            const parent = img.parentElement;
                            if (parent) {
                                if (img.nextSibling) {
                                    parent.insertBefore(overlay, img.nextSibling);
                                } else {
                                    parent.appendChild(overlay);
                                }
                            }
                        }
                    } else if (isUpsized) {
                        label = getUpsizeLabel(img, scalePercent);
                        img.style.filter = label.tint;
                        overlay = createOverlay(label.text, label.color, img);
                        const parent = img.parentElement;
                        if (parent) {
                            if (img.nextSibling) {
                                parent.insertBefore(overlay, img.nextSibling);
                            } else {
                                parent.appendChild(overlay);
                            }
                        }
                    }
                } else {
                    // image at 100% - remove any filter and restore parent position if we changed it
                    img.style.filter = '';
                    const parent = img.parentElement;
                    if (parent && typeof parent.dataset.originalPosition !== 'undefined') {
                        parent.style.position = parent.dataset.originalPosition || '';
                        delete parent.dataset.originalPosition;
                    }
                }
            }
        });
    }

    // Settings modal
    function createSettingsModal() {
        // Remove existing modal if present
        const existing = document.getElementById('img-scale-settings-modal');
        if (existing) existing.remove();

        const modal = document.createElement('div');
        modal.id = 'img-scale-settings-modal';
        modal.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.7);
            z-index: 999999;
            display: flex;
            align-items: center;
            justify-content: center;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
        `;

        const panel = document.createElement('div');
        panel.style.cssText = `
            background: #fff;
            border-radius: 8px;
            padding: 24px;
            max-width: 500px;
            width: 90%;
            max-height: 80vh;
            overflow-y: auto;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
        `;

        const currentHost = getHost();
        const isEnabled = isEnabledForCurrentDomain();
        const showDownscale = getSetting('showDownscale');
        const showProportional = getSetting('showProportional');
        const enabledDomains = listEnabledDomains();

        panel.innerHTML = `
            <h2 style="margin: 0 0 20px 0; font-size: 20px; color: #333;">Highlight Scaled Images - Settings</h2>

            <div style="margin-bottom: 20px;">
                <h3 style="margin: 0 0 10px 0; font-size: 16px; color: #555;">Current Domain</h3>
                <div style="margin-bottom: 10px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
                    <strong>${currentHost}</strong>
                    <div style="margin-top: 8px;">
                        <button id="toggle-domain-btn" style="padding: 6px 12px; background: ${isEnabled ? '#dc3545' : '#28a745'}; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
                            ${isEnabled ? 'Disable' : 'Enable'} for this domain
                        </button>
                    </div>
                </div>
            </div>

            <div style="margin-bottom: 20px;">
                <h3 style="margin: 0 0 10px 0; font-size: 16px; color: #555;">Highlight Options</h3>
                <label style="display: block; margin-bottom: 8px; cursor: pointer;">
                    <input type="checkbox" id="show-downscale" ${showDownscale ? 'checked' : ''} style="margin-right: 8px;">
                    Show Downscale (blue) highlight
                </label>
                <label style="display: block; margin-bottom: 8px; cursor: pointer;">
                    <input type="checkbox" id="show-proportional" ${showProportional ? 'checked' : ''} style="margin-right: 8px;">
                    Show Proportional (green) highlight
                </label>
            </div>

            <div style="margin-bottom: 20px;">
                <h3 style="margin: 0 0 10px 0; font-size: 16px; color: #555;">Enabled Domains</h3>
                <div id="domains-list" style="max-height: 150px; overflow-y: auto; padding: 10px; background: #f5f5f5; border-radius: 4px; margin-bottom: 10px;">
                    ${enabledDomains.length ? enabledDomains.map(d => `<div style="margin-bottom: 4px;">• ${d}</div>`).join('') : '<div style="color: #999;">No domains enabled</div>'}
                </div>
                <button id="clear-domains-btn" style="padding: 6px 12px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;" ${enabledDomains.length ? '' : 'disabled'}>
                    Clear all domains
                </button>
            </div>

            <div style="text-align: right; margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
                <button id="close-settings-btn" style="padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
                    Close
                </button>
            </div>
        `;

        modal.appendChild(panel);
        document.body.appendChild(modal);

        // Event handlers
        modal.addEventListener('click', function (e) {
            if (e.target === modal) modal.remove();
        });

        document.getElementById('close-settings-btn').addEventListener('click', function () {
            modal.remove();
        });

        document.getElementById('toggle-domain-btn').addEventListener('click', function () {
            if (isEnabledForCurrentDomain()) {
                disableForCurrentDomain();
                console.log('Highlight Scaled Images: DISABLED for', currentHost);
                detachRuntime();
            } else {
                enableForCurrentDomain();
                console.log('Highlight Scaled Images: ENABLED for', currentHost);
                attachRuntime();
            }
            modal.remove();
        });

        document.getElementById('show-downscale').addEventListener('change', function (e) {
            setSetting('showDownscale', e.target.checked);
            console.log('Downscale highlight:', e.target.checked ? 'ON' : 'OFF');
            if (isEnabledForCurrentDomain()) applyTintToImages();
        });

        document.getElementById('show-proportional').addEventListener('change', function (e) {
            setSetting('showProportional', e.target.checked);
            console.log('Proportional highlight:', e.target.checked ? 'ON' : 'OFF');
            if (isEnabledForCurrentDomain()) applyTintToImages();
        });

        document.getElementById('clear-domains-btn').addEventListener('click', function () {
            if (confirm('Clear all enabled domains?')) {
                clearEnabledDomains();
                console.log('All enabled domains cleared');
                modal.remove();
                detachRuntime();
            }
        });

        // Close on Escape key
        const escHandler = function (e) {
            if (e.key === 'Escape') {
                modal.remove();
                document.removeEventListener('keydown', escHandler);
            }
        };
        document.addEventListener('keydown', escHandler);
    }

    // Menu registration (only once, no duplicates)
    GM_registerMenuCommand('Enable for this domain', function () {
        const currentHost = getHost();
        if (isEnabledForCurrentDomain()) {
            alert(`This domain (${currentHost}) is already enabled.\n\nUse the Settings menu to disable it.`);
        } else {
            enableForCurrentDomain();
            console.log('Highlight Scaled Images: ENABLED for', currentHost);
            attachRuntime();
        }
    });

    GM_registerMenuCommand('Settings', function () {
        createSettingsModal();
    });

    // Only run the active parts of the script when the current domain is enabled.
    // Menu commands above remain available regardless.
    let observer = null;
    let debouncedApply = null;
    let resizeHandler = null;
    let loadHandler = null;

    function attachRuntime() {
        // Don't attach if already attached
        if (observer) return;

        // Initial scan
        applyTintToImages();

        // Debounced runner to avoid rapid repeat work and avoid reacting to our own changes
        function debounce(fn, wait) {
            let t = null;
            return function () {
                const args = arguments;
                clearTimeout(t);
                t = setTimeout(function () { fn.apply(null, args); }, wait);
            };
        }

        debouncedApply = debounce(applyTintToImages, 120);

        // Reapply on window resize
        resizeHandler = function () { applyTintToImages(); };
        window.addEventListener('resize', resizeHandler);

        // Reapply on DOM changes (lazy load, AJAX, etc.)
        observer = new MutationObserver(function (mutations) {
            // ignore mutations that are only our overlays being added/removed
            for (const m of mutations) {
                if (m.type === 'childList') {
                    // if any added/removed node is our overlay, skip scheduling
                    const nodes = [...m.addedNodes, ...m.removedNodes];
                    let skip = false;
                    for (const n of nodes) {
                        if (n.nodeType === 1 && n.classList && n.classList.contains('img-scale-overlay')) {
                            skip = true;
                            break;
                        }
                    }
                    if (skip) continue;
                }
                // schedule debounced apply for other mutations
                debouncedApply();
                return;
            }
        });
        // avoid observing style/class changes we make; only observe src/srcset and structural changes
        try {
            observer.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['src', 'srcset']
            });
        } catch (e) {
            // ignore if document.body isn't available yet
        }

        // Also reapply when images finish loading (for lazy-loaded images)
        loadHandler = function (e) {
            if (e.target.tagName === 'IMG') {
                applyTintToImages();
            }
        };
        document.body.addEventListener('load', loadHandler, true);
    }

    function detachRuntime() {
        // Disconnect observer
        try { if (observer) observer.disconnect(); } catch (e) { /* ignore */ }
        observer = null;
        debouncedApply = null;

        // Remove event listeners
        if (resizeHandler) {
            window.removeEventListener('resize', resizeHandler);
            resizeHandler = null;
        }
        if (loadHandler) {
            try {
                document.body.removeEventListener('load', loadHandler, true);
            } catch (e) { /* ignore */ }
            loadHandler = null;
        }

        // remove any overlays we added and clear filters
        document.querySelectorAll('.img-scale-overlay').forEach(function (o) { o.remove(); });
        document.querySelectorAll('img').forEach(function (img) { img.style.filter = ''; });
        // try to restore parent positions we modified
        document.querySelectorAll('[data-original-position]').forEach(function (el) {
            try { el.style.position = el.dataset.originalPosition || ''; delete el.dataset.originalPosition; } catch (e) { }
        });
    }

    // Ensure runtime is attached only when enabled
    if (isEnabledForCurrentDomain()) {
        attachRuntime();
    }
})();