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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址