Image Alt to Title

Hover tooltip of image displaying alt attribute, original title and URL info.

目前為 2021-08-17 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Image Alt to Title
// @namespace   myfonj
// @include     *
// @grant       none
// @version     1.4.0
// @run-at      document-start
// @description Hover tooltip of image displaying alt attribute, original title and URL info.
// ==/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
 * Load (and error?) event listener constructing and setting title.
 * 
 * Dangers
 * Artificially altered alt or title after image load event will not be taken into account.
 * Mitigate with mutationObserver?
 * 
 * Process
 * Draw the rest of the owl
 * 
 * 
 * § 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> 
 * 
 * */

/**
 * @type {WeakMap.<HTMLImageElement,{originalTitle?:string,augmentedTitle?:string}>}
 */
const processedImages = new WeakMap();

const dppx = window.devicePixelRatio;

// 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
}

document.documentElement.addEventListener('load', delayAltPic, true);
// document.documentElement.addEventListener('error', altPic, true);

/**
 * @param {{target: EventTarget}} event
 */
function delayAltPic (event) {
  const img = /**@type {HTMLImageElement}*/(event.target);
  if (!img.tagName) {
    return
  };
  if (img.tagName != 'IMG') {
    return
  };
  setTimeout(function(){altPic(img)}, 64);
}
/**
 * @param {HTMLImageElement} img
 */
function altPic (img) {
  const separator = '---';
  var uri;
  try {
    const info = [];
    const alt = img.getAttribute('alt');
    // using .getAttribute because .alt == ''; 

    var known = processedImages.get(img);
    if (known) {
      // existing img loaded new src
      if (img.title == known.augmentedTitle) {
        // reset to not recursively add our own title to new title
        // console.log('known, resetting to original', known.originalTitle);
        img.title = known.originalTitle;
      } else {
        // in this case "original" means "augmented by page author"
        // console.log('known, title does not match', img.title);
        known.originalTitle = img.title;
      }
    } else {
      processedImages.set(img, { originalTitle: img.title });
      known = processedImages.get(img);
      // console.log('unknown', known);
    }

    const title = getClosestTitle(img);

    switch (alt) {
      case null:
        info.push('⚠ Alt missing');
        break;
      case '':
        info.push(`Alt: ""`);
        break;
      default:
        if( alt != alt.trim() ) {
          // "quote" characters are generally useful only to reveal leading/trailing whitespace
          alt = `»${alt}«`;
        }
        if (alt == title) {
          info.push(`Alt (=title): ${alt}`);
        } else {
          info.push(`Alt: ${alt}`);
        }
    }

    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);
    }
    
    
    // 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);
    uri = srcURI;
    const slugRx = /[^/]+$/;
    switch (srcURI.protocol) {
      case 'http:':
      case 'https:': {
        if (srcURI.search) {
          info.push('Params: ' + srcURI.search);
        }
        info.push('File: ' + srcURI.pathname.match(slugRx));
        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}px`;
    // var physicalSizes = `${img.width*dppx}×${img.height*dppx}px`;
    // ↑ hm, this is not working. how to get dimensions in sampling point counts when dppx != 1 / zoomed page?
    if (img.naturalWidth && img.naturalHeight) {
      // SVG have zero naturals
      if (img.naturalWidth == img.width && img.naturalHeight == img.height) {
        CSSsizes += ` (Natural)`;
      } else {
        CSSsizes += ` (Natural: ${img.naturalWidth}×${img.naturalHeight}px)`;
        // physicalSizes += ` (Natural: ${img.naturalWidth*dppx}×${img.naturalHeight*dppx}px)`;
      }
    }
    info.push('CSS Size: ' + CSSsizes);
    // if (dppx != 1) {
    //  info.push('Physical: ' + physicalSizes);
    // }
    img.title = known.augmentedTitle = info.join('\n');
  } catch (e) {
    // console.error(e, uri, img);
  }
}

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

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