/* * This file is part of the MediaWiki extension MediaViewer. * * MediaViewer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * MediaViewer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with MediaViewer. If not, see . */ const Thumbnail = require( '../model/mmv.model.Thumbnail.js' ); /** * This provider is similar to ThumbnailInfo, but instead of making an API call * to get the thumbnail URL, it tries to guess it. There are two failure modes: * - known failure: in the given situation it does not seem possible or safe to guess the URL. * It is up to the caller to obtain it by falling back to the normal provider. * - unexpected failure: we guess an URL but it does not work. The current implementation is * conservative so at least on WMF wikis this probably won't happen, but should be reckoned * with anyway. On other wikis (especially ones which do not generate thumbnails on demand * via the 404 handler) this could be more frequent. Again, it is the caller's responsibility * to handle this by detecting image loading errors and falling back to the normal provider. */ class GuessedThumbnailInfo { /** * File extensions which are vector types (as opposed to bitmap). * Thumbnails of vector types can be larger than the original file. * * @return {Object.} */ get vectorExtensions() { return { svg: 1 }; } /** * File extensions which can be displayed in the browser. * Other file types need to be thumbnailed even if the size of the original file would be right. * * @return {Object.} */ get displayableExtensions() { return { png: 1, jpg: 1, jpeg: 1, gif: 1 }; } /** * Try to guess the thumbnailinfo for a thumbnail without doing an API request. * An existing thumbnail URL is required. * * There is no guarantee this function will be successful - in some cases, it is impossible * to guess how the URL would look. If that's the case, the promise just rejects. * * @param {mw.Title} file * @param {string} sampleUrl a thumbnail URL for the same file (but with different size). * @param {number} width thumbnail width in pixels * @param {number} originalWidth width of original image in pixels * @param {number} originalHeight height of original image in pixels * @return {jQuery.Promise.} */ get( file, sampleUrl, width, originalWidth, originalHeight ) { const url = this.getUrl( file, sampleUrl, width, originalWidth ); if ( url ) { return $.Deferred().resolve( new Thumbnail( url, this.guessWidth( file, width, originalWidth ), this.guessHeight( file, width, originalWidth, originalHeight ) ) ); } else { return $.Deferred().reject( 'Could not guess thumbnail URL' ); } } /** * Try to guess the URL of a thumbnail without doing an API request. * See #get(). * * @param {mw.Title} file * @param {string} sampleUrl a thumbnail URL for the same file (but with different size) * @param {number} width thumbnail width in pixels * @param {number} originalWidth width of original image in pixels * @return {string|undefined} a thumbnail URL or nothing */ getUrl( file, sampleUrl, width, originalWidth ) { const needsFullSize = this.needsOriginal( file, width, originalWidth ); const sampleIsFullSize = this.isFullSizeUrl( sampleUrl, file ); if ( sampleIsFullSize && needsFullSize ) { // sample thumbnail uses full size, and we need full size as well - the sample URL // happens to be just the right one for us return sampleUrl; } else if ( !sampleIsFullSize && !needsFullSize ) { // need to convert a scaled thumbnail URL to another scaled thumbnail URL return this.replaceSize( file, sampleUrl, width ); } else if ( !sampleIsFullSize && needsFullSize ) { if ( this.canBeDisplayedInBrowser( file ) ) { // the size requested is larger than the original - we need to return an URL // to the original file instead return this.guessFullUrl( file, sampleUrl ); } else { // the size requested is larger than the original, but this file type cannot // be displayed by all browsers, so needs to be thumbnailed anyway, // but the thumbnail still cannot be larger than the original file return this.replaceSize( file, sampleUrl, originalWidth ); } } else { // sampleIsFullSize && !needsOriginal return this.guessThumbUrl( file, sampleUrl, width ); } } /** * True if the original image needs to be used as a thumbnail. * * @protected * @param {mw.Title} file * @param {number} width thumbnail width in pixels * @param {number} originalWidth width of original image in pixels * @return {boolean} */ needsOriginal( file, width, originalWidth ) { return width >= originalWidth && !this.canHaveLargerThumbnailThanOriginal( file ); } /** * Checks if a given thumbnail URL is full-size (the original image) or scaled * * @protected * @param {string} url a thumbnail URL * @param {mw.Title} file * @return {boolean} */ isFullSizeUrl( url, file ) { return !this.obscureFilename( url, file ).match( '/thumb/' ); } /** * Removes the filename in a reversible way. This is useful because the filename can be nearly * anything and could cause false positives when looking for patterns. * * @protected * @param {string} url a thumbnail URL * @param {mw.Title} file * @return {string} thumbnail URL with occurrences of the filename replaced by `` */ obscureFilename( url, file ) { // corresponds to File::getUrlRel() which uses rawurlencode() const filenameInUrl = mw.util.rawurlencode( file.getMain() ); // In the URL to the original file the filename occurs once. In a thumbnail URL it usually // occurs twice, but can occur once if it is too short. We replace twice, can't hurt. return url.replace( filenameInUrl, '' ).replace( filenameInUrl, '' ); } /** * Undoes #obscureFilename(). * * @protected * @param {string} url a thumbnail URL (with obscured filename) * @param {mw.Title} file * @return {string} original thumbnail URL */ restoreFilename( url, file ) { // corresponds to File::getUrlRel() which uses rawurlencode() const filenameInUrl = mw.util.rawurlencode( file.getMain() ); // <> cannot be used in titles, so this is safe return url.replace( '', filenameInUrl ).replace( '', filenameInUrl ); } /** * True if the file is of a type for which the thumbnail can be scaled beyond the original size. * * @protected * @param {mw.Title} file * @return {boolean} */ canHaveLargerThumbnailThanOriginal( file ) { return ( file.getExtension().toLowerCase() in this.vectorExtensions ); } /** * True if the file type can be displayed in most browsers, false if it needs thumbnailing * * @protected * @param {mw.Title} file * @return {boolean} */ canBeDisplayedInBrowser( file ) { return ( file.getExtension().toLowerCase() in this.displayableExtensions ); } /** * Guess what will be the width of the thumbnail. (Thumbnails for most file formats cannot be * larger than the original file so this might be smaller than the requested width.) * * @protected * @param {mw.Title} file * @param {number} width thumbnail width in pixels * @param {number} originalWidth width of original image in pixels * @return {number} guessed width */ guessWidth( file, width, originalWidth ) { if ( width >= originalWidth && !this.canHaveLargerThumbnailThanOriginal( file ) ) { return originalWidth; } else { return width; } } /** * Guess what will be the height of the thumbnail, given its width. * * @protected * @param {mw.Title} file * @param {number} width thumbnail width in pixels * @param {number} originalWidth width of original image in pixels * @param {number} originalHeight height of original image in pixels * @return {number} guessed height */ guessHeight( file, width, originalWidth, originalHeight ) { if ( width >= originalWidth && !this.canHaveLargerThumbnailThanOriginal( file ) ) { return originalHeight; } else { // might be off 1px due to rounding (we don't know what exact scaling method the // backend uses) but that should not cause any issues return Math.round( width * ( originalHeight / originalWidth ) ); } } /** * Given a thumbnail URL with a wrong size, returns one with the right size. * * @protected * @param {mw.Title} file * @param {string} sampleUrl a thumbnail URL for the same file (but with different size) * @param {number} width thumbnail width in pixels * @return {string|undefined} thumbnail URL with the correct size */ replaceSize( file, sampleUrl, width ) { let url = this.obscureFilename( sampleUrl, file ); const sizeRegexp = /\b\d{1,5}px\b/; // this should never happen, but let's play it safe - returning the sample URL and believing // it is the resized one would be bad. Returning a wrong filename is not catastrophic // as long as we return a non-working wrong filename, which would not be the case here. if ( !url.match( sizeRegexp ) ) { return undefined; } // we are assuming here that the other thumbnail parameters do not look like a size url = url.replace( sizeRegexp, `${ width }px` ); return this.restoreFilename( url, file ); } /** * Try to guess the original URL to the file, from a thumb URL. * * @protected * @param {mw.Title} file * @param {string} thumbnailUrl * @return {string} URL of the original file */ guessFullUrl( file, thumbnailUrl ) { let url = this.obscureFilename( thumbnailUrl, file ); if ( url === thumbnailUrl ) { // Did not find the filename, maybe due to URL encoding issues. Bail out. return undefined; } // this depends on some config settings, but will work with default or WMF settings. url = url.replace( /.*/, '' ); url = url.replace( '/thumb', '' ); return this.restoreFilename( url, file ); } /** * Hardest version: try to guess thumbnail URL from original * * @protected * @param {mw.Title} file * @param {string} originalUrl URL for the original file * @param {number} width thumbnail width in pixels * @return {string|undefined} thumbnail URL */ guessThumbUrl() { // Not implemented. This can be very complicated (the thumbnail might have other // parameters than the size, which are impossible to guess, might be converted to some // other format, might have a special shortened format depending on the length of the // filename) and it is unlikely to be useful - it would be only called when we need // a thumbnail that is smaller than the sample (the thumbnail which is already on the page). return undefined; } } module.exports = GuessedThumbnailInfo;