Image Alt to Title

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

2022-09-05 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

// ==UserScript==
// @name        Image Alt to Title
// @namespace   myfonj
// @include     *
// @grant       none
// @version     1.6.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
 * 
 * § 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
if (document.querySelector('body>img[alt="' + document.location.href + '"]:only-child')) {
  // @ts-ignore (GreaseMonkey script is in fact function body)
  return
}

const originalTitles = new WeakMap();
let lastSetTitle = '';
const docEl = document.documentElement;
const listenerConf = { capture: true, passive: true };

docEl.addEventListener('mouseenter', altToTitle, listenerConf);
docEl.addEventListener('mouseleave', restoreTitle, listenerConf);

function altToTitle (event) {
  const tgt = event.target
  if (tgt.tagName && tgt.tagName == 'IMG') {
    if(originalTitles.has(tgt) || (tgt.title && tgt.title === lastSetTitle)) {
      // few times I got situations when mouseout was not triggered
      // presumably because someting covered the image
      // or whole context were temporarily replaced or covered
      // or perhaps it was reconstructed from dirty snapshot
      // so this should prevent exoponentially growing title
      return
    }
    originalTitles.set(tgt, tgt.getAttribute('title'));
    altPic(tgt);
  }
}

function restoreTitle (event) {
  const tgt = event.target;
  if (originalTitles.has(tgt)) {
    let ot = originalTitles.get(tgt);
    if(ot === null) {
      tgt.removeAttribute('title');
    } else {
      tgt.title = ot;
    }
    originalTitles.delete(tgt);
  }
}

/**
 * @param {HTMLImageElement} img
 */
function altPic (img) {
  try {
    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: ' + 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): ${altText}`);
        } else {
          info.push(`Alt: ${altText}`);
        }
    }

    if (title && alt != title) {
      info.push(separator);
      info.push('Title: ' + title);
    }

    const descby = img.getAttribute('aria-describedby');
    if (descby) {
      info.push(separator);
      info.push('Described by `' + descby + '`: ' + (document.getElementById(descby) || { textContent: '(element not found)' }).textContent);
    }

    // depreated, 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: ' + longdesc);
    }

    const arialabel = img.getAttribute('aria-label');
    if (arialabel) {
      info.push(separator);
      info.push('Label (ARIA): ' + 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: ' + histeve);
    }

    var fig = getClosestEl(img, 'FIGURE');
    if( fig ){
      let capt = fig.querySelector('figcaption');
      if( capt) {
        info.push(separator);
        info.push('Figcaption: ' + 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.search) {
          info.push('Params: ' + srcURI.search);
        }
        info.push('File: ' + srcURI.pathname.match(slugRx));
        let path = srcURI.pathname.replace(slugRx, '');
        if(path && path != '/') {
          info.push('Path: ' + srcURI.pathname.replace(slugRx, ''));
        }
        if (document.location.hostname != srcURI.hostname || window != window.top) {
          info.push('Host: ' + srcURI.hostname);
        }
        break;
      }
      case 'data:': {
        let durichunks = srcURI.href.split(',');
        info.push(durichunks[0] + ', + ' + durichunks[1].length + ' b.');
        break;
      }
      default:
        info.push('Src: ' + 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: ' + CSSsizes);
    img.title = info.join('\n');
    lastSetTitle = img.title;
  } catch (e) {
    // console.error('altPic ERROR', e, img);
  }
}

/**
 * @param {HTMLElement} el
 */
function getClosestTitle (el) {
  do {
    if (el.title) {
      return el.title;
    }
  } while (el = el.parentElement);
  return ''
}

/**
 * @param {HTMLElement} el
 */
function getClosestEl (el, tagName) {
  do {
    if (el.tagName == tagName) {
      return el;
    }
  } while (el = el.parentElement);
  return false
}


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 + ']'
}