/* 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']
})
}())