- // ==UserScript==
- // @name Image Alt to Title
- // @namespace myfonj
- // @include *
- // @grant none
- // @version 1.9.2
- // @run-at document-start
- // @description Hover tooltip of image displaying alt attribute, original title, some accessibility-related properties, and URL info.
- // @license CC0
- // ==/UserScript==
- /*
- * https://gf.zukizuki.org/en/scripts/418348/versions/new
- *
- * Changelog:
- * 1.9.2 (2024-11-04): Another fix for SVG titles. Titled SVG (non-root) elements still take precedence over ours "view source" amendments.
- * 1.9.1 (2024-11-04): Fix for SVG source overshadowing parent (possibly HTML) title.
- * 1.9.0 (2024-10-31): SVG source to its title. Crude, but how I needed this, goddamit!
- * 1.8.9 (2024-01-24): better optical formatting of location search (URLSearchParams)
- * 1.8.8 (2023-09-12): no "none" background, further tab stop adjustments
- * 1.8.7 (2023-09-11): unified tab stop across devices (hopefuly)
- * 1.8.6 (2023-09-04): values separated by tab stops from labels
- * 1.8.5 (2023-09-04): for multiline string, break them below label, so the first line aligns with rest
- * 1.8.4 (2022-11-04): trim long strings
- * 1.8.3 (2022-11-02): ~ minor, omit empty filename from info.
- * 1.8.2 (2022-10-23): ~ minor, bail out from image-only page also in Chrome / Edge.
- * 1.8.1 (2022-10-19): ~ minor text corrections.
- * 1.8.0 (2022-10-18): + 'generator-unable-to-provide-required-alt' https://html.spec.whatwg.org/multipage/images.html#guidance-for-markup-generators.
- *
- * § Trivia:
- * ¶ Hover tooltip displays content of nearest element's title attribute (@title).
- * ¶ Alt attribute (@alt) is possible only at IMG element.
- * ¶ IMG@alt is not displayed in tooltip.
- * ¶ IMG cannot have children.
- * ¶ @title is possible on any element, including IMG.
- * ¶ IMG@src is also valuable.
- *
- * Goal:
- * Display image alt attribute value in images hover tooltip, add valuable @SRC chunks.
- *
- * Details
- * Pull @alt from image and set it so it is readable as @title tooltip
- * so that produced title value will not obscure existing parent title
- * that would be displayed otherwise. Also include image filename from @src,
- * and additionally path or domain.
- *
- * Means
- * Upon "hover" set image's title attribute. Luckily tooltips delay catches augmented value.
- *
- * § Tastcases
- *
- * FROM:
- * <a>
- * <img>
- * </a>
- * TO:
- * <a>
- * <img title="Alt missing.">
- * </a>
- *
- * FROM:
- * <a>
- * <img alt="">
- * </a>
- * TO:
- * <a>
- * <img alt="" title="Alt: ''">
- * </a>
- *
- * FROM:
- * <a>
- * <img alt="░">
- * </a>
- * TO:
- * <a>
- * <img alt="░" title="Alt: ░">
- * </a>
- *
- * FROM:
- * <a>
- * <img alt="░" title="▒">
- * </a>
- * TO:
- * <a>
- * <img title="Alt: ░, title: ▒">
- * </a>
-
- * FROM:
- * <a title="▒">
- * <img alt="░">
- * </a>
- * TO:
- * <a>
- * <img title="Alt: ░, title: ▒">
- * </a>
- *
- */
-
- // do not run at image-only pages
- // Firefox is adding alt same as location
- if (
- document.querySelector(`body > img[src="${document.location.href}"]:only-child`)
- ) {
- // @ts-ignore (GreaseMonkey script is in fact function body)
- return
- }
-
- const originalTitles = new WeakMap();
- const amendedSVG = new WeakMap();
-
- let lastSetTitle = '';
- const docEl = document.documentElement;
- const listenerConf = { capture: true, passive: true };
-
- docEl.addEventListener('mouseenter', altToTitle, listenerConf);
- docEl.addEventListener('mouseleave', restoreTitle, listenerConf);
-
- const hoverLoadHandlerConf = { passive: true, once: false, capture: true };
- function hoverLoadHandler (event) {
- const tgt = event.target;
- // console.log('load', tgt)
- altPic(tgt, 'prepend');
- }
-
-
- function altToTitle (event) {
- const tgt = event.target;
- const tag = tgt.tagName;
- if(!tag) {
- return
- }
- if(tgt.namespaceURI === 'http://www.w3.org/2000/svg'){
- const origTitle = getClosestTitle(tgt);
- const s = tgt.closest('svg');
- if(amendedSVG.has(s)) {
- return
- }
- let st = s.querySelector('& > title');
- // FIXME: add handling for nested titled SVG elements
- // not clear how exactly: to always show the full source
- // wou would have to temp-remove title elements a hoist
- // their text to our root constructed.
- let origSource = s.outerHTML;
- if( st ) {
- amendedSVG.set(s,st.textContent);
- } else {
- amendedSVG.set(s,null);
- st = s.appendChild(
- document.createElementNS(
- 'http://www.w3.org/2000/svg',
- 'title'
- )
- );
- }
- if(origTitle){
- origSource = origTitle + '\n\n---\n\n' + origSource
- }
- st.textContent = origSource;
- return
- }
- if (tag == 'IMG') {
- if (originalTitles.has(tgt) || (tgt.title && tgt.title === lastSetTitle)) {
- // few times I got situations when mouseout was not triggered
- // presumably because something covered the image
- // or whole context was temporarily replaced or covered
- // or perhaps it was reconstructed from dirty snapshot
- // so this should prevent exponentially growing title
- return
- }
- tgt.addEventListener('load', hoverLoadHandler, hoverLoadHandlerConf);
- originalTitles.set(tgt, tgt.getAttribute('title'));
- altPic(tgt);
- }
-
- }
-
- function restoreTitle (event) {
- const tgt = event.target;
- if(tgt.namespaceURI==='http://www.w3.org/2000/svg'){
- const s = tgt.closest('svg');
- if(amendedSVG.has(s)) {
- const ot = amendedSVG.get(s);
- const te = s.querySelector('& > title');
- if(ot) {
- te.textContent = ot;
- } else {
- te.remove();
- }
- amendedSVG.delete(s);
- }
- return
- }
-
- if (originalTitles.has(tgt)) {
- let ot = originalTitles.get(tgt);
- if (ot === null) {
- tgt.removeAttribute('title');
- } else {
- tgt.title = ot;
- }
- originalTitles.delete(tgt);
- }
- tgt.removeEventListener('load', hoverLoadHandler, hoverLoadHandlerConf);
- }
-
-
- /**
- * @param {HTMLImageElement} img
- * @param {'prepend'} [mode]
- */
- function altPic (img, mode) {
- // console.log('altPic', mode);
- try {
- let titleToAppend = '';
- if (mode == 'prepend') {
- titleToAppend = img.title;
- if (titleToAppend == lastSetTitle) {
- img.removeAttribute('title');
- }
- }
- const separator = '---';
- const info = [];
- const alt = img.getAttribute('alt');
- let altText = alt || '';
- const title = getClosestTitle(img);
- const role = img.getAttribute('role');
- const isPresentation = role === 'presentation';
-
- if (role) {
- info.push('Role:\t' + role);
- }
-
- switch (alt) {
- case null:
- info.push(isPresentation ? `(Alt missing but not needed for this role.)` : `⚠ Alt missing`);
- break;
- case '':
- info.push(`Alt: ""`);
- break;
- default:
- if (alt != alt.trim()) {
- // "quote" characters are generally useful only to reveal leading/trailing whitespace
- altText = `»${alt}«`;
- }
- if (alt == title) {
- info.push(`Alt (=title):\t${altText}`);
- } else {
- // break first line below "Alt:" label when alt also contains breaks.
- if(altText.includes('\n')){
- altText = '\n' + altText;
- }
- info.push(`Alt:\t${altText}`);
- }
- }
-
- // https://html.spec.whatwg.org/multipage/images.html#guidance-for-markup-generators
- const gutpra = img.getAttribute('generator-unable-to-provide-required-alt');
- if (gutpra !== null) {
- info.push(separator);
- info.push('generator-unable-to-provide-required-alt');
- }
-
- if (title && alt != title) {
- info.push(separator);
- info.push('Title:\t' + title);
- }
-
- const descby = img.getAttribute('aria-describedby');
- if (descby) {
- info.push(separator);
- info.push('Described by (ARIA)`' + descby + '`:\t' + (document.getElementById(descby) || { textContent: '(element not found)' }).textContent);
- }
-
- // deprecated, but let's see
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/longDesc
- // https://www.stylemanual.gov.au/format-writing-and-structure/content-formats/images/alt-text-captions-and-titles-images
- const longdesc = img.getAttribute('longdesc');
- if (longdesc) {
- info.push(separator);
- info.push('Long Description (deprecated):\t' + longdesc);
- }
-
- const arialabel = img.getAttribute('aria-label');
- if (arialabel) {
- info.push(separator);
- info.push('Label (ARIA):\t' + arialabel);
- }
-
- // https://html5accessibility.com/stuff/2021/02/09/aria-description-by-public-demand-and-to-thunderous-applause/
- const histeve = img.getAttribute('aria-description');
- if (histeve) {
- info.push(separator);
- info.push('Description (ARIA):\t' + histeve);
- }
-
- var fig = img.closest('FIGURE');
- if (fig) {
- let capt = fig.querySelector('figcaption');
- if (capt && capt.textContent) {
- info.push(separator);
- info.push('Caption:\t' + capt.textContent.trim());
- }
- }
-
- info.push(separator);
-
- const srcURI = new URL(img.currentSrc || img.src, img.baseURI);
- const slugRx = /[^/]+$/;
- switch (srcURI.protocol) {
- case 'http:':
- case 'https:': {
- if (srcURI.hash) {
- info.push('Hash:\t' + trimString(decodeURIComponent(srcURI.hash)));
- }
- if (srcURI.search) {
- info.push('Search Params:\t' + formatParams(srcURI.search));
- }
- let filename = srcURI.pathname.match(slugRx);
- if (filename) {
- info.push('File:\t' + trimString(decodeURIComponent(String(filename))));
- }
- let path = srcURI.pathname.replace(slugRx, '');
- if (path && path != '/') {
- info.push('Path:\t' + trimString(decodeURIComponent(srcURI.pathname.replace(slugRx, ''))));
- }
- if (document.location.hostname != srcURI.hostname || window != window.top) {
- info.push('Host:\t' + trimString(srcURI.hostname));
- }
- break;
- }
- case 'data:': {
- info.push(trimString(srcURI.href));
- break;
- }
- default:
- info.push('Src:\t' + trimString(srcURI.href));
- }
- // ↔ ↕
- var CSSsizes = `${img.width} × ${img.height} CSSpx${findRatio(img.width, img.height)}`;
- var _width_ratio, _height_ratio;
- if (img.naturalWidth && img.naturalHeight) {
- // SVG have zero naturals
- if (img.naturalWidth == img.width && img.naturalHeight == img.height) {
- CSSsizes += ` (Natural)`;
- } else {
- _width_ratio = '~' + (img.width / img.naturalWidth * 100).toFixed(0) + '% of ';
- _height_ratio = '~' + (img.height / img.naturalHeight * 100).toFixed(0) + '% of ';
- if (_height_ratio == _width_ratio) {
- _height_ratio = '';
- }
- CSSsizes += ` (${_width_ratio}${img.naturalWidth} × ${_height_ratio}${img.naturalHeight} natural px${findRatio(img.naturalWidth, img.naturalHeight)})`;
- }
- }
- info.push('Size:\t' + CSSsizes);
- const cs = getComputedStyle(img);
- if (cs.backgroundImage && cs.backgroundImage != 'none') {
- info.push(separator);
- info.push('Background:\t' + cs.backgroundImage);
- }
- // unified tab stop across devices (hopefuly)
- // hotfix for label length and tab widths
- // add bunch of spaces to get uniform lengths
- // to tab aligns values in all browsers
- // (each value has the label at the begining, or not at all)
- const labelRgx = /^([A-Z].*?:)(\t)/;
- const longestLength = 3 + info.reduce((acc,msg)=>{
- if(!msg.startsWith('Background:\t') && labelRgx.test(msg)) {
- const l = msg.match(labelRgx)[1].length;
- if( acc < l ) {
- acc = l;
- }
- };
- return(acc);
- },0);
- const finalTitle = info.map(msg=>{
- if(labelRgx.test(msg)) {
- return msg.replace(labelRgx,(m0, m1, m2)=>{
- return m1.padEnd(longestLength, '\u2002') + m2
- });
- };
- return msg;
- }).join('\n');
- img.title = finalTitle;
- if (titleToAppend && (finalTitle != titleToAppend)) {
- img.title += '\n\n-- Previously --\n\n'
- + titleToAppend;
- }
- lastSetTitle = img.title;
- } catch (e) {
- // console.error('altPic ERROR', e, img);
- }
- }
-
- /**
- * @param {HTMLElement|SVGElement} el
- */
- function getClosestTitle (el) {
- let _ = el;
- do {
- let isSVG = _.namespaceURI === 'http://www.w3.org/2000/svg';
- if(isSVG){
- let svgTitle = _.querySelector('& > title');
- if(svgTitle) {
- return svgTitle.textContent;
- }
- } else {
- if (_.title) {
- return _.title;
- }
- }
- } while (_.parentElement && (_ = _.parentElement));
- return ''
- }
-
- function findRatio (x, y) {
- var smallest = Math.min(x, y);
- var n = 0;
- var res = n;
- while (++n <= smallest) {
- if (x % n == 0 && y % n == 0) res = n;
- }
- if (res == 1) {
- return ''
- }
- return ' [' + x / res + ':' + y / res + ']'
- }
-
- function trimString (str) {
- const limit = 524;
- if(str.length < limit) {
- return str;
- }
- return str.slice(0, limit) + ' (…+ '+ (str.length - limit) + ' characters)';
- }
-
- function formatParams(search) {
- let result = [];
- for ( const [k, v] of new URLSearchParams(search) ) {
- result.push(trimString(`${k}${v?`\t=\t${v}`:``}`))
- }
- if( result.length === 1) {
- return result
- } else if (result.length > 1){
- return '\n' + result.map(_=>`\t${_}`).join('\n')
- }
- return ''
- }