NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
/* eslint-disable max-len */ // ==UserScript== // @name Improve pixiv thumbnails // @name:ja pixivサムネイルを改善する // @namespace https://www.kepstin.ca/userscript/ // @license MIT; https://spdx.org/licenses/MIT.html // @version 20240918.2 // @description Stop pixiv from cropping thumbnails to a square. Use higher resolution thumbnails on Retina displays. // @description:ja 正方形にトリミングされて表示されるのを防止します。Retinaディスプレイで高解像度のサムネイルを使用します。 // @author Calvin Walton // @match https://www.pixiv.net/* // @match https://dic.pixiv.net/* // @match https://en-dic.pixiv.net/* // @exclude https://www.pixiv.net/fanbox* // @noframes // @run-at document-start // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // ==/UserScript== /* eslint-enable max-len */ /* global GM_addValueChangeListener */ // Copyright © 2020 Calvin Walton <calvin.walton@kepstin.ca> // // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation // files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, // modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, // ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. (function kepstinFixPixivThumbnails () { 'use strict' // Use an alternate domain (CDN) to load images // Configure this by setting `domainOverride` in userscript values let domainOverride = '' // Use custom (uploader-provided) thumbnail crops // If you enable this setting, then if the uploader has set a custom square crop on the image, it will // be used. Automatically cropped images will continue to be converted to uncropped images // Configure this by setting `allowCustom` in userscript values let allowCustom = false // Browser feature detection for CSS 4 image-set() let imageSetSupported = false let imageSetPrefix = '' if (CSS.supports('background-image', 'image-set(url("image1") 1x, url("image2") 2x)')) { imageSetSupported = true } else if (CSS.supports('background-image', '-webkit-image-set(url("image1") 1x, url("image2") 2x)')) { imageSetSupported = true imageSetPrefix = '-webkit-' } // A regular expression that matches pixiv thumbnail urls // Has 5 captures: // $1: domain name // $2: thumbnail width (optional) // $3: thumbnail height (optional) // $4: everything in the URL after the thumbnail size up to the image suffix // $5: thumbnail crop type: square (auto crop), custom (manual crop), master (no crop) // eslint-disable-next-line max-len const srcRegexp = /https?:\/\/(i[^.]*\.pximg\.net)(?:\/c\/(\d+)x(\d+)(?:_[^/]*)?)?\/(?:custom-thumb|img-master)\/(.*?)_(custom|master|square)1200.jpg/ // Look for a URL pattern for a thumbnail image in a string and return its properties // Returns null if no image found, otherwise a structure containing the domain, width, height, path. function matchThumbnail (str) { const m = str.match(srcRegexp) if (!m) { return null } let [_, domain, width, height, path, crop] = m // The 1200 size does not include size in the URL, so fill in the values here when missing width = width || 1200 height = height || 1200 if (domainOverride) { domain = domainOverride } return { domain, width, height, path, crop } } // List of image sizes and paths possible for original aspect thumbnail images // This must be in order from small to large for the image set generation to work const imageSizes = [ { size: 150, path: '/c/150x150' }, { size: 240, path: '/c/240x240' }, { size: 360, path: '/c/360x360_70' }, { size: 600, path: '/c/600x600' }, { size: 1200, path: '' } ] // Generate a list of original thumbnail images in various sizes for an image, // and determine a default image based on the display size and screen resolution function genImageSet (size, m) { if (allowCustom && m.crop === 'custom') { return imageSizes.map((imageSize) => ({ src: `https://${m.domain}${imageSize.path}/custom-thumb/${m.path}_custom1200.jpg`, scale: imageSize.size / size })) } return imageSizes.map((imageSize) => ({ src: `https://${m.domain}${imageSize.path}/img-master/${m.path}_master1200.jpg`, scale: imageSize.size / size })) } // Create a srcset= attribute on the img, with appropriate dpi scaling values // Also update the src= attribute function imgSrcset (img, size, m) { const imageSet = genImageSet(size, m) img.srcset = imageSet.map((image) => `${image.src} ${image.scale}x`).join(', ') // IMG tag src attribute is assumed to be 1x scale const defaultSrc = imageSet.find((image) => image.scale >= 1) || imageSet[imageSet.length - 1] img.src = defaultSrc.src img.style.objectFit = 'contain' if (!img.attributes.width && !img.style.width) { img.setAttribute('width', size) } if (!img.attributes.height && !img.style.height) { img.setAttribute('height', size) } } // Set up a css background-image with image-set() where supported, falling back // to a single image function cssImageSet (node, size, m) { const imageSet = genImageSet(size, m) node.style.backgroundSize = 'contain' node.style.backgroundPosition = 'center' node.style.backgroundRepeat = 'no-repeat' if (imageSetSupported) { const cssImageList = imageSet.map((image) => `url("${image.src}") ${image.scale}x`).join(', ') node.style.backgroundImage = `${imageSetPrefix}image-set(${cssImageList})` } else { const optimalSrc = imageSet.find((image) => image.scale >= window.devicePixelRatio) || imageSet[imageSet.length - 1] node.style.backgroundImage = `url("${optimalSrc.src}")` } } // Parse a CSS length value to a number of pixels. Returns NaN for other units. function cssPx (value) { if (!value.endsWith('px')) { return NaN } return +value.replace(/[^\d.-]/g, '') } function findParentSize (node) { let e = node while (e.parentElement) { let size = Math.max(e.getAttribute('width'), e.getAttribute('height')) if (size > 0) { return size } size = Math.max(cssPx(e.style.width), cssPx(e.style.height)) if (size > 0) { return size } e = e.parentElement } e = node while (e.parentElement) { const cstyle = window.getComputedStyle(e) const size = Math.max(cssPx(cstyle.width), cssPx(cstyle.height)) if (size > 0) { return size } e = e.parentElement } return 0 } function handleImg (node) { if (node.dataset.kepstinThumbnail === 'bad') { return } // Check for lazy-loaded images, which have a temporary URL // They'll be updated later when the src is set if (node.src === '' || node.src.startsWith('data:') || node.src.endsWith('transparent.gif')) { return } // Terrible hack: A few pages on pixiv create temporary IMG tags to... preload? the images, then switch // to setting a background on a DIV afterwards. This would be fine, except the temporary images have // the height/width set to 0, breaking the hidpi image selection if ( node.getAttribute('width') === '0' && node.getAttribute('height') === '0' && /^\/(?:discovery|(?:bookmark|mypixiv)_new_illust(?:_r18)?\.php)/.test(window.location.pathname) ) { // Set the width/height to the expected values node.setAttribute('width', 198) node.setAttribute('height', 198) } const m = matchThumbnail(node.src || node.srcset) if (!m) { node.dataset.kepstinThumbnail = 'bad'; return } if (node.dataset.kepstinThumbnail === m.path) { return } // Cancel image load if it's not already loaded if (!node.complete) { node.src = '' node.srcset = '' } // layout-thumbnail type don't have externally set size, but instead element size is determined // from image size. For other types we have to calculate size. let size = Math.max(m.width, m.height) if (node.matches('div._layout-thumbnail img')) { node.setAttribute('width', m.width) node.setAttribute('height', m.height) } else { const newSize = findParentSize(node) if (newSize > 16) { size = newSize } } imgSrcset(node, size, m) node.dataset.kepstinThumbnail = m.path } function handleCSSBackground (node) { if (node.dataset.kepstinThumbnail === 'bad') { return } // Check for lazy-loaded images // They'll be updated later when the background image (in style attribute) is set if ( node.classList.contains('js-lazyload') || node.classList.contains('lazyloaded') || node.classList.contains('lazyloading') ) { return } const m = matchThumbnail(node.style.backgroundImage) if (!m) { node.dataset.kepstinThumbnail = 'bad'; return } if (node.dataset.kepstinThumbnail === m.path) { return } node.style.backgroundImage = '' let size = Math.max(cssPx(node.style.width), cssPx(node.style.height)) if (!(size > 0)) { const cstyle = window.getComputedStyle(node) size = Math.max(cssPx(cstyle.width), cssPx(cstyle.height)) } if (!(size > 0)) { size = Math.max(m.width, m.height) } cssImageSet(node, size, m) node.dataset.kepstinThumbnail = m.path } function onetimeThumbnails (parentNode) { parentNode.querySelectorAll('IMG').forEach((node) => { handleImg(node) }) parentNode.querySelectorAll('DIV[style*=background-image]').forEach((node) => { handleCSSBackground(node) }) parentNode.querySelectorAll('A[style*=background-image]').forEach((node) => { handleCSSBackground(node) }) } function mutationObserverCallback (mutationList, _observer) { mutationList.forEach((mutation) => { const target = mutation.target switch (mutation.type) { case 'childList': mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return if (node.nodeName === 'IMG') { handleImg(node) } else if ((node.nodeName === 'DIV' || node.nodeName === 'A') && node.style.backgroundImage) { handleCSSBackground(node) } else { onetimeThumbnails(node) } }) break case 'attributes': if (target.nodeType !== Node.ELEMENT_NODE) break if ((target.nodeName === 'DIV' || target.nodeName === 'A') && target.style.backgroundImage) { handleCSSBackground(target) } else if (target.nodeName === 'IMG') { handleImg(target) } break // no default } }) } function addStylesheet() { if (!(window.location.host == "www.pixiv.net")) { return; } if (document.head === null) { document.addEventListener("DOMContentLoaded", addStylesheet, { once: true }); return; } let s = document.createElement("style"); s.textContent = ` div:has(>div[width][height]), div:has(>label div[width][height]), div[type="illust"] { border-radius: 0; } div[width][height]:hover img[data-kepstin-thumbnail], div[type="illust"] a:hover img { opacity: 1 !important; background-color: var(--charcoal-background1-hover); } div[radius] img[data-kepstin-thumbnail], div[type="illust"] img { border-radius: 0; background-color: var(--charcoal-background1); transition: background-color 0.2s; } div[radius]::before, div[type="illust"] a > div::before { border-radius: 0; background: transparent; box-shadow: inset 0 0 0 1px var(--charcoal-border); } `; document.head.appendChild(s); } function loadSettings () { const gmDomainOverride = GM_getValue('domainOverride') if (typeof gmDomainOverride === 'undefined') { // migrate settings domainOverride = localStorage.getItem('kepstinDomainOverride') || '' localStorage.removeItem('kepstinDomainOverride') } else { domainOverride = gmDomainOverride || '' } if (domainOverride !== gmDomainOverride) { GM_setValue('domainOverride', domainOverride) } const gmAllowCustom = GM_getValue('allowCustom') if (typeof gmAllowCustom === 'undefined') { // migrate settings allowCustom = !!localStorage.getItem('kepstinAllowCustom') localStorage.removeItem('kepstinAllowCustom') } else { allowCustom = !!gmAllowCustom } if (allowCustom !== gmAllowCustom) { GM_setValue('allowCustom', allowCustom) } } if (typeof GM_getValue !== 'undefined' && typeof GM_setValue !== 'undefined') { loadSettings() } if (typeof GM_addValueChangeListener !== 'undefined') { GM_addValueChangeListener('domainOverride', (_name, _oldValue, newValue, remote) => { if (!remote) { return } domainOverride = newValue || '' if (domainOverride !== newValue) { GM_setValue('domainOverride', domainOverride) } }) GM_addValueChangeListener('allowCustom', (_name, _oldValue, newValue, remote) => { if (!remote) { return } allowCustom = !!newValue if (allowCustom !== newValue) { GM_setValue('allowCustom', allowCustom) } }) } addStylesheet() onetimeThumbnails(document.firstElementChild) const thumbnailObserver = new MutationObserver(mutationObserverCallback) thumbnailObserver.observe(document.firstElementChild, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'src', 'style'] }) }())