Greasy Fork is available in English.

Improve pixiv thumbnails

Stop pixiv from cropping thumbnails to a square. Use higher resolution thumbnails on Retina displays.

  1. /* eslint-disable max-len */
  2. // ==UserScript==
  3. // @name Improve pixiv thumbnails
  4. // @name:ja pixivサムネイルを改善する
  5. // @namespace https://www.kepstin.ca/userscript/
  6. // @license MIT; https://spdx.org/licenses/MIT.html
  7. // @version 20240918.2
  8. // @description Stop pixiv from cropping thumbnails to a square. Use higher resolution thumbnails on Retina displays.
  9. // @description:ja 正方形にトリミングされて表示されるのを防止します。Retinaディスプレイで高解像度のサムネイルを使用します。
  10. // @author Calvin Walton
  11. // @match https://www.pixiv.net/*
  12. // @match https://dic.pixiv.net/*
  13. // @match https://en-dic.pixiv.net/*
  14. // @exclude https://www.pixiv.net/fanbox*
  15. // @noframes
  16. // @run-at document-start
  17. // @grant GM_setValue
  18. // @grant GM_getValue
  19. // @grant GM_addValueChangeListener
  20. // ==/UserScript==
  21. /* eslint-enable max-len */
  22. /* global GM_addValueChangeListener */
  23.  
  24. // Copyright © 2020 Calvin Walton <calvin.walton@kepstin.ca>
  25. //
  26. // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
  27. // files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
  28. // modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
  29. // Software is furnished to do so, subject to the following conditions:
  30. //
  31. // The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or
  32. // substantial portions of the Software.
  33. //
  34. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
  35. // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  36. // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
  37. // ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  38.  
  39. (function kepstinFixPixivThumbnails () {
  40. 'use strict'
  41.  
  42. // Use an alternate domain (CDN) to load images
  43. // Configure this by setting `domainOverride` in userscript values
  44. let domainOverride = ''
  45.  
  46. // Use custom (uploader-provided) thumbnail crops
  47. // If you enable this setting, then if the uploader has set a custom square crop on the image, it will
  48. // be used. Automatically cropped images will continue to be converted to uncropped images
  49. // Configure this by setting `allowCustom` in userscript values
  50. let allowCustom = false
  51.  
  52. // Browser feature detection for CSS 4 image-set()
  53. let imageSetSupported = false
  54. let imageSetPrefix = ''
  55. if (CSS.supports('background-image', 'image-set(url("image1") 1x, url("image2") 2x)')) {
  56. imageSetSupported = true
  57. } else if (CSS.supports('background-image', '-webkit-image-set(url("image1") 1x, url("image2") 2x)')) {
  58. imageSetSupported = true
  59. imageSetPrefix = '-webkit-'
  60. }
  61.  
  62. // A regular expression that matches pixiv thumbnail urls
  63. // Has 5 captures:
  64. // $1: domain name
  65. // $2: thumbnail width (optional)
  66. // $3: thumbnail height (optional)
  67. // $4: everything in the URL after the thumbnail size up to the image suffix
  68. // $5: thumbnail crop type: square (auto crop), custom (manual crop), master (no crop)
  69. // eslint-disable-next-line max-len
  70. const srcRegexp = /https?:\/\/(i[^.]*\.pximg\.net)(?:\/c\/(\d+)x(\d+)(?:_[^/]*)?)?\/(?:custom-thumb|img-master)\/(.*?)_(custom|master|square)1200.jpg/
  71.  
  72. // Look for a URL pattern for a thumbnail image in a string and return its properties
  73. // Returns null if no image found, otherwise a structure containing the domain, width, height, path.
  74. function matchThumbnail (str) {
  75. const m = str.match(srcRegexp)
  76. if (!m) { return null }
  77.  
  78. let [_, domain, width, height, path, crop] = m
  79. // The 1200 size does not include size in the URL, so fill in the values here when missing
  80. width = width || 1200
  81. height = height || 1200
  82. if (domainOverride) { domain = domainOverride }
  83. return { domain, width, height, path, crop }
  84. }
  85.  
  86. // List of image sizes and paths possible for original aspect thumbnail images
  87. // This must be in order from small to large for the image set generation to work
  88. const imageSizes = [
  89. { size: 150, path: '/c/150x150' },
  90. { size: 240, path: '/c/240x240' },
  91. { size: 360, path: '/c/360x360_70' },
  92. { size: 600, path: '/c/600x600' },
  93. { size: 1200, path: '' }
  94. ]
  95.  
  96. // Generate a list of original thumbnail images in various sizes for an image,
  97. // and determine a default image based on the display size and screen resolution
  98. function genImageSet (size, m) {
  99. if (allowCustom && m.crop === 'custom') {
  100. return imageSizes.map((imageSize) => ({
  101. src: `https://${m.domain}${imageSize.path}/custom-thumb/${m.path}_custom1200.jpg`,
  102. scale: imageSize.size / size
  103. }))
  104. }
  105. return imageSizes.map((imageSize) => ({
  106. src: `https://${m.domain}${imageSize.path}/img-master/${m.path}_master1200.jpg`,
  107. scale: imageSize.size / size
  108. }))
  109. }
  110.  
  111. // Create a srcset= attribute on the img, with appropriate dpi scaling values
  112. // Also update the src= attribute
  113. function imgSrcset (img, size, m) {
  114. const imageSet = genImageSet(size, m)
  115. img.srcset = imageSet.map((image) => `${image.src} ${image.scale}x`).join(', ')
  116. // IMG tag src attribute is assumed to be 1x scale
  117. const defaultSrc = imageSet.find((image) => image.scale >= 1) || imageSet[imageSet.length - 1]
  118. img.src = defaultSrc.src
  119. img.style.objectFit = 'contain'
  120. if (!img.attributes.width && !img.style.width) { img.setAttribute('width', size) }
  121. if (!img.attributes.height && !img.style.height) { img.setAttribute('height', size) }
  122. }
  123.  
  124. // Set up a css background-image with image-set() where supported, falling back
  125. // to a single image
  126. function cssImageSet (node, size, m) {
  127. const imageSet = genImageSet(size, m)
  128. node.style.backgroundSize = 'contain'
  129. node.style.backgroundPosition = 'center'
  130. node.style.backgroundRepeat = 'no-repeat'
  131. if (imageSetSupported) {
  132. const cssImageList = imageSet.map((image) => `url("${image.src}") ${image.scale}x`).join(', ')
  133. node.style.backgroundImage = `${imageSetPrefix}image-set(${cssImageList})`
  134. } else {
  135. const optimalSrc = imageSet.find((image) => image.scale >= window.devicePixelRatio) || imageSet[imageSet.length - 1]
  136. node.style.backgroundImage = `url("${optimalSrc.src}")`
  137. }
  138. }
  139.  
  140. // Parse a CSS length value to a number of pixels. Returns NaN for other units.
  141. function cssPx (value) {
  142. if (!value.endsWith('px')) { return NaN }
  143. return +value.replace(/[^\d.-]/g, '')
  144. }
  145.  
  146. function findParentSize (node) {
  147. let e = node
  148. while (e.parentElement) {
  149. let size = Math.max(e.getAttribute('width'), e.getAttribute('height'))
  150. if (size > 0) { return size }
  151.  
  152. size = Math.max(cssPx(e.style.width), cssPx(e.style.height))
  153. if (size > 0) { return size }
  154.  
  155. e = e.parentElement
  156. }
  157. e = node
  158. while (e.parentElement) {
  159. const cstyle = window.getComputedStyle(e)
  160. const size = Math.max(cssPx(cstyle.width), cssPx(cstyle.height))
  161. if (size > 0) { return size }
  162.  
  163. e = e.parentElement
  164. }
  165. return 0
  166. }
  167.  
  168. function handleImg (node) {
  169. if (node.dataset.kepstinThumbnail === 'bad') { return }
  170. // Check for lazy-loaded images, which have a temporary URL
  171. // They'll be updated later when the src is set
  172. if (node.src === '' || node.src.startsWith('data:') || node.src.endsWith('transparent.gif')) { return }
  173.  
  174. // Terrible hack: A few pages on pixiv create temporary IMG tags to... preload? the images, then switch
  175. // to setting a background on a DIV afterwards. This would be fine, except the temporary images have
  176. // the height/width set to 0, breaking the hidpi image selection
  177. if (
  178. node.getAttribute('width') === '0' && node.getAttribute('height') === '0' &&
  179. /^\/(?:discovery|(?:bookmark|mypixiv)_new_illust(?:_r18)?\.php)/.test(window.location.pathname)
  180. ) {
  181. // Set the width/height to the expected values
  182. node.setAttribute('width', 198)
  183. node.setAttribute('height', 198)
  184. }
  185.  
  186. const m = matchThumbnail(node.src || node.srcset)
  187. if (!m) { node.dataset.kepstinThumbnail = 'bad'; return }
  188. if (node.dataset.kepstinThumbnail === m.path) { return }
  189.  
  190. // Cancel image load if it's not already loaded
  191. if (!node.complete) {
  192. node.src = ''
  193. node.srcset = ''
  194. }
  195.  
  196. // layout-thumbnail type don't have externally set size, but instead element size is determined
  197. // from image size. For other types we have to calculate size.
  198. let size = Math.max(m.width, m.height)
  199. if (node.matches('div._layout-thumbnail img')) {
  200. node.setAttribute('width', m.width)
  201. node.setAttribute('height', m.height)
  202. } else {
  203. const newSize = findParentSize(node)
  204. if (newSize > 16) { size = newSize }
  205. }
  206.  
  207. imgSrcset(node, size, m)
  208.  
  209. node.dataset.kepstinThumbnail = m.path
  210. }
  211.  
  212. function handleCSSBackground (node) {
  213. if (node.dataset.kepstinThumbnail === 'bad') { return }
  214. // Check for lazy-loaded images
  215. // They'll be updated later when the background image (in style attribute) is set
  216. if (
  217. node.classList.contains('js-lazyload') ||
  218. node.classList.contains('lazyloaded') ||
  219. node.classList.contains('lazyloading')
  220. ) { return }
  221.  
  222. const m = matchThumbnail(node.style.backgroundImage)
  223. if (!m) { node.dataset.kepstinThumbnail = 'bad'; return }
  224. if (node.dataset.kepstinThumbnail === m.path) { return }
  225.  
  226. node.style.backgroundImage = ''
  227.  
  228. let size = Math.max(cssPx(node.style.width), cssPx(node.style.height))
  229. if (!(size > 0)) {
  230. const cstyle = window.getComputedStyle(node)
  231. size = Math.max(cssPx(cstyle.width), cssPx(cstyle.height))
  232. }
  233. if (!(size > 0)) { size = Math.max(m.width, m.height) }
  234.  
  235. cssImageSet(node, size, m)
  236.  
  237. node.dataset.kepstinThumbnail = m.path
  238. }
  239.  
  240. function onetimeThumbnails (parentNode) {
  241. parentNode.querySelectorAll('IMG').forEach((node) => {
  242. handleImg(node)
  243. })
  244. parentNode.querySelectorAll('DIV[style*=background-image]').forEach((node) => {
  245. handleCSSBackground(node)
  246. })
  247. parentNode.querySelectorAll('A[style*=background-image]').forEach((node) => {
  248. handleCSSBackground(node)
  249. })
  250. }
  251.  
  252. function mutationObserverCallback (mutationList, _observer) {
  253. mutationList.forEach((mutation) => {
  254. const target = mutation.target
  255. switch (mutation.type) {
  256. case 'childList':
  257. mutation.addedNodes.forEach((node) => {
  258. if (node.nodeType !== Node.ELEMENT_NODE) return
  259.  
  260. if (node.nodeName === 'IMG') {
  261. handleImg(node)
  262. } else if ((node.nodeName === 'DIV' || node.nodeName === 'A') && node.style.backgroundImage) {
  263. handleCSSBackground(node)
  264. } else {
  265. onetimeThumbnails(node)
  266. }
  267. })
  268. break
  269. case 'attributes':
  270. if (target.nodeType !== Node.ELEMENT_NODE) break
  271.  
  272. if ((target.nodeName === 'DIV' || target.nodeName === 'A') && target.style.backgroundImage) {
  273. handleCSSBackground(target)
  274. } else if (target.nodeName === 'IMG') {
  275. handleImg(target)
  276. }
  277. break
  278. // no default
  279. }
  280. })
  281. }
  282.  
  283. function addStylesheet() {
  284. if (!(window.location.host == "www.pixiv.net")) { return; }
  285. if (document.head === null) {
  286. document.addEventListener("DOMContentLoaded", addStylesheet, { once: true });
  287. return;
  288. }
  289. let s = document.createElement("style");
  290. s.textContent = `
  291. div:has(>div[width][height]), div:has(>label div[width][height]), div[type="illust"] {
  292. border-radius: 0;
  293. }
  294. div[width][height]:hover img[data-kepstin-thumbnail], div[type="illust"] a:hover img {
  295. opacity: 1 !important;
  296. background-color: var(--charcoal-background1-hover);
  297. }
  298. div[radius] img[data-kepstin-thumbnail], div[type="illust"] img {
  299. border-radius: 0;
  300. background-color: var(--charcoal-background1);
  301. transition: background-color 0.2s;
  302. }
  303. div[radius]::before, div[type="illust"] a > div::before {
  304. border-radius: 0;
  305. background: transparent;
  306. box-shadow: inset 0 0 0 1px var(--charcoal-border);
  307. }
  308. `;
  309. document.head.appendChild(s);
  310. }
  311.  
  312. function loadSettings () {
  313. const gmDomainOverride = GM_getValue('domainOverride')
  314. if (typeof gmDomainOverride === 'undefined') {
  315. // migrate settings
  316. domainOverride = localStorage.getItem('kepstinDomainOverride') || ''
  317. localStorage.removeItem('kepstinDomainOverride')
  318. } else {
  319. domainOverride = gmDomainOverride || ''
  320. }
  321. if (domainOverride !== gmDomainOverride) {
  322. GM_setValue('domainOverride', domainOverride)
  323. }
  324.  
  325. const gmAllowCustom = GM_getValue('allowCustom')
  326. if (typeof gmAllowCustom === 'undefined') {
  327. // migrate settings
  328. allowCustom = !!localStorage.getItem('kepstinAllowCustom')
  329. localStorage.removeItem('kepstinAllowCustom')
  330. } else {
  331. allowCustom = !!gmAllowCustom
  332. }
  333. if (allowCustom !== gmAllowCustom) {
  334. GM_setValue('allowCustom', allowCustom)
  335. }
  336. }
  337.  
  338. if (typeof GM_getValue !== 'undefined' && typeof GM_setValue !== 'undefined') {
  339. loadSettings()
  340. }
  341. if (typeof GM_addValueChangeListener !== 'undefined') {
  342. GM_addValueChangeListener('domainOverride', (_name, _oldValue, newValue, remote) => {
  343. if (!remote) { return }
  344. domainOverride = newValue || ''
  345. if (domainOverride !== newValue) {
  346. GM_setValue('domainOverride', domainOverride)
  347. }
  348. })
  349. GM_addValueChangeListener('allowCustom', (_name, _oldValue, newValue, remote) => {
  350. if (!remote) { return }
  351. allowCustom = !!newValue
  352. if (allowCustom !== newValue) {
  353. GM_setValue('allowCustom', allowCustom)
  354. }
  355. })
  356. }
  357.  
  358. addStylesheet()
  359. onetimeThumbnails(document.firstElementChild)
  360. const thumbnailObserver = new MutationObserver(mutationObserverCallback)
  361. thumbnailObserver.observe(document.firstElementChild, {
  362. childList: true,
  363. subtree: true,
  364. attributes: true,
  365. attributeFilter: ['class', 'src', 'style']
  366. })
  367. }())