MediaWiki:Gadget-AutoContrastFixer.js
Qiyofa
Eslatma: Saqlaganingizdan soʻng, oʻzgarishlarni koʻrish uchun brauzeringiz keshini tozalashingizga toʻgri kelishi mumkin.
- Firefox / Safari: Shift tugmasini bosgan holda, Yangilash unsurlar darchasini bosing, yoki Ctrl-F5 yoki Ctrl-R (Macda ⌘-R) ni bosing
- Google Chrome: Ctrl-Shift-R (Macda ⌘-Shift-R) ni bosing
- Internet Explorer / Edge: Ctrlni bosgan holda, Yangilashni bosing, yoki Ctrl-F5ni bosing
- Opera: Ctrl-F5ni bosing.
// <nowiki>
/* jshint maxerr:1048576, strict:true, undef:true, latedef:true, esversion:6 */
/* global $, mw */
/**
* Tungi rejimda andozalarning kontrastligini avtomatik ravishda sozlaydi.
*
* Muallif(lar): Surjection
* Oxirgi yangilangan: 2024-11-23
*/
// [[MediaWiki:Gadget-WiktGadgetPrefs.js]]
const preferences = mw.wiktGadgetPrefs.get(
"autoContrastFixer",
{
label: {
en: "Automatically fix custom template colors in night mode",
},
},
{
darkTextColorOverride: {
type: "boolean",
default: false,
label: {
en: "Apply also to templates with explicit dark text colors",
},
},
}
);
const darkTextColorOverride = preferences.darkTextColorOverride;
/* 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;
const convertSrgbChannel = (x) => x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
return 0.2126 * convertSrgbChannel(r) + 0.7152 * convertSrgbChannel(g) + 0.0722 * convertSrgbChannel(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];
}
function lightenForegroundColor(rgbaColor) {
/**
* Lightens the given RGBA color for it to make it a suitable
* text 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, 0.5 + (1 - 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;
const hasDarkTextColor =
colorLuminance(parsedForegroundColor) < PARAMETER_MINIMUM_FG_LUMA;
// check that:
// * the background color has enough alpha
// * the text color is actually light (or overridden)
// * the contrast is bad enough to consider fixing (or dark text color and overridden)
// * this isn't something like an image tag
if (
parsedBackgroundColor[3] >= PARAMETER_MINIMUM_BG_ALPHA &&
(darkTextColorOverride || !hasDarkTextColor) &&
(colorContrast(parsedForegroundColor, parsedBackgroundColor) <=
PARAMETER_MAXIMUM_CONTRAST_TO_FIX ||
(hasDarkTextColor && darkTextColorOverride)) &&
el.tagName !== "IMG"
) {
if (
colorLuminance(parsedForegroundColor) <
PARAMETER_MINIMUM_FG_LUMA
) {
// generate a new foreground color and apply it
const newForegroundColor = lightenForegroundColor(
parsedForegroundColor
);
if (
colorLuminance(newForegroundColor) >
colorLuminance(parsedForegroundColor)
) {
fixedElement = true;
el.style.color = makeCssColor(newForegroundColor);
}
}
// 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);
}
$(document).ready(function () {
doAutoContrastFix();
});
// </nowiki>