Image Alt to Title

Hover tooltip of image displaying alt attribute, original title, some accessibility-related properties, and URL info.

// ==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://greasyfork.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 ''
}