From 966cda2f802cd6cf08d9b73f75fc4b0e7beab625 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Tue, 18 Sep 2012 00:18:50 -0700 Subject: [PATCH] Initial stab at responsive images for screen densities. * adds $wgResponsiveImages setting, defaulting to true, to enable the feature * adds 'srcset' attribute with 1.5x and 2x URLs to image links and image thumbs * adds jquery.hidpi plugin to check pixel density and implement partial 'srcset' polyfill ** $.devicePixelRatio() returns window.devicePixelRatio, with compat fallback for IE 10 ** $().hidpi() performs a 'srcset' polyfill for browsers with no native 'srcset' support * adds mediawiki.hidpi RL script to trigger hidpi loads after main images load Note that this is a work in progress. There will be places where this doesn't yet work which output their imgs differently. If moving from a low to high-DPI screen on a MacBook Pro Retina display, you won't see images load until you reload. Confirmed basic images and thumbs in wikitext appear to work in Safari 6, Chrome 21, Firefox 18 nightly on MacBook Pro Retina display, and IE 10 in Windows 8 at 150% zoom, 200% zoom, and 140% and 180%-ratio Metro tablet sizes. Internally this is still a bit of a hack; Linker::makeImageLink and Linker::makeThumbLink explicitly ask for 1.5x and 2x scaled versions and insert their URLs, if different, into the original thumbnail object which (in default handler) outputs the srcset. This means that a number of places that handle images differently won't see the higher-resolution versions, such as and the large thumbnail on the File: description page. At some point we may wish to redo some of how the MediaHandler stuff works so that requesting a single thumbnail automatically produces the extra sizes in all circumstances. We might also consider outputting a 'srcset' or multiple src sizes in 'imageinfo' API requests, which would make ApiForeignRepo/InstantCommons more efficient. (Currently it has to make three requests for each image to get the three sizes.) Change-Id: Id80ebd07a1a9f401a2c2bfeb21aae987e5aa863b --- RELEASE-NOTES-1.21 | 1 + includes/DefaultSettings.php | 10 ++ includes/Html.php | 18 +++ includes/Linker.php | 37 ++++++ includes/OutputPage.php | 7 +- includes/media/MediaTransformOutput.php | 14 ++- resources/Resources.php | 9 ++ resources/jquery/jquery.hidpi.js | 119 ++++++++++++++++++ resources/mediawiki/mediawiki.hidpi.js | 5 + tests/parser/parserTest.inc | 2 +- tests/parser/parserTests.txt | 54 ++++---- .../phpunit/includes/parser/NewParserTest.php | 2 +- tests/qunit/QUnitTestResources.php | 2 + .../resources/jquery/jquery.hidpi.test.js | 20 +++ 14 files changed, 269 insertions(+), 31 deletions(-) create mode 100644 resources/jquery/jquery.hidpi.js create mode 100644 resources/mediawiki/mediawiki.hidpi.js create mode 100644 tests/qunit/suites/resources/jquery/jquery.hidpi.test.js diff --git a/RELEASE-NOTES-1.21 b/RELEASE-NOTES-1.21 index a1fa4caf42..7931834d50 100644 --- a/RELEASE-NOTES-1.21 +++ b/RELEASE-NOTES-1.21 @@ -19,6 +19,7 @@ production. * (bug 34876) jquery.makeCollapsible has been improved in performance. * Added ContentHandler facility to allow extensions to support other content than wikitext. See docs/contenthandler.txt for details. +* $wgResponsiveImages is added to support images on high-DPI mobile and desktop displays. === Bug fixes in 1.21 === * (bug 40353) SpecialDoubleRedirect should support interwiki redirects. diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 2e1e82f517..ae8ff58fa2 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -1088,6 +1088,16 @@ $wgThumbUpright = 0.75; */ $wgDirectoryMode = 0777; +/** + * Generate and use thumbnails suitable for screens with 1.5 and 2.0 pixel densities. + * + * This means a 320x240 use of an image on the wiki will also generate 480x360 and 640x480 + * thumbnails, output via data-src-1-5 and data-src-2-0. Runtime JavaScript switches the + * images in after loading the original low-resolution versions depending on the reported + * window.devicePixelRatio. + */ +$wgResponsiveImages = true; + /** * @name DJVU settings * @{ diff --git a/includes/Html.php b/includes/Html.php index 8cb99f55cd..a07dd4c2d3 100644 --- a/includes/Html.php +++ b/includes/Html.php @@ -942,4 +942,22 @@ class Html { return $s; } + + /** + * Generate a srcset attribute value from an array mapping pixel densities + * to URLs. Note that srcset supports width and height values as well, which + * are not used here. + * + * @param array $urls + * @return string + */ + static function srcSet( $urls ) { + $candidates = array(); + foreach( $urls as $density => $url ) { + // Image candidate syntax per current whatwg live spec, 2012-09-23: + // http://www.whatwg.org/specs/web-apps/current-work/multipage/embedded-content-1.html#attr-img-srcset + $candidates[] = "{$url} {$density}x"; + } + return implode( ", ", $candidates ); + } } diff --git a/includes/Linker.php b/includes/Linker.php index c17e2d1687..0f4516575d 100644 --- a/includes/Linker.php +++ b/includes/Linker.php @@ -676,6 +676,7 @@ class Linker { if ( !$thumb ) { $s = self::makeBrokenImageLinkObj( $title, $fp['title'], '', '', '', $time == true ); } else { + self::processResponsiveImages( $file, $thumb, $hp ); $params = array( 'alt' => $fp['alt'], 'title' => $fp['title'], @@ -796,6 +797,7 @@ class Linker { $hp['width'] = isset( $fp['upright'] ) ? 130 : 180; } $thumb = false; + $noscale = false; if ( !$exists ) { $outerWidth = $hp['width'] + 2; @@ -814,6 +816,7 @@ class Linker { } elseif ( isset( $fp['framed'] ) ) { // Use image dimensions, don't scale $thumb = $file->getUnscaledThumb( $hp ); + $noscale = true; } else { # Do not present an image bigger than the source, for bitmap-style images # This is a hack to maintain compatibility with arbitrary pre-1.10 behaviour @@ -847,6 +850,9 @@ class Linker { $s .= wfMessage( 'thumbnail_error', '' )->escaped(); $zoomIcon = ''; } else { + if ( !$noscale ) { + self::processResponsiveImages( $file, $thumb, $hp ); + } $params = array( 'alt' => $fp['alt'], 'title' => $fp['title'], @@ -873,6 +879,37 @@ class Linker { return str_replace( "\n", ' ', $s ); } + /** + * Process responsive images: add 1.5x and 2x subimages to the thumbnail, where + * applicable. + * + * @param File $file + * @param MediaOutput $thumb + * @param array $hp image parameters + */ + protected static function processResponsiveImages( $file, $thumb, $hp ) { + global $wgResponsiveImages; + if ( $wgResponsiveImages ) { + $hp15 = $hp; + $hp15['width'] = round( $hp['width'] * 1.5 ); + $hp20 = $hp; + $hp20['width'] = $hp['width'] * 2; + if ( isset( $hp['height'] ) ) { + $hp15['height'] = round( $hp['height'] * 1.5 ); + $hp20['height'] = $hp['height'] * 2; + } + + $thumb15 = $file->transform( $hp15 ); + $thumb20 = $file->transform( $hp20 ); + if ( $thumb15->url !== $thumb->url ) { + $thumb->responsiveUrls['1.5'] = $thumb15->url; + } + if ( $thumb20->url !== $thumb->url ) { + $thumb->responsiveUrls['2'] = $thumb20->url; + } + } + } + /** * Make a "broken" link to an image * diff --git a/includes/OutputPage.php b/includes/OutputPage.php index dd9c9e3f82..3578568651 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -2462,7 +2462,7 @@ $templates */ private function addDefaultModules() { global $wgIncludeLegacyJavaScript, $wgPreloadJavaScriptMwUtil, $wgUseAjax, - $wgAjaxWatch; + $wgAjaxWatch, $wgResponsiveImages; // Add base resources $this->addModules( array( @@ -2503,6 +2503,11 @@ $templates if ( $this->isArticle() && $this->getUser()->getOption( 'editondblclick' ) ) { $this->addModules( 'mediawiki.action.view.dblClickEdit' ); } + + // Support for high-density display images + if ( $wgResponsiveImages ) { + $this->addModules( 'mediawiki.hidpi' ); + } } /** diff --git a/includes/media/MediaTransformOutput.php b/includes/media/MediaTransformOutput.php index c5e4566be6..69bdc2fb21 100644 --- a/includes/media/MediaTransformOutput.php +++ b/includes/media/MediaTransformOutput.php @@ -33,6 +33,13 @@ abstract class MediaTransformOutput { var $file; var $width, $height, $url, $page, $path; + + /** + * @var array Associative array mapping optional supplementary image files + * from pixel density (eg 1.5 or 2) to additional URLs. + */ + public $responsiveUrls = array(); + protected $storagePath = false; /** @@ -324,7 +331,7 @@ class ThumbnailImage extends MediaTransformOutput { 'alt' => $alt, 'src' => $this->url, 'width' => $this->width, - 'height' => $this->height, + 'height' => $this->height ); if ( !empty( $options['valign'] ) ) { $attribs['style'] = "vertical-align: {$options['valign']}"; @@ -332,6 +339,11 @@ class ThumbnailImage extends MediaTransformOutput { if ( !empty( $options['img-class'] ) ) { $attribs['class'] = $options['img-class']; } + + // Additional densities for responsive images, if specified. + if ( !empty( $this->responsiveUrls ) ) { + $attribs['srcset'] = Html::srcSet( $this->responsiveUrls ); + } return $this->linkWrap( $linkAttribs, Xml::element( 'img', $attribs ) ); } diff --git a/resources/Resources.php b/resources/Resources.php index cccc64574c..c906143e98 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -178,6 +178,9 @@ return array( 'jquery.getAttrs' => array( 'scripts' => 'resources/jquery/jquery.getAttrs.js', ), + 'jquery.hidpi' => array( + 'scripts' => 'resources/jquery/jquery.hidpi.js', + ), 'jquery.highlightText' => array( 'scripts' => 'resources/jquery/jquery.highlightText.js', 'dependencies' => 'jquery.mwExtension', @@ -621,6 +624,12 @@ return array( 'feedback-bugnew', ), ), + 'mediawiki.hidpi' => array( + 'scripts' => 'resources/mediawiki/mediawiki.hidpi.js', + 'dependencies' => array( + 'jquery.hidpi', + ), + ), 'mediawiki.htmlform' => array( 'scripts' => 'resources/mediawiki/mediawiki.htmlform.js', ), diff --git a/resources/jquery/jquery.hidpi.js b/resources/jquery/jquery.hidpi.js new file mode 100644 index 0000000000..b7335ffe86 --- /dev/null +++ b/resources/jquery/jquery.hidpi.js @@ -0,0 +1,119 @@ +/** + * Responsive images based on 'srcset' and 'window.devicePixelRatio' emulation where needed. + * + * Call $().hidpi() on a document or part of a document to replace image srcs in that section. + * + * $.devicePixelRatio() can be used to supplement window.devicePixelRatio with support on + * some additional browsers. + */ +( function ( $ ) { + +/** + * Detect reported or approximate device pixel ratio. + * 1.0 means 1 CSS pixel is 1 hardware pixel + * 2.0 means 1 CSS pixel is 2 hardware pixels + * etc + * + * Uses window.devicePixelRatio if available, or CSS media queries on IE. + * + * @method + * @returns {number} Device pixel ratio + */ +$.devicePixelRatio = function () { + if ( window.devicePixelRatio !== undefined ) { + // Most web browsers: + // * WebKit (Safari, Chrome, Android browser, etc) + // * Opera + // * Firefox 18+ + return window.devicePixelRatio; + } else if ( window.msMatchMedia !== undefined ) { + // Windows 8 desktops / tablets, probably Windows Phone 8 + // + // IE 10 doesn't report pixel ratio directly, but we can get the + // screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for + // simplicity, but you may get different values depending on zoom + // factor, size of screen and orientation in Metro IE. + if ( window.msMatchMedia( '(min-resolution: 192dpi)' ).matches ) { + return 2; + } else if ( window.msMatchMedia( '(min-resolution: 144dpi)' ).matches ) { + return 1.5; + } else { + return 1; + } + } else { + // Legacy browsers... + // Assume 1 if unknown. + return 1; + } +}; + +/** + * Implement responsive images based on srcset attributes, if browser has no + * native srcset support. + * + * @method + * @returns {jQuery} This selection + */ +$.fn.hidpi = function () { + var $target = this, + // @todo add support for dpi media query checks on Firefox, IE + devicePixelRatio = $.devicePixelRatio(), + testImage = new Image(); + + if ( devicePixelRatio > 1 && testImage.srcset === undefined ) { + // No native srcset support. + $target.find( 'img' ).each( function () { + var $img = $( this ), + srcset = $img.attr( 'srcset' ), + match; + if ( typeof srcset === 'string' && srcset !== '' ) { + match = $.matchSrcSet( devicePixelRatio, srcset ); + if (match !== null ) { + $img.attr( 'src', match ); + } + } + }); + } + + return $target; +}; + +/** + * Match a srcset entry for the given device pixel ratio + * + * @param {number} devicePixelRatio + * @param {string} srcset + * @return {mixed} null or the matching src string + * + * Exposed for testing. + */ +$.matchSrcSet = function ( devicePixelRatio, srcset ) { + var candidates, + candidate, + bits, + src, + i, + ratioStr, + ratio, + selectedRatio = 1, + selectedSrc = null; + candidates = srcset.split( / *, */ ); + for ( i = 0; i < candidates.length; i++ ) { + candidate = candidates[i]; + bits = candidate.split( / +/ ); + src = bits[0]; + if ( bits.length > 1 && bits[1].charAt( bits[1].length - 1 ) === 'x' ) { + ratioStr = bits[1].substr( 0, bits[1].length - 1 ); + ratio = parseFloat( ratioStr ); + if ( ratio > devicePixelRatio ) { + // Too big, skip! + } else if ( ratio > selectedRatio ) { + selectedRatio = ratio; + selectedSrc = src; + } + } + } + return selectedSrc; +}; + +}( jQuery ) ); diff --git a/resources/mediawiki/mediawiki.hidpi.js b/resources/mediawiki/mediawiki.hidpi.js new file mode 100644 index 0000000000..1979573047 --- /dev/null +++ b/resources/mediawiki/mediawiki.hidpi.js @@ -0,0 +1,5 @@ +$( function() { + // Apply hidpi images on DOM-ready + // Some may have already partly preloaded at low resolution. + $( 'body' ).hidpi(); +} ); \ No newline at end of file diff --git a/tests/parser/parserTest.inc b/tests/parser/parserTest.inc index fb0c84825a..cd2289d33b 100644 --- a/tests/parser/parserTest.inc +++ b/tests/parser/parserTest.inc @@ -671,7 +671,7 @@ class ParserTest { 'wgNoFollowLinks' => true, 'wgNoFollowDomainExceptions' => array(), 'wgThumbnailScriptPath' => false, - 'wgUseImageResize' => false, + 'wgUseImageResize' => true, 'wgLocaltimezone' => 'UTC', 'wgAllowExternalImages' => true, 'wgUseTidy' => false, diff --git a/tests/parser/parserTests.txt b/tests/parser/parserTests.txt index 61cf508a57..007ae924e4 100644 --- a/tests/parser/parserTests.txt +++ b/tests/parser/parserTests.txt @@ -5457,7 +5457,7 @@ Thumbnail image with link parameter !! input [[Image:foobar.jpg|thumb|link=http://example.com/|Title]] !! result -
Title
+
Title
!! end @@ -5531,7 +5531,7 @@ Thumbnail image caption with a free URL !! input [[Image:foobar.jpg|thumb|http://example.com]] !! result - + !! end @@ -5540,7 +5540,7 @@ Thumbnail image caption with a free URL and explicit alt !! input [[Image:foobar.jpg|thumb|http://example.com|alt=Alteration]] !! result - + !! end @@ -5549,7 +5549,7 @@ BUG 1887: A ISBN with a thumbnail !! input [[Image:foobar.jpg|thumb|ISBN 1235467890]] !! result - + !! end @@ -5558,7 +5558,7 @@ BUG 1887: A RFC with a thumbnail !! input [[Image:foobar.jpg|thumb|This is RFC 12354]] !! result -
This is RFC 12354
+
This is RFC 12354
!! end @@ -5567,7 +5567,7 @@ BUG 1887: A mailto link with a thumbnail !! input [[Image:foobar.jpg|thumb|Please mailto:nobody@example.com]] !! result - + !! end @@ -5640,7 +5640,7 @@ Image caption containing another image !! input [[Image:Foobar.jpg|thumb|This is a caption with another [[Image:icon.png|image]] inside it!]] !! result -
This is a caption with another image inside it!
+
This is a caption with another image inside it!
!! end @@ -5660,7 +5660,7 @@ Bug 3090: External links other than http: in image captions !! input [[Image:Foobar.jpg|thumb|200px|This caption has [irc://example.net irc] and [https://example.com Secure] ext links in it.]] !! result -
This caption has irc and Secure ext links in it.
+
This caption has irc and Secure ext links in it.
!! end @@ -8823,19 +8823,19 @@ image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
  • -
    +

    some caption Main Page

  • -
    Foobar.jpg
    +
    Foobar.jpg
  • -
    This is a foo-bar.
    +
    This is a foo-bar.

    Blabla|blabla.

    @@ -8855,14 +8855,14 @@ File:foobar.jpg|{{Test|unamedParam|alt=param}}|alt=galleryalt !! result