Image Alt to Title

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

  1. // ==UserScript==
  2. // @name Image Alt to Title
  3. // @namespace myfonj
  4. // @include *
  5. // @grant none
  6. // @version 1.9.2
  7. // @run-at document-start
  8. // @description Hover tooltip of image displaying alt attribute, original title, some accessibility-related properties, and URL info.
  9. // @license CC0
  10. // ==/UserScript==
  11. /*
  12. * https://gf.zukizuki.org/en/scripts/418348/versions/new
  13. *
  14. * Changelog:
  15. * 1.9.2 (2024-11-04): Another fix for SVG titles. Titled SVG (non-root) elements still take precedence over ours "view source" amendments.
  16. * 1.9.1 (2024-11-04): Fix for SVG source overshadowing parent (possibly HTML) title.
  17. * 1.9.0 (2024-10-31): SVG source to its title. Crude, but how I needed this, goddamit!
  18. * 1.8.9 (2024-01-24): better optical formatting of location search (URLSearchParams)
  19. * 1.8.8 (2023-09-12): no "none" background, further tab stop adjustments
  20. * 1.8.7 (2023-09-11): unified tab stop across devices (hopefuly)
  21. * 1.8.6 (2023-09-04): values separated by tab stops from labels
  22. * 1.8.5 (2023-09-04): for multiline string, break them below label, so the first line aligns with rest
  23. * 1.8.4 (2022-11-04): trim long strings
  24. * 1.8.3 (2022-11-02): ~ minor, omit empty filename from info.
  25. * 1.8.2 (2022-10-23): ~ minor, bail out from image-only page also in Chrome / Edge.
  26. * 1.8.1 (2022-10-19): ~ minor text corrections.
  27. * 1.8.0 (2022-10-18): + 'generator-unable-to-provide-required-alt' https://html.spec.whatwg.org/multipage/images.html#guidance-for-markup-generators.
  28. *
  29. * § Trivia:
  30. * ¶ Hover tooltip displays content of nearest element's title attribute (@title).
  31. * ¶ Alt attribute (@alt) is possible only at IMG element.
  32. * ¶ IMG@alt is not displayed in tooltip.
  33. * ¶ IMG cannot have children.
  34. * ¶ @title is possible on any element, including IMG.
  35. * ¶ IMG@src is also valuable.
  36. *
  37. * Goal:
  38. * Display image alt attribute value in images hover tooltip, add valuable @SRC chunks.
  39. *
  40. * Details
  41. * Pull @alt from image and set it so it is readable as @title tooltip
  42. * so that produced title value will not obscure existing parent title
  43. * that would be displayed otherwise. Also include image filename from @src,
  44. * and additionally path or domain.
  45. *
  46. * Means
  47. * Upon "hover" set image's title attribute. Luckily tooltips delay catches augmented value.
  48. *
  49. * § Tastcases
  50. *
  51. * FROM:
  52. * <a>
  53. * <img>
  54. * </a>
  55. * TO:
  56. * <a>
  57. * <img title="Alt missing.">
  58. * </a>
  59. *
  60. * FROM:
  61. * <a>
  62. * <img alt="">
  63. * </a>
  64. * TO:
  65. * <a>
  66. * <img alt="" title="Alt: ''">
  67. * </a>
  68. *
  69. * FROM:
  70. * <a>
  71. * <img alt="░">
  72. * </a>
  73. * TO:
  74. * <a>
  75. * <img alt="░" title="Alt: ░">
  76. * </a>
  77. *
  78. * FROM:
  79. * <a>
  80. * <img alt="░" title="▒">
  81. * </a>
  82. * TO:
  83. * <a>
  84. * <img title="Alt: ░, title: ▒">
  85. * </a>
  86.  
  87. * FROM:
  88. * <a title="▒">
  89. * <img alt="░">
  90. * </a>
  91. * TO:
  92. * <a>
  93. * <img title="Alt: ░, title: ▒">
  94. * </a>
  95. *
  96. */
  97.  
  98. // do not run at image-only pages
  99. // Firefox is adding alt same as location
  100. if (
  101. document.querySelector(`body > img[src="${document.location.href}"]:only-child`)
  102. ) {
  103. // @ts-ignore (GreaseMonkey script is in fact function body)
  104. return
  105. }
  106.  
  107. const originalTitles = new WeakMap();
  108. const amendedSVG = new WeakMap();
  109.  
  110. let lastSetTitle = '';
  111. const docEl = document.documentElement;
  112. const listenerConf = { capture: true, passive: true };
  113.  
  114. docEl.addEventListener('mouseenter', altToTitle, listenerConf);
  115. docEl.addEventListener('mouseleave', restoreTitle, listenerConf);
  116.  
  117. const hoverLoadHandlerConf = { passive: true, once: false, capture: true };
  118. function hoverLoadHandler (event) {
  119. const tgt = event.target;
  120. // console.log('load', tgt)
  121. altPic(tgt, 'prepend');
  122. }
  123.  
  124.  
  125. function altToTitle (event) {
  126. const tgt = event.target;
  127. const tag = tgt.tagName;
  128. if(!tag) {
  129. return
  130. }
  131. if(tgt.namespaceURI === 'http://www.w3.org/2000/svg'){
  132. const origTitle = getClosestTitle(tgt);
  133. const s = tgt.closest('svg');
  134. if(amendedSVG.has(s)) {
  135. return
  136. }
  137. let st = s.querySelector('& > title');
  138. // FIXME: add handling for nested titled SVG elements
  139. // not clear how exactly: to always show the full source
  140. // wou would have to temp-remove title elements a hoist
  141. // their text to our root constructed.
  142. let origSource = s.outerHTML;
  143. if( st ) {
  144. amendedSVG.set(s,st.textContent);
  145. } else {
  146. amendedSVG.set(s,null);
  147. st = s.appendChild(
  148. document.createElementNS(
  149. 'http://www.w3.org/2000/svg',
  150. 'title'
  151. )
  152. );
  153. }
  154. if(origTitle){
  155. origSource = origTitle + '\n\n---\n\n' + origSource
  156. }
  157. st.textContent = origSource;
  158. return
  159. }
  160. if (tag == 'IMG') {
  161. if (originalTitles.has(tgt) || (tgt.title && tgt.title === lastSetTitle)) {
  162. // few times I got situations when mouseout was not triggered
  163. // presumably because something covered the image
  164. // or whole context was temporarily replaced or covered
  165. // or perhaps it was reconstructed from dirty snapshot
  166. // so this should prevent exponentially growing title
  167. return
  168. }
  169. tgt.addEventListener('load', hoverLoadHandler, hoverLoadHandlerConf);
  170. originalTitles.set(tgt, tgt.getAttribute('title'));
  171. altPic(tgt);
  172. }
  173.  
  174. }
  175.  
  176. function restoreTitle (event) {
  177. const tgt = event.target;
  178. if(tgt.namespaceURI==='http://www.w3.org/2000/svg'){
  179. const s = tgt.closest('svg');
  180. if(amendedSVG.has(s)) {
  181. const ot = amendedSVG.get(s);
  182. const te = s.querySelector('& > title');
  183. if(ot) {
  184. te.textContent = ot;
  185. } else {
  186. te.remove();
  187. }
  188. amendedSVG.delete(s);
  189. }
  190. return
  191. }
  192.  
  193. if (originalTitles.has(tgt)) {
  194. let ot = originalTitles.get(tgt);
  195. if (ot === null) {
  196. tgt.removeAttribute('title');
  197. } else {
  198. tgt.title = ot;
  199. }
  200. originalTitles.delete(tgt);
  201. }
  202. tgt.removeEventListener('load', hoverLoadHandler, hoverLoadHandlerConf);
  203. }
  204.  
  205.  
  206. /**
  207. * @param {HTMLImageElement} img
  208. * @param {'prepend'} [mode]
  209. */
  210. function altPic (img, mode) {
  211. // console.log('altPic', mode);
  212. try {
  213. let titleToAppend = '';
  214. if (mode == 'prepend') {
  215. titleToAppend = img.title;
  216. if (titleToAppend == lastSetTitle) {
  217. img.removeAttribute('title');
  218. }
  219. }
  220. const separator = '---';
  221. const info = [];
  222. const alt = img.getAttribute('alt');
  223. let altText = alt || '';
  224. const title = getClosestTitle(img);
  225. const role = img.getAttribute('role');
  226. const isPresentation = role === 'presentation';
  227.  
  228. if (role) {
  229. info.push('Role:\t' + role);
  230. }
  231.  
  232. switch (alt) {
  233. case null:
  234. info.push(isPresentation ? `(Alt missing but not needed for this role.)` : `⚠ Alt missing`);
  235. break;
  236. case '':
  237. info.push(`Alt: ""`);
  238. break;
  239. default:
  240. if (alt != alt.trim()) {
  241. // "quote" characters are generally useful only to reveal leading/trailing whitespace
  242. altText = ${alt}«`;
  243. }
  244. if (alt == title) {
  245. info.push(`Alt (=title):\t${altText}`);
  246. } else {
  247. // break first line below "Alt:" label when alt also contains breaks.
  248. if(altText.includes('\n')){
  249. altText = '\n' + altText;
  250. }
  251. info.push(`Alt:\t${altText}`);
  252. }
  253. }
  254.  
  255. // https://html.spec.whatwg.org/multipage/images.html#guidance-for-markup-generators
  256. const gutpra = img.getAttribute('generator-unable-to-provide-required-alt');
  257. if (gutpra !== null) {
  258. info.push(separator);
  259. info.push('generator-unable-to-provide-required-alt');
  260. }
  261.  
  262. if (title && alt != title) {
  263. info.push(separator);
  264. info.push('Title:\t' + title);
  265. }
  266.  
  267. const descby = img.getAttribute('aria-describedby');
  268. if (descby) {
  269. info.push(separator);
  270. info.push('Described by (ARIA)`' + descby + '`:\t' + (document.getElementById(descby) || { textContent: '(element not found)' }).textContent);
  271. }
  272.  
  273. // deprecated, but let's see
  274. // https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/longDesc
  275. // https://www.stylemanual.gov.au/format-writing-and-structure/content-formats/images/alt-text-captions-and-titles-images
  276. const longdesc = img.getAttribute('longdesc');
  277. if (longdesc) {
  278. info.push(separator);
  279. info.push('Long Description (deprecated):\t' + longdesc);
  280. }
  281.  
  282. const arialabel = img.getAttribute('aria-label');
  283. if (arialabel) {
  284. info.push(separator);
  285. info.push('Label (ARIA):\t' + arialabel);
  286. }
  287.  
  288. // https://html5accessibility.com/stuff/2021/02/09/aria-description-by-public-demand-and-to-thunderous-applause/
  289. const histeve = img.getAttribute('aria-description');
  290. if (histeve) {
  291. info.push(separator);
  292. info.push('Description (ARIA):\t' + histeve);
  293. }
  294.  
  295. var fig = img.closest('FIGURE');
  296. if (fig) {
  297. let capt = fig.querySelector('figcaption');
  298. if (capt && capt.textContent) {
  299. info.push(separator);
  300. info.push('Caption:\t' + capt.textContent.trim());
  301. }
  302. }
  303.  
  304. info.push(separator);
  305.  
  306. const srcURI = new URL(img.currentSrc || img.src, img.baseURI);
  307. const slugRx = /[^/]+$/;
  308. switch (srcURI.protocol) {
  309. case 'http:':
  310. case 'https:': {
  311. if (srcURI.hash) {
  312. info.push('Hash:\t' + trimString(decodeURIComponent(srcURI.hash)));
  313. }
  314. if (srcURI.search) {
  315. info.push('Search Params:\t' + formatParams(srcURI.search));
  316. }
  317. let filename = srcURI.pathname.match(slugRx);
  318. if (filename) {
  319. info.push('File:\t' + trimString(decodeURIComponent(String(filename))));
  320. }
  321. let path = srcURI.pathname.replace(slugRx, '');
  322. if (path && path != '/') {
  323. info.push('Path:\t' + trimString(decodeURIComponent(srcURI.pathname.replace(slugRx, ''))));
  324. }
  325. if (document.location.hostname != srcURI.hostname || window != window.top) {
  326. info.push('Host:\t' + trimString(srcURI.hostname));
  327. }
  328. break;
  329. }
  330. case 'data:': {
  331. info.push(trimString(srcURI.href));
  332. break;
  333. }
  334. default:
  335. info.push('Src:\t' + trimString(srcURI.href));
  336. }
  337. // ↔ ↕
  338. var CSSsizes = `${img.width} × ${img.height} CSSpx${findRatio(img.width, img.height)}`;
  339. var _width_ratio, _height_ratio;
  340. if (img.naturalWidth && img.naturalHeight) {
  341. // SVG have zero naturals
  342. if (img.naturalWidth == img.width && img.naturalHeight == img.height) {
  343. CSSsizes += ` (Natural)`;
  344. } else {
  345. _width_ratio = '~' + (img.width / img.naturalWidth * 100).toFixed(0) + '% of ';
  346. _height_ratio = '~' + (img.height / img.naturalHeight * 100).toFixed(0) + '% of ';
  347. if (_height_ratio == _width_ratio) {
  348. _height_ratio = '';
  349. }
  350. CSSsizes += ` (${_width_ratio}${img.naturalWidth} × ${_height_ratio}${img.naturalHeight} natural px${findRatio(img.naturalWidth, img.naturalHeight)})`;
  351. }
  352. }
  353. info.push('Size:\t' + CSSsizes);
  354. const cs = getComputedStyle(img);
  355. if (cs.backgroundImage && cs.backgroundImage != 'none') {
  356. info.push(separator);
  357. info.push('Background:\t' + cs.backgroundImage);
  358. }
  359. // unified tab stop across devices (hopefuly)
  360. // hotfix for label length and tab widths
  361. // add bunch of spaces to get uniform lengths
  362. // to tab aligns values in all browsers
  363. // (each value has the label at the begining, or not at all)
  364. const labelRgx = /^([A-Z].*?:)(\t)/;
  365. const longestLength = 3 + info.reduce((acc,msg)=>{
  366. if(!msg.startsWith('Background:\t') && labelRgx.test(msg)) {
  367. const l = msg.match(labelRgx)[1].length;
  368. if( acc < l ) {
  369. acc = l;
  370. }
  371. };
  372. return(acc);
  373. },0);
  374. const finalTitle = info.map(msg=>{
  375. if(labelRgx.test(msg)) {
  376. return msg.replace(labelRgx,(m0, m1, m2)=>{
  377. return m1.padEnd(longestLength, '\u2002') + m2
  378. });
  379. };
  380. return msg;
  381. }).join('\n');
  382. img.title = finalTitle;
  383. if (titleToAppend && (finalTitle != titleToAppend)) {
  384. img.title += '\n\n-- Previously --\n\n'
  385. + titleToAppend;
  386. }
  387. lastSetTitle = img.title;
  388. } catch (e) {
  389. // console.error('altPic ERROR', e, img);
  390. }
  391. }
  392.  
  393. /**
  394. * @param {HTMLElement|SVGElement} el
  395. */
  396. function getClosestTitle (el) {
  397. let _ = el;
  398. do {
  399. let isSVG = _.namespaceURI === 'http://www.w3.org/2000/svg';
  400. if(isSVG){
  401. let svgTitle = _.querySelector('& > title');
  402. if(svgTitle) {
  403. return svgTitle.textContent;
  404. }
  405. } else {
  406. if (_.title) {
  407. return _.title;
  408. }
  409. }
  410. } while (_.parentElement && (_ = _.parentElement));
  411. return ''
  412. }
  413.  
  414. function findRatio (x, y) {
  415. var smallest = Math.min(x, y);
  416. var n = 0;
  417. var res = n;
  418. while (++n <= smallest) {
  419. if (x % n == 0 && y % n == 0) res = n;
  420. }
  421. if (res == 1) {
  422. return ''
  423. }
  424. return ' [' + x / res + ':' + y / res + ']'
  425. }
  426.  
  427. function trimString (str) {
  428. const limit = 524;
  429. if(str.length < limit) {
  430. return str;
  431. }
  432. return str.slice(0, limit) + ' (…+ '+ (str.length - limit) + ' characters)';
  433. }
  434.  
  435. function formatParams(search) {
  436. let result = [];
  437. for ( const [k, v] of new URLSearchParams(search) ) {
  438. result.push(trimString(`${k}${v?`\t=\t${v}`:``}`))
  439. }
  440. if( result.length === 1) {
  441. return result
  442. } else if (result.length > 1){
  443. return '\n' + result.map(_=>`\t${_}`).join('\n')
  444. }
  445. return ''
  446. }