MediaWiki:Gadget-AutoContrastFixer.js

Note: You may have to bypass your browser’s cache to see the changes. In addition, after saving a sitewide CSS file such as MediaWiki:Common.css, it will take 5-10 minutes before the changes take effect, even if you clear your cache.

  • Mozilla / Firefox / Safari: hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Command-R on a Macintosh);
  • Konqueror and Chrome: click Reload or press F5;
  • Opera: clear the cache in Tools → Preferences;
  • Internet Explorer: hold Ctrl while clicking Refresh, or press Ctrl-F5.


// <nowiki>
/* jshint maxerr:1048576, strict:true, undef:true, latedef:true, es5:true */
/* global $ */

/**
 * Automatically adjusts contrast of templates in night mode.
 *
 * Author(s): Surjection
 * Last updated: 2024-09-21
 */

/* Color conversion code. */
/**************************/

function parseCssValue(cssAlpha, divisor) {
    /**
     * Parses a numeric value in CSS syntax, either given directly or
     * as a percentage.
     *
     * If a divisor is given, it is used to scale the value, unless it is
     * a percentage.
     */
    if (cssAlpha.endsWith("%")) {
        return parseCssValue(cssAlpha.slice(0, -1).trim(), 100.0);
    }
    return Number(cssAlpha) / (divisor || 1.0);
}

// RegEx used by parseCssColor
const RE_CSS_HEX_COLOR_3 = /^#([0-9A-Fa-f])([0-9A-Fa-f])([0-9A-Fa-f])$/;
const RE_CSS_HEX_COLOR_6 =
    /^#([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})$/;
const RE_CSS_RGB_COLOR =
    /^rgb\(\s*([0-9.]+\s*%?)(?:\s+|\s*,\s*)([0-9.]+\s*%?)(?:\s+|\s*,\s*)([0-9.]+\s*%?)\s*\)$/;
const RE_CSS_RGBA_COLOR =
    /^rgba?\(\s*([0-9.]+\s*%?)\s*,\s*([0-9.]+\s*%?)\s*,\s*([0-9.]+\s*%?)\s*,\s*([0-9.]+\s*%?)\s*\)$/;
const RE_CSS_RGBA_SLASH_COLOR =
    /^rgba?\(\s*([0-9.]+\s*%?)\s+([0-9.]+\s*%?)\s+([0-9.]+\s*%?)\s*\/\s*([0-9.]+\s*%?)\s*\)$/;

function parseCssColor(cssColor) {
    /**
     * Parses a color in CSS syntax and returns it in RGBA format:
     *
     * [r, g, b, a]
     *      with each value within [0, 1].
     *
     * Returns undefined if the color could not be parsed.
     */
    let m;
    if ((m = cssColor.match(RE_CSS_RGB_COLOR))) {
        return [
            parseCssValue(m[1], 255.0),
            parseCssValue(m[2], 255.0),
            parseCssValue(m[3], 255.0),
            1.0
        ];
    }
    if ((m = cssColor.match(RE_CSS_RGBA_COLOR))) {
        return [
            parseCssValue(m[1], 255.0),
            parseCssValue(m[2], 255.0),
            parseCssValue(m[3], 255.0),
            parseCssValue(m[4])
        ];
    }
    if ((m = cssColor.match(RE_CSS_RGBA_SLASH_COLOR))) {
        return [
            parseCssValue(m[1], 255.0),
            parseCssValue(m[2], 255.0),
            parseCssValue(m[3], 255.0),
            parseCssValue(m[4])
        ];
    }
    if ((m = cssColor.match(RE_CSS_HEX_COLOR_6))) {
        return [
            parseInt(m[1], 16) / 255.0,
            parseInt(m[2], 16) / 255.0,
            parseInt(m[3], 16) / 255.0,
            1.0
        ];
    }
    if ((m = cssColor.match(RE_CSS_HEX_COLOR_3))) {
        return [
            parseInt(m[1], 16) / 15.0,
            parseInt(m[2], 16) / 15.0,
            parseInt(m[3], 16) / 15.0,
            1.0
        ];
    }
    if (cssColor === "transparent") return [0, 0, 0, 0];
    return undefined;
}

function makeCssColor(rgbaColor) {
    /**
     * Converts the given RGBA color into CSS format.
     */
    const [r, g, b, a] = rgbaColor;
    const normR = Math.round(r * 255.0);
    const normG = Math.round(g * 255.0);
    const normB = Math.round(b * 255.0);
    if (a >= 1.0) {
        return `rgb(${normR}, ${normG}, ${normB})`;
    } else {
        return `rgba(${normR}, ${normG}, ${normB}, ${a})`;
    }
}

function rgbToHsl(r, g, b) {
    /**
     * Converts colors from RGB to HSL (hue, saturation, lightness).
     *
     * The RGB inputs should all lie within [0, 1].
     *
     * With valid inputs, the hue value will lie within [0, 6],
     * while the saturation and lightness values will lie within [0, 1].
     */
    const min = Math.min(r, g, b);
    const max = Math.max(r, g, b);
    const delta = max - min;
    const l = (max + min) / 2;

    if (delta === 0) {
        return [0, 0, l];
    }

    const s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);

    let h;
    if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
    else if (max === g) h = (b - r) / delta + 2;
    else if (max === b) h = (r - g) / delta + 4;
    return [h, s, l];
}

function hslToRgb(h, s, l) {
    /**
     * Converts colors from HSL (hue, saturation, lightness) to RGB.
     *
     * The hue value should lie within [0, 6], and the saturation and
     * lightness values within [0, 1].
     *
     * With valid inputs, the RGB outputs will all lie within [0, 1].
     */
    if (s === 0) {
        return [l, l, l];
    }

    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
    h /= 6;
    if (h < 0) h = 1 - h;
    h %= 1;

    function hueToRgb(p, q, t) {
        if (t < 0) t += 1;
        else if (t > 1) t -= 1;

        if (t < 1 / 6) return p + (q - p) * 6 * t;
        if (t < 1 / 2) return q;
        if (t < 2 / 3) return p + (q - p) * 6 * (2 / 3 - t);
        return p;
    }

    const r = hueToRgb(p, q, h + 1 / 3);
    const g = hueToRgb(p, q, h);
    const b = hueToRgb(p, q, h - 1 / 3);
    return [r, g, b];
}

/* Color processing code. */
/**************************/

function colorLuminance(rgbaColor) {
    /**
     * Returns the luminance of the RGB color (alpha is ignored) using the
     * sRGB/BT.709 luminance formula.
     */
    const [r, g, b] = rgbaColor;
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function colorContrast(fgRgbaColor, bgRgbaColor) {
    /**
     * Returns the WCAG contrast ratio (>= 1) for the given two colors.
     */
    const q =
        (colorLuminance(fgRgbaColor) + 0.05) /
        (colorLuminance(bgRgbaColor) + 0.05);
    return q < 1 ? 1 / q : q;
}

function darkenBackgroundColor(rgbaColor) {
    /**
     * Darkens the given RGBA color for it to make it a suitable
     * background color in night mode.
     */
    const [r, g, b, a] = rgbaColor;
    const [h, s, l] = rgbToHsl(r, g, b);

    let lAdj = 1 - colorLuminance(rgbaColor);
    lAdj = Math.pow(lAdj, 0.75);
    lAdj = 0.05 + 0.95 * lAdj;

    const lScale = Math.min(Math.max(lAdj, 0.01) / Math.max(l, 0.01), 1);
    const [rr, gg, bb] = hslToRgb(h, (s + s * lScale) / 2, lAdj);
    return [rr, gg, bb, a];
}

function darkenBorderColor(rgbaColor) {
    /**
     * Darkens the given RGBA color for it to make it a suitable
     * border color in night mode.
     */
    const [r, g, b, a] = rgbaColor;
    const [h, s, l] = rgbToHsl(r, g, b);
    const [rr, gg, bb] = hslToRgb(h, s * 0.5, l * 0.5);
    return [rr, gg, bb, a];
}

/* Auto-fixer parameters. */
/**************************/

/** Any background-foreground combo with the contrast ratio exceeding
 * this value are ignored by the fixer. */
const PARAMETER_MAXIMUM_CONTRAST_TO_FIX = 3;
/** The background color must have at least this much alpha,
 * or the element is ignored by the fixer. */
const PARAMETER_MINIMUM_BG_ALPHA = 0.5;
/** The foreground color must have at least this much luminance,
 * or the element is ignored by the fixer. */
const PARAMETER_MINIMUM_FG_LUMA = 0.5;
/** The border color must have at least this much luminance,
 * or it is ignored by the fixer. */
const PARAMETER_MINIMUM_BORDER_LUMA = 0.7;

/* Auto-fixer framework. */
/*************************/

function isSkinNightMode() {
    /**
     * Returns true if the user has requested night mode.
     */
    if (!window.matchMedia) return;

    const htmlRoot = document.documentElement;
    const nightPref = htmlRoot.classList.contains(
        "skin-theme-clientpref-night"
    );
    const osPref = htmlRoot.classList.contains("skin-theme-clientpref-os");
    const isScreen = window.matchMedia("screen").matches;
    const prefersDark = window.matchMedia(
        "(prefers-color-scheme: dark)"
    ).matches;

    return isScreen && (nightPref || (osPref && prefersDark));
}

function doAutoContrastFix() {
    /**
     * Applies the auto-contrast fix to all elements in the
     * wikipage body.
     */
    // see if the user has requested night mode.
    if (!isSkinNightMode()) return;

    const view = document.defaultView;
    const ELEMENTS_PER_FRAME = 50;

    function isCandidate(el) {
        const style = view.getComputedStyle(el);
        return style.background !== "none" || style.borderStyle !== "none";
    }

    // TODO optimize; can we skip searching candidates or split it up
    // into multiple frames to avoid blocking the page for some time?
    const candidates = Array.prototype.filter.call(
        document.querySelectorAll("#mw-content-text *"),
        isCandidate
    );

    let autoContrastReport = [];

    function autoContrastReportElement(el) {
        // find top level element within mw-content-text
        let top = el;
        while (
            top.parentElement &&
            !top.parentElement.classList.contains("mw-parser-output")
        ) {
            top = top.parentElement;
        }
        let nextHeadingLevel = 6;
        const foundHeadings = Array(nextHeadingLevel + 1);
        let sectionId, sectionNumber;
        if (top) {
            // find elements
            let previous = top;
            const recordHeading = function (headingElement, headingLevel) {
                foundHeadings[headingLevel] = headingElement.textContent;
                if (sectionId == null) sectionId = headingElement.id;
                if (sectionNumber == null) {
                    const editLink =
                        headingElement.parentElement.querySelector(
                            ".mw-editsection a"
                        );
                    const editUrl = !editLink ? null : new URL(editLink.href);
                    if (
                        editUrl &&
                        editUrl.searchParams &&
                        editUrl.searchParams.has("section")
                    )
                        sectionNumber = Number(
                            editUrl.searchParams.get("section")
                        );
                }
            };

            while ((previous = previous.previousElementSibling)) {
                if (previous.classList.contains("mw-heading")) {
                    // get heading level
                    const headingElement =
                        previous.querySelector("h1,h2,h3,h4,h5,h6");
                    if (headingElement) {
                        const headingLevel = Number(
                            headingElement.tagName.replace(/h/i, "")
                        );
                        if (headingLevel && headingLevel <= nextHeadingLevel) {
                            recordHeading(headingElement, headingLevel);
                            nextHeadingLevel = headingLevel - 1;
                        }
                    }
                }
            }
        }

        autoContrastReport.push({
            headings: foundHeadings
                .filter(function (v) {
                    return v != null;
                })
                .join(" > "),
            sectionId: sectionId,
            sectionNumber: sectionNumber,
            topClasses: top ? top.className : undefined,
            classes: el.className,
            element: el
        });
        el.classList.add("wikt-auto-contrast-fixed");
    }

    function autoContrastFixDone() {
        /**
         * Executed when the auto-contrast fix has been applied
         * to all elements.
         */
        if (autoContrastReport.length > 0) {
            console.log(
                `Applied auto-contrast fix to ${autoContrastReport.length} elements`
            );
            console.log("Full auto-contrast report", autoContrastReport);
        }
    }

    function doAutoContrastFixOne(el) {
        /**
         * Applies the auto-contrast fix to the specified element.
         */
        // exemptions
        if (el.closest(".wikt-auto-contrast-exempt")) return;

        const computedStyle = view.getComputedStyle(el);
        
        // get background and foreground colors.
        const cssBackgroundColor = computedStyle.backgroundColor;
        const parsedBackgroundColor = parseCssColor(cssBackgroundColor);
        if (parsedBackgroundColor == null) return;

        const cssForegroundColor = computedStyle.color;
        const parsedForegroundColor = parseCssColor(cssForegroundColor);
        if (parsedForegroundColor == null) return;

        let fixedElement = false;

        // check that:
        // * the background color has enough alpha
        // * the text color is actually light
        // * the contrast is bad enough to consider fixing
        // * this isn't something like an image tag
        if (
            parsedBackgroundColor[3] >= PARAMETER_MINIMUM_BG_ALPHA &&
            colorLuminance(parsedForegroundColor) >=
                PARAMETER_MINIMUM_FG_LUMA &&
            colorContrast(parsedForegroundColor, parsedBackgroundColor) <=
                PARAMETER_MAXIMUM_CONTRAST_TO_FIX &&
            el.tagName !== "IMG"
        ) {
            // generate a background color and apply it, but only if it is darker.
            const newBackgroundColor = darkenBackgroundColor(
                parsedBackgroundColor
            );
            if (
                colorLuminance(newBackgroundColor) <
                colorLuminance(parsedBackgroundColor)
            ) {
                fixedElement = true;
                el.style.backgroundColor = makeCssColor(newBackgroundColor);
            }
        }

        if (computedStyle.borderStyle !== "none") {
            // odds are the border color needs the same treatment.
            const cssBorderColor = view.getComputedStyle(el).borderColor;
            const parsedBorderColor = parseCssColor(cssBorderColor);

            if (
                parsedBorderColor != null &&
                colorLuminance(parsedBorderColor) >=
                    PARAMETER_MINIMUM_BORDER_LUMA
            ) {
                // generate a border color and apply it, but only if it is darker.
                const newBorderColor = darkenBorderColor(parsedBorderColor);
                if (
                    colorLuminance(newBorderColor) <
                    colorLuminance(parsedBorderColor)
                ) {
                    fixedElement = true;
                    el.style.borderColor = makeCssColor(newBorderColor);
                }
            }
        }

        if (fixedElement) {
            // report that we found an element to fix.
            autoContrastReportElement(el);
        }
    }

    function doAutoContrastFixBatch(base) {
        /**
         * Applies the auto-contrast fix to the next batch
         * of elements.
         */
        for (let offset = 0; offset < ELEMENTS_PER_FRAME; ++offset) {
            const index = base + offset;
            if (index >= candidates.length) {
                autoContrastFixDone();
                return;
            }
            doAutoContrastFixOne(candidates[index]);
        }

        window.requestAnimationFrame(function () {
            doAutoContrastFixBatch(base + ELEMENTS_PER_FRAME);
        });
    }

    doAutoContrastFixBatch(0);
}

jQuery(document).ready(function () {
    doAutoContrastFix();
});

// </nowiki>