Move ArrayUtils into libs/ as there is nothing tying it to MediaWiki
[lhc/web/wiklou.git] / includes / libs / CSSMin.php
index dcaa685..6eb5258 100644 (file)
@@ -38,6 +38,7 @@ class CSSMin {
         * which when base64 encoded will result in a 1/3 increase in size.
         */
        const EMBED_SIZE_LIMIT = 24576;
+       const DATA_URI_SIZE_LIMIT = 32768;
        const URL_REGEX = 'url\(\s*[\'"]?(?P<file>[^\?\)\'"]*?)(?P<query>\?[^\)\'"]*?|)[\'"]?\s*\)';
        const EMBED_REGEX = '\/\*\s*\@embed\s*\*\/';
        const COMMENT_REGEX = '\/\*.*?\*\/';
@@ -100,10 +101,11 @@ class CSSMin {
        }
 
        /**
-        * Encode an image file as a base64 data URI.
-        * If the image file has a suitable MIME type and size, encode it as a
-        * base64 data URI. Return false if the image type is unfamiliar or exceeds
-        * the size limit.
+        * Encode an image file as a data URI.
+        *
+        * If the image file has a suitable MIME type and size, encode it as a data URI, base64-encoded
+        * for binary files or just percent-encoded otherwise. Return false if the image type is
+        * unfamiliar or file exceeds the size limit.
         *
         * @param string $file Image file to encode.
         * @param string|null $type File's MIME type or null. If null, CSSMin will
@@ -111,7 +113,7 @@ class CSSMin {
         * @param int|bool $sizeLimit If the size of the target file is greater than
         *     this value, decline to encode the image file and return false
         *     instead. If $sizeLimit is false, no limit is enforced.
-        * @return string|bool: Image contents encoded as a data URI or false.
+        * @return string|bool Image contents encoded as a data URI or false.
         */
        public static function encodeImageAsDataURI( $file, $type = null,
                $sizeLimit = self::EMBED_SIZE_LIMIT
@@ -125,8 +127,23 @@ class CSSMin {
                if ( !$type ) {
                        return false;
                }
-               $data = base64_encode( file_get_contents( $file ) );
-               return 'data:' . $type . ';base64,' . $data;
+
+               $contents = file_get_contents( $file );
+               // Only whitespace and printable ASCII characters
+               $isText = (bool)preg_match( '/^[\r\n\t\x20-\x7e]+$/', $contents );
+
+               if ( $isText ) {
+                       // Do not base64-encode non-binary files (sane SVGs), unless that'd exceed URI length limit.
+                       // (This often produces longer URLs, but they compress better, yielding a net smaller size.)
+                       $uri = 'data:' . $type . ',' . rawurlencode( $contents );
+                       if ( strlen( $uri ) >= self::DATA_URI_SIZE_LIMIT ) {
+                               $uri = 'data:' . $type . ';base64,' . base64_encode( $contents );
+                       }
+               } else {
+                       $uri = 'data:' . $type . ';base64,' . base64_encode( $contents );
+               }
+
+               return $uri;
        }
 
        /**
@@ -200,10 +217,9 @@ class CSSMin {
                        $remote = substr( $remote, 0, -1 );
                }
 
-               // Replace all comments by a placeholder so they will not interfere
-               // with the remapping
-               // Warning: This will also catch on anything looking like the start of
-               // a comment between quotation marks (e.g. "foo /* bar").
+               // Replace all comments by a placeholder so they will not interfere with the remapping.
+               // Warning: This will also catch on anything looking like the start of a comment between
+               // quotation marks (e.g. "foo /* bar").
                $comments = array();
                $placeholder = uniqid( '', true );
 
@@ -226,12 +242,13 @@ class CSSMin {
 
                $source = preg_replace_callback(
                        $pattern,
-                       function ( $matchOuter ) use ( $local, $remote, $embedData ) {
+                       function ( $matchOuter ) use ( $local, $remote, $embedData, $placeholder ) {
                                $rule = $matchOuter[0];
 
-                               // Check for global @embed comment and remove it
+                               // Check for global @embed comment and remove it. Allow other comments to be present
+                               // before @embed (they have been replaced with placeholders at this point).
                                $embedAll = false;
-                               $rule = preg_replace( '/^(\s*)' . CSSMin::EMBED_REGEX . '\s*/', '$1', $rule, 1, $embedAll );
+                               $rule = preg_replace( '/^((?:\s+|' . $placeholder . '(\d+)x)*)' . CSSMin::EMBED_REGEX . '\s*/', '$1', $rule, 1, $embedAll );
 
                                // Build two versions of current rule: with remapped URLs
                                // and with embedded data: URIs (where possible).
@@ -248,9 +265,12 @@ class CSSMin {
                                );
 
                                if ( $embedData ) {
+                                       // Remember the occurring MIME types to avoid fallbacks when embedding some files.
+                                       $mimeTypes = array();
+
                                        $ruleWithEmbedded = preg_replace_callback(
                                                $pattern,
-                                               function ( $match ) use ( $embedAll, $local, $remote ) {
+                                               function ( $match ) use ( $embedAll, $local, $remote, &$mimeTypes ) {
                                                        $embed = $embedAll || $match['embed'];
                                                        $embedded = CSSMin::remapOne(
                                                                $match['file'],
@@ -260,21 +280,35 @@ class CSSMin {
                                                                $embed
                                                        );
 
+                                                       $url = $match['file'] . $match['query'];
+                                                       $file = $local . $match['file'];
+                                                       if (
+                                                               !CSSMin::isRemoteUrl( $url ) && !CSSMin::isLocalUrl( $url )
+                                                               && file_exists( $file )
+                                                       ) {
+                                                               $mimeTypes[ CSSMin::getMimeType( $file ) ] = true;
+                                                       }
+
                                                        return CSSMin::buildUrlValue( $embedded );
                                                },
                                                $rule
                                        );
+
+                                       // Are all referenced images SVGs?
+                                       $needsEmbedFallback = $mimeTypes !== array( 'image/svg+xml' => true );
                                }
 
-                               if ( $embedData && $ruleWithEmbedded !== $ruleWithRemapped ) {
-                                       // Build 2 CSS properties; one which uses a base64 encoded data URI in place
-                                       // of the @embed comment to try and retain line-number integrity, and the
-                                       // other with a remapped an versioned URL and an Internet Explorer hack
+                               if ( !$embedData || $ruleWithEmbedded === $ruleWithRemapped ) {
+                                       // We're not embedding anything, or we tried to but the file is not embeddable
+                                       return $ruleWithRemapped;
+                               } elseif ( $embedData && $needsEmbedFallback ) {
+                                       // Build 2 CSS properties; one which uses a data URI in place of the @embed comment, and
+                                       // the other with a remapped and versioned URL with an Internet Explorer 6 and 7 hack
                                        // making it ignored in all browsers that support data URIs
                                        return "$ruleWithEmbedded;$ruleWithRemapped!ie";
                                } else {
-                                       // No reason to repeat twice
-                                       return $ruleWithRemapped;
+                                       // Look ma, no fallbacks! This is for files which IE 6 and 7 don't support anyway: SVG.
+                                       return $ruleWithEmbedded;
                                }
                        }, $source );
 
@@ -288,6 +322,34 @@ class CSSMin {
 
        }
 
+       /**
+        * Is this CSS rule referencing a remote URL?
+        *
+        * @private Until we require PHP 5.5 and we can access self:: from closures.
+        * @param string $maybeUrl
+        * @return bool
+        */
+       public static function isRemoteUrl( $maybeUrl ) {
+               if ( substr( $maybeUrl, 0, 2 ) === '//' || parse_url( $maybeUrl, PHP_URL_SCHEME ) ) {
+                       return true;
+               }
+               return false;
+       }
+
+       /**
+        * Is this CSS rule referencing a local URL?
+        *
+        * @private Until we require PHP 5.5 and we can access self:: from closures.
+        * @param string $maybeUrl
+        * @return bool
+        */
+       public static function isLocalUrl( $maybeUrl ) {
+               if ( !self::isRemoteUrl( $maybeUrl ) && $maybeUrl !== '' && $maybeUrl[0] === '/' ) {
+                       return true;
+               }
+               return false;
+       }
+
        /**
         * Remap or embed a CSS URL path.
         *
@@ -302,22 +364,16 @@ class CSSMin {
                // The full URL possibly with query, as passed to the 'url()' value in CSS
                $url = $file . $query;
 
-               // Skip fully-qualified and protocol-relative URLs and data URIs
-               if ( substr( $url, 0, 2 ) === '//' || parse_url( $url, PHP_URL_SCHEME ) ) {
-                       return $url;
+               // Expand local URLs with absolute paths like /w/index.php to possibly protocol-relative URL, if
+               // wfExpandUrl() is available. (This will not be the case if we're running outside of MW.)
+               if ( self::isLocalUrl( $url ) && function_exists( 'wfExpandUrl' ) ) {
+                       return wfExpandUrl( $url, PROTO_RELATIVE );
                }
 
-               // URLs with absolute paths like /w/index.php need to be expanded
-               // to absolute URLs but otherwise left alone
-               if ( $url !== '' && $url[0] === '/' ) {
-                       // Replace the file path with an expanded (possibly protocol-relative) URL
-                       // ...but only if wfExpandUrl() is even available.
-                       // This will not be the case if we're running outside of MW
-                       if ( function_exists( 'wfExpandUrl' ) ) {
-                               return wfExpandUrl( $url, PROTO_RELATIVE );
-                       } else {
-                               return $url;
-                       }
+               // Pass thru fully-qualified and protocol-relative URLs and data URIs, as well as local URLs if
+               // we can't expand them.
+               if ( self::isRemoteUrl( $url ) || self::isLocalUrl( $url ) ) {
+                       return $url;
                }
 
                if ( $local === false ) {