├── .gitignore ├── composer.json ├── LICENSE ├── README.md ├── function.combine.php └── minify ├── JSmin.php └── CSSmin.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "name": "dead23angel/smarty-combine", 4 | "description": "Combine and minify many JS or CSS to one file.", 5 | "license": "MIT", 6 | "keywords": [ 7 | "smarty", 8 | "minify" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Vital Smereka", 13 | "homepage": "https://github.com/2nlin3", 14 | "role": "Developer" 15 | }, 16 | { 17 | "name": "Gorochov Ivan", 18 | "homepage": "https://github.com/dead23angel", 19 | "role": "Developer" 20 | }, 21 | { 22 | "name": "Leif Neland", 23 | "homepage": "https://github.com/leifnel", 24 | "role": "Developer" 25 | } 26 | ], 27 | "require": { 28 | "php": ">=5.6.40", 29 | "smarty/smarty": "~3.1|~4.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Philipp Tkachev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Smarty Combine 2 | ============== 3 | 4 | Combine plugin allows concatenating several js or css files into one. 5 | It can be useful for big projects with a lot of several small CSS and JS files. 6 | 7 | ### Usage examples 8 | 9 | **Template inline example Smarty 3 or 4.1.0** 10 | 11 | ```{combine input=array('/bm.js','/bm2.js') output='/cache/big.js' use_true_path=false age='30' debug=false}``` 12 | 13 | **Smarty 2 example** 14 | 15 | **PHP code** 16 | 17 | ```$js_filelist = array('/js/core.js','/js/slideviewer.js');``` 18 | 19 | ```$smarty_object->assign('js_files', $js_filelist);``` 20 | 21 | **Template code** 22 | 23 | ```{combine input=$js_files output='/cache/big.js' use_true_path=false age='30' debug=false}``` 24 | 25 | **The plugin has 4 parameters:** 26 | * **input** - must be an array, containing list with absolute pathes to files. In Smarty 3 it can be inline array, for Smarty 2 you will need to pass from yours controller a variable, which will contains this array. 27 | * **output** - absolute path to output file. Directory must be writable to www daemon (Usually chmod 777 resolve this problem :) 28 | * **use_true_path** - value is a boolean. If it is set to true the plugin will use the paths of the asset files as they are but if set to false it will assume the files have relative paths. By default it is set to false. You can omit this parameter. 29 | * **age** - value of seconds between checks when original files were changed. By default it is 3600 - one hour. You can omit this parameter. 30 | * **debug** - parameter in the value of TRUE, disable compilation useful for debugging when developing a site.. By default it is FALSE. You can omit this parameter. 31 | 32 | Basedir of css-file is added to relative paths in url() 33 | 34 | A semicolon is added at the end of .js-files, as it is not needed at the end 35 | of a file, but is needed between concatenated files. 36 | -------------------------------------------------------------------------------- /function.combine.php: -------------------------------------------------------------------------------- 1 | 13 | * Name: combine
14 | * Date: September 5, 2015 15 | * Purpose: Combine content from several JS or CSS files into one 16 | * Input: string to count 17 | * Example: {combine input=$array_of_files_to_combine output=$path_to_output_file use_true_path=true age=$seconds_to_try_recombine_file} 18 | * 19 | * @author Gorochov Ivan 20 | * @author Vital Smereka 21 | * @version 1.4 22 | * @param array 23 | * @param string 24 | * @param int 25 | * @return string 26 | */ 27 | 28 | function smarty_function_combine($params, &$smarty) 29 | { 30 | require_once dirname(__FILE__) . '/minify/JSmin.php'; 31 | require_once dirname(__FILE__) . '/minify/CSSmin.php'; 32 | 33 | /** 34 | * Build combined file 35 | * 36 | * @param array $params 37 | */ 38 | if ( ! function_exists('smarty_build_combine')) { 39 | function smarty_build_combine($params, $skip_out_for_shutdown = false) 40 | { 41 | $filelist = array(); 42 | $lastest_mtime = 0; 43 | 44 | foreach ($params['input'] as $item) { 45 | $mtime = filemtime($params['file_path'] . $item); 46 | $lastest_mtime = max($lastest_mtime, $mtime); 47 | $filelist[] = array('name' => $item, 'time' => $mtime); 48 | } 49 | 50 | if ($params['debug'] === true) { 51 | $output_filename = ''; 52 | foreach ($filelist as $file) { 53 | if ($params['type'] == 'js') { 54 | $output_filename .= '' . "\n"; 55 | } elseif ($params['type'] == 'css') { 56 | $output_filename .= '' . "\n"; 57 | } 58 | } 59 | 60 | echo $output_filename; 61 | return; 62 | } 63 | 64 | $last_cmtime = 0; 65 | 66 | if (file_exists($params['file_path'] . $params['cache_file_name'])) { 67 | $last_cmtime = filemtime($params['file_path'] . $params['cache_file_name']); 68 | } 69 | 70 | if ($lastest_mtime > $last_cmtime) { 71 | $glob_mask = preg_replace('/\.(js|css)$/i', '_*.$1', $params['output']); 72 | $files_to_cleanup = glob($params['file_path'] . $glob_mask); 73 | 74 | foreach ($files_to_cleanup as $cfile) { 75 | if (is_file($cfile) && file_exists($cfile)) { 76 | unlink($cfile); 77 | } 78 | } 79 | 80 | $output_filename = preg_replace('/\.(js|css)$/i', date('_YmdHis.', $lastest_mtime) . '$1', $params['output']); 81 | 82 | $dirname = dirname($params['file_path'] . $output_filename); 83 | 84 | if ( ! is_dir($dirname)) { 85 | mkdir($dirname, 0755, true); 86 | } 87 | 88 | $sleep = checkWritabeFile($params['file_path'] . $output_filename); 89 | 90 | $fh = fopen($params['file_path'] . $output_filename, 'w'); 91 | 92 | if (flock($fh, LOCK_EX)) { 93 | foreach ($filelist as $file) { 94 | $min = ''; 95 | 96 | $dirname = dirname(str_replace($_SERVER['DOCUMENT_ROOT'],'',$params['file_path'] . $file['name'])); 97 | 98 | if ($params['type'] == 'js') { 99 | $min = JSMin::minify(file_get_contents($params['file_path'] . $file['name'])) . ";" ; 100 | } elseif ($params['type'] == 'css') { 101 | $min = CSSMin::minify(preg_replace('/url\\(((?>["\']?))(?!(\\/|http(s)?:|data:|#))(.*?)\\1\\)/', 'url("' . $dirname . '/$4")', file_get_contents($params['file_path'] . $file['name']))); 102 | } else { 103 | fputs($fh, PHP_EOL . PHP_EOL . '/* ' . $file['name'] . ' @ ' . date('c', $file['time']) . ' */' . PHP_EOL . PHP_EOL); 104 | $min = file_get_contents($params['file_path'] . $file['name']); 105 | } 106 | 107 | fputs($fh, $min); 108 | } 109 | 110 | flock($fh, LOCK_UN); 111 | file_put_contents($params['file_path'] . $params['cache_file_name'], $lastest_mtime, LOCK_EX); 112 | } 113 | 114 | fclose($fh); 115 | clearstatcache(); 116 | } 117 | 118 | touch($params['file_path'] . $params['cache_file_name'], $lastest_mtime); 119 | 120 | if(!$skip_out_for_shutdown){ 121 | smarty_print_out($params); 122 | } 123 | } 124 | } 125 | 126 | /** 127 | * Print filename 128 | * 129 | * @param string $params 130 | */ 131 | if ( ! function_exists('smarty_print_out')) { 132 | function smarty_print_out($params) 133 | { 134 | $mtime = 0; 135 | 136 | if (file_exists($params['file_path'] . $params['cache_file_name'])) { 137 | $mtime = filemtime($params['file_path'] . $params['cache_file_name']); 138 | } 139 | 140 | $output_filename = preg_replace('/\.(js|css)$/i', date('_YmdHis.', $mtime) . '$1', $params['output']); 141 | 142 | if ($params['type'] == 'js') { 143 | echo ''; 144 | } elseif ($params['type'] == 'css') { 145 | echo ''; 146 | } else { 147 | echo $output_filename; 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * This function gets the base url for the project where this plugin is used 154 | * If this plugin is used within Code Igniter, the base_url() would have already been defined 155 | */ 156 | if ( ! function_exists('base_url')) { 157 | function base_url(){ 158 | 159 | return sprintf( 160 | "%s://%s%s", 161 | isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' ? 'https' : 'http', 162 | $_SERVER['HTTP_HOST'], 163 | rtrim(dirname($_SERVER['PHP_SELF']), '/\\') 164 | ); 165 | } 166 | } 167 | 168 | // The new 'use_true_path' option that tells this plugin to use the path to the files as it is 169 | if ( isset($params['use_true_path']) && !is_bool($params['use_true_path'])) { 170 | trigger_error('use_true_path must be boolean', E_USER_NOTICE); 171 | return; 172 | } 173 | 174 | if ( ! isset($params['use_true_path'])) { 175 | $params['use_true_path'] = false; 176 | } 177 | 178 | // use the relative path or the true path of the file based on the 'use_true_path' option passed in 179 | $params['file_path'] = ($params['use_true_path']) ? '' : getenv('DOCUMENT_ROOT'); 180 | 181 | 182 | if ( ! isset($params['input'])) { 183 | trigger_error('input cannot be empty', E_USER_NOTICE); 184 | return; 185 | } 186 | 187 | if ( ! is_array($params['input']) || count($params['input']) < 1) { 188 | trigger_error('input must be array and have one item at least', E_USER_NOTICE); 189 | return; 190 | } 191 | 192 | foreach ($params['input'] as $file) { 193 | if ( ! file_exists($params['file_path'] . $file)) { 194 | trigger_error('File ' . $params['file_path'] . $file . ' does not exist!', E_USER_WARNING); 195 | return; 196 | } 197 | 198 | $ext = pathinfo($file, PATHINFO_EXTENSION); 199 | 200 | if ( ! in_array($ext, array('js', 'css'))) { 201 | trigger_error('all input files must have js or css extension', E_USER_NOTICE); 202 | return; 203 | } 204 | 205 | $files_extensions[] = $ext; 206 | } 207 | 208 | if (count(array_unique($files_extensions)) > 1) { 209 | trigger_error('all input files must have the same extension', E_USER_NOTICE); 210 | return; 211 | } 212 | 213 | $params['type'] = $ext; 214 | 215 | if ( ! isset($params['output'])) { 216 | $params['output'] = dirname($params['input'][0]) . '/combined.' . $ext; 217 | } 218 | 219 | if ( ! isset($params['age'])) { 220 | $params['age'] = 3600; 221 | } 222 | 223 | if ( ! isset($params['cache_file_name'])) { 224 | $params['cache_file_name'] = $params['output'] . '.cache'; 225 | } 226 | 227 | if ( ! isset($params['debug'])) { 228 | $params['debug'] = false; 229 | } 230 | 231 | /** Build combine in background fastcgi_finish_request() */ 232 | if ( ! function_exists('build_cache_combine')) { 233 | function build_cache_combine($params){ 234 | register_shutdown_function(function($params){ 235 | ignore_user_abort(true); 236 | 237 | if (function_exists('fastcgi_finish_request')) { 238 | fastcgi_finish_request(); 239 | } 240 | 241 | smarty_build_combine($params, true); 242 | }, $params); 243 | } 244 | } 245 | 246 | $file_cache_exists = file_exists($params['file_path'] . $params['cache_file_name']); 247 | 248 | $cache_mtime = $file_cache_exists ? filemtime($params['file_path'] . $params['cache_file_name']) : 0; 249 | 250 | if ($params['debug'] === true || !$file_cache_exists) { 251 | $time = time(); 252 | 253 | if($cache_mtime + $params['age'] < $time) { 254 | $filelist = array(); 255 | 256 | foreach ($params['input'] as $item) { 257 | $filelist[] = ['name' => $item]; 258 | } 259 | 260 | $out = ''; 261 | foreach ($filelist as $file) { 262 | if ($params['type'] == 'js') { 263 | $out .= '' . "\n"; 264 | } elseif ($params['type'] == 'css') { 265 | $out .= '' . "\n"; 266 | } 267 | } 268 | 269 | echo $out; 270 | 271 | //smarty_build_combine($params); 272 | register_shutdown_function('build_cache_combine', $params); 273 | 274 | return; 275 | } 276 | } 277 | 278 | smarty_print_out($params); 279 | } 280 | 281 | function checkWritabeFile($file) 282 | { 283 | $i = 0; 284 | 285 | while(!is_writable($file)) 286 | { 287 | $i++; 288 | 289 | if($i > 5) 290 | { 291 | break; 292 | } 293 | 294 | sleep(rand(0,2)); 295 | } 296 | 297 | return $i; 298 | } 299 | -------------------------------------------------------------------------------- /minify/JSmin.php: -------------------------------------------------------------------------------- 1 | 6 | * $minifiedJs = JSMin::minify($js); 7 | * 8 | * 9 | * This is a modified port of jsmin.c. Improvements: 10 | * 11 | * Does not choke on some regexp literals containing quote characters. E.g. /'/ 12 | * 13 | * Spaces are preserved after some add/sub operators, so they are not mistakenly 14 | * converted to post-inc/dec. E.g. a + ++b -> a+ ++b 15 | * 16 | * Preserves multi-line comments that begin with /*! 17 | * 18 | * PHP 5 or higher is required. 19 | * 20 | * Permission is hereby granted to use this version of the library under the 21 | * same terms as jsmin.c, which has the following license: 22 | * 23 | * -- 24 | * Copyright (c) 2002 Douglas Crockford (www.crockford.com) 25 | * 26 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 27 | * this software and associated documentation files (the "Software"), to deal in 28 | * the Software without restriction, including without limitation the rights to 29 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 30 | * of the Software, and to permit persons to whom the Software is furnished to do 31 | * so, subject to the following conditions: 32 | * 33 | * The above copyright notice and this permission notice shall be included in all 34 | * copies or substantial portions of the Software. 35 | * 36 | * The Software shall be used for Good, not Evil. 37 | * 38 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 39 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 40 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 41 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 42 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 43 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 44 | * SOFTWARE. 45 | * -- 46 | * 47 | * @package JSMin 48 | * @author Ryan Grove (PHP port) 49 | * @author Steve Clay (modifications + cleanup) 50 | * @author Andrea Giammarchi (spaceBeforeRegExp) 51 | * @copyright 2002 Douglas Crockford (jsmin.c) 52 | * @copyright 2008 Ryan Grove (PHP port) 53 | * @license http://opensource.org/licenses/mit-license.php MIT License 54 | * @link http://code.google.com/p/jsmin-php/ 55 | */ 56 | 57 | class JSMin 58 | { 59 | const ORD_LF = 10; 60 | const ORD_SPACE = 32; 61 | const ACTION_KEEP_A = 1; 62 | const ACTION_DELETE_A = 2; 63 | const ACTION_DELETE_A_B = 3; 64 | 65 | protected $a = "\n"; 66 | protected $b = ''; 67 | protected $input = ''; 68 | protected $inputIndex = 0; 69 | protected $inputLength = 0; 70 | protected $lookAhead = null; 71 | protected $output = ''; 72 | protected $lastByteOut = ''; 73 | protected $keptComment = ''; 74 | 75 | /** 76 | * Minify Javascript. 77 | * 78 | * @param string $js Javascript to be minified 79 | * 80 | * @return string 81 | */ 82 | public static function minify($js) 83 | { 84 | $jsmin = new JSMin($js); 85 | return $jsmin->min(); 86 | } 87 | 88 | /** 89 | * @param string $input 90 | */ 91 | public function __construct($input) 92 | { 93 | $this->input = $input; 94 | } 95 | 96 | /** 97 | * Perform minification, return result 98 | * 99 | * @return string 100 | */ 101 | public function min() 102 | { 103 | if ($this->output !== '') { // min already run 104 | return $this->output; 105 | } 106 | 107 | $mbIntEnc = null; 108 | if (function_exists('mb_strlen') && ((int)ini_get('mbstring.func_overload') & 2)) { 109 | $mbIntEnc = mb_internal_encoding(); 110 | mb_internal_encoding('8bit'); 111 | } 112 | $this->input = str_replace("\r\n", "\n", $this->input); 113 | $this->inputLength = strlen($this->input); 114 | 115 | $this->action(self::ACTION_DELETE_A_B); 116 | 117 | while ($this->a !== null) { 118 | // determine next command 119 | $command = self::ACTION_KEEP_A; // default 120 | if ($this->a === ' ') { 121 | if (($this->lastByteOut === '+' || $this->lastByteOut === '-') 122 | && ($this->b === $this->lastByteOut)) { 123 | // Don't delete this space. If we do, the addition/subtraction 124 | // could be parsed as a post-increment 125 | } elseif (! $this->isAlphaNum($this->b)) { 126 | $command = self::ACTION_DELETE_A; 127 | } 128 | } elseif ($this->a === "\n") { 129 | if ($this->b === ' ') { 130 | $command = self::ACTION_DELETE_A_B; 131 | 132 | // in case of mbstring.func_overload & 2, must check for null b, 133 | // otherwise mb_strpos will give WARNING 134 | } elseif ($this->b === null 135 | || (false === strpos('{[(+-!~', $this->b) 136 | && ! $this->isAlphaNum($this->b))) { 137 | $command = self::ACTION_DELETE_A; 138 | } 139 | } elseif (! $this->isAlphaNum($this->a)) { 140 | if ($this->b === ' ' 141 | || ($this->b === "\n" 142 | && (false === strpos('}])+-"\'', $this->a)))) { 143 | $command = self::ACTION_DELETE_A_B; 144 | } 145 | } 146 | $this->action($command); 147 | } 148 | $this->output = trim($this->output); 149 | 150 | if ($mbIntEnc !== null) { 151 | mb_internal_encoding($mbIntEnc); 152 | } 153 | return $this->output; 154 | } 155 | 156 | /** 157 | * ACTION_KEEP_A = Output A. Copy B to A. Get the next B. 158 | * ACTION_DELETE_A = Copy B to A. Get the next B. 159 | * ACTION_DELETE_A_B = Get the next B. 160 | * 161 | * @param int $command 162 | * @throws JSMin_UnterminatedRegExpException|JSMin_UnterminatedStringException 163 | */ 164 | protected function action($command) 165 | { 166 | // make sure we don't compress "a + ++b" to "a+++b", etc. 167 | if ($command === self::ACTION_DELETE_A_B 168 | && $this->b === ' ' 169 | && ($this->a === '+' || $this->a === '-')) { 170 | // Note: we're at an addition/substraction operator; the inputIndex 171 | // will certainly be a valid index 172 | if ($this->input[$this->inputIndex] === $this->a) { 173 | // This is "+ +" or "- -". Don't delete the space. 174 | $command = self::ACTION_KEEP_A; 175 | } 176 | } 177 | 178 | switch ($command) { 179 | case self::ACTION_KEEP_A: // 1 180 | $this->output .= $this->a; 181 | 182 | if ($this->keptComment) { 183 | $this->output = rtrim($this->output, "\n"); 184 | $this->output .= $this->keptComment; 185 | $this->keptComment = ''; 186 | } 187 | 188 | $this->lastByteOut = $this->a; 189 | 190 | // fallthrough intentional 191 | case self::ACTION_DELETE_A: // 2 192 | $this->a = $this->b; 193 | if ($this->a === "'" || $this->a === '"') { // string literal 194 | $str = $this->a; // in case needed for exception 195 | for (;;) { 196 | $this->output .= $this->a; 197 | $this->lastByteOut = $this->a; 198 | 199 | $this->a = $this->get(); 200 | if ($this->a === $this->b) { // end quote 201 | break; 202 | } 203 | if ($this->isEOF($this->a)) { 204 | throw new JSMin_UnterminatedStringException( 205 | "JSMin: Unterminated String at byte {$this->inputIndex}: {$str}"); 206 | } 207 | $str .= $this->a; 208 | if ($this->a === '\\') { 209 | $this->output .= $this->a; 210 | $this->lastByteOut = $this->a; 211 | 212 | $this->a = $this->get(); 213 | $str .= $this->a; 214 | } 215 | } 216 | } 217 | 218 | // fallthrough intentional 219 | case self::ACTION_DELETE_A_B: // 3 220 | $this->b = $this->next(); 221 | if ($this->b === '/' && $this->isRegexpLiteral()) { 222 | $this->output .= $this->a . $this->b; 223 | $pattern = '/'; // keep entire pattern in case we need to report it in the exception 224 | for (;;) { 225 | $this->a = $this->get(); 226 | $pattern .= $this->a; 227 | if ($this->a === '[') { 228 | for (;;) { 229 | $this->output .= $this->a; 230 | $this->a = $this->get(); 231 | $pattern .= $this->a; 232 | if ($this->a === ']') { 233 | break; 234 | } 235 | if ($this->a === '\\') { 236 | $this->output .= $this->a; 237 | $this->a = $this->get(); 238 | $pattern .= $this->a; 239 | } 240 | if ($this->isEOF($this->a)) { 241 | throw new JSMin_UnterminatedRegExpException( 242 | "JSMin: Unterminated set in RegExp at byte " 243 | . $this->inputIndex .": {$pattern}"); 244 | } 245 | } 246 | } 247 | 248 | if ($this->a === '/') { // end pattern 249 | break; // while (true) 250 | } elseif ($this->a === '\\') { 251 | $this->output .= $this->a; 252 | $this->a = $this->get(); 253 | $pattern .= $this->a; 254 | } elseif ($this->isEOF($this->a)) { 255 | throw new JSMin_UnterminatedRegExpException( 256 | "JSMin: Unterminated RegExp at byte {$this->inputIndex}: {$pattern}"); 257 | } 258 | $this->output .= $this->a; 259 | $this->lastByteOut = $this->a; 260 | } 261 | $this->b = $this->next(); 262 | } 263 | // end case ACTION_DELETE_A_B 264 | } 265 | } 266 | 267 | /** 268 | * @return bool 269 | */ 270 | protected function isRegexpLiteral() 271 | { 272 | if (false !== strpos("(,=:[!&|?+-~*{;", $this->a)) { 273 | // we obviously aren't dividing 274 | return true; 275 | } 276 | if ($this->a === ' ' || $this->a === "\n") { 277 | $length = strlen($this->output); 278 | if ($length < 2) { // weird edge case 279 | return true; 280 | } 281 | // you can't divide a keyword 282 | if (preg_match('/(?:case|else|in|return|typeof)$/', $this->output, $m)) { 283 | if ($this->output === $m[0]) { // odd but could happen 284 | return true; 285 | } 286 | // make sure it's a keyword, not end of an identifier 287 | $charBeforeKeyword = substr($this->output, $length - strlen($m[0]) - 1, 1); 288 | if (! $this->isAlphaNum($charBeforeKeyword)) { 289 | return true; 290 | } 291 | } 292 | } 293 | return false; 294 | } 295 | 296 | /** 297 | * Return the next character from stdin. Watch out for lookahead. If the character is a control character, 298 | * translate it to a space or linefeed. 299 | * 300 | * @return string 301 | */ 302 | protected function get() 303 | { 304 | $c = $this->lookAhead; 305 | $this->lookAhead = null; 306 | if ($c === null) { 307 | // getc(stdin) 308 | if ($this->inputIndex < $this->inputLength) { 309 | $c = $this->input[$this->inputIndex]; 310 | $this->inputIndex += 1; 311 | } else { 312 | $c = null; 313 | } 314 | } 315 | if (ord($c) >= self::ORD_SPACE || $c === "\n" || $c === null) { 316 | return $c; 317 | } 318 | if ($c === "\r") { 319 | return "\n"; 320 | } 321 | return ' '; 322 | } 323 | 324 | /** 325 | * Does $a indicate end of input? 326 | * 327 | * @param string $a 328 | * @return bool 329 | */ 330 | protected function isEOF($a) 331 | { 332 | return ord($a) <= self::ORD_LF; 333 | } 334 | 335 | /** 336 | * Get next char (without getting it). If is ctrl character, translate to a space or newline. 337 | * 338 | * @return string 339 | */ 340 | protected function peek() 341 | { 342 | $this->lookAhead = $this->get(); 343 | return $this->lookAhead; 344 | } 345 | 346 | /** 347 | * Return true if the character is a letter, digit, underscore, dollar sign, or non-ASCII character. 348 | * 349 | * @param string $c 350 | * 351 | * @return bool 352 | */ 353 | protected function isAlphaNum($c) 354 | { 355 | return (preg_match('/^[a-z0-9A-Z_\\$\\\\]$/', $c) || ord($c) > 126); 356 | } 357 | 358 | /** 359 | * Consume a single line comment from input (possibly retaining it) 360 | */ 361 | protected function consumeSingleLineComment() 362 | { 363 | $comment = ''; 364 | while (true) { 365 | $get = $this->get(); 366 | $comment .= $get; 367 | if (ord($get) <= self::ORD_LF) { // end of line reached 368 | // if IE conditional comment 369 | if (preg_match('/^\\/@(?:cc_on|if|elif|else|end)\\b/', $comment)) { 370 | $this->keptComment .= "/{$comment}"; 371 | } 372 | return; 373 | } 374 | } 375 | } 376 | 377 | /** 378 | * Consume a multiple line comment from input (possibly retaining it) 379 | * 380 | * @throws JSMin_UnterminatedCommentException 381 | */ 382 | protected function consumeMultipleLineComment() 383 | { 384 | $this->get(); 385 | $comment = ''; 386 | for (;;) { 387 | $get = $this->get(); 388 | if ($get === '*') { 389 | if ($this->peek() === '/') { // end of comment reached 390 | $this->get(); 391 | if (0 === strpos($comment, '!')) { 392 | // preserved by YUI Compressor 393 | if (!$this->keptComment) { 394 | // don't prepend a newline if two comments right after one another 395 | $this->keptComment = "\n"; 396 | } 397 | $this->keptComment .= "/*!" . substr($comment, 1) . "*/\n"; 398 | } elseif (preg_match('/^@(?:cc_on|if|elif|else|end)\\b/', $comment)) { 399 | // IE conditional 400 | $this->keptComment .= "/*{$comment}*/"; 401 | } 402 | return; 403 | } 404 | } elseif ($get === null) { 405 | throw new JSMin_UnterminatedCommentException( 406 | "JSMin: Unterminated comment at byte {$this->inputIndex}: /*{$comment}"); 407 | } 408 | $comment .= $get; 409 | } 410 | } 411 | 412 | /** 413 | * Get the next character, skipping over comments. Some comments may be preserved. 414 | * 415 | * @return string 416 | */ 417 | protected function next() 418 | { 419 | $get = $this->get(); 420 | if ($get === '/') { 421 | switch ($this->peek()) { 422 | case '/': 423 | $this->consumeSingleLineComment(); 424 | $get = "\n"; 425 | break; 426 | case '*': 427 | $this->consumeMultipleLineComment(); 428 | $get = ' '; 429 | break; 430 | } 431 | } 432 | return $get; 433 | } 434 | } 435 | 436 | class JSMin_UnterminatedStringException extends Exception 437 | { 438 | } 439 | class JSMin_UnterminatedCommentException extends Exception 440 | { 441 | } 442 | class JSMin_UnterminatedRegExpException extends Exception 443 | { 444 | } 445 | -------------------------------------------------------------------------------- /minify/CSSmin.php: -------------------------------------------------------------------------------- 1 | run($css, $linebreak_pos); 51 | } 52 | 53 | /** 54 | * @param bool|int $raise_php_limits 55 | * If true, PHP settings will be raised if needed 56 | */ 57 | public function __construct($raise_php_limits = true) 58 | { 59 | // Set suggested PHP limits 60 | $this->memory_limit = 128 * 1048576; // 128MB in bytes 61 | $this->max_execution_time = 60; // 1 min 62 | $this->pcre_backtrack_limit = 1000 * 1000; 63 | $this->pcre_recursion_limit = 500 * 1000; 64 | 65 | $this->raise_php_limits = (bool) $raise_php_limits; 66 | } 67 | 68 | /** 69 | * Minify a string of CSS 70 | * @param string $css 71 | * @param int|bool $linebreak_pos 72 | * @return string 73 | */ 74 | public function run($css = '', $linebreak_pos = false) 75 | { 76 | if (empty($css)) { 77 | return ''; 78 | } 79 | 80 | if ($this->raise_php_limits) { 81 | $this->do_raise_php_limits(); 82 | } 83 | 84 | $this->comments = array(); 85 | $this->preserved_tokens = array(); 86 | 87 | $start_index = 0; 88 | $length = strlen($css); 89 | 90 | $css = $this->extract_data_urls($css); 91 | 92 | // collect all comment blocks... 93 | while (($start_index = $this->index_of($css, '/*', $start_index)) >= 0) { 94 | $end_index = $this->index_of($css, '*/', $start_index + 2); 95 | if ($end_index < 0) { 96 | $end_index = $length; 97 | } 98 | $comment_found = $this->str_slice($css, $start_index + 2, $end_index); 99 | $this->comments[] = $comment_found; 100 | $comment_preserve_string = self::COMMENT . (count($this->comments) - 1) . '___'; 101 | $css = $this->str_slice($css, 0, $start_index + 2) . $comment_preserve_string . $this->str_slice($css, $end_index); 102 | // Set correct start_index: Fixes issue #2528130 103 | $start_index = $end_index + 2 + strlen($comment_preserve_string) - strlen($comment_found); 104 | } 105 | 106 | // preserve strings so their content doesn't get accidentally minified 107 | $css = preg_replace_callback('/(?:"(?:[^\\\\"]|\\\\.|\\\\)*")|'."(?:'(?:[^\\\\']|\\\\.|\\\\)*')/S", array($this, 'replace_string'), $css); 108 | 109 | // Let's divide css code in chunks of 25.000 chars aprox. 110 | // Reason: PHP's PCRE functions like preg_replace have a "backtrack limit" 111 | // of 100.000 chars by default (php < 5.3.7) so if we're dealing with really 112 | // long strings and a (sub)pattern matches a number of chars greater than 113 | // the backtrack limit number (i.e. /(.*)/s) PCRE functions may fail silently 114 | // returning NULL and $css would be empty. 115 | $charset = ''; 116 | $charset_regexp = '/(@charset)( [^;]+;)/i'; 117 | $css_chunks = array(); 118 | $css_chunk_length = 25000; // aprox size, not exact 119 | $start_index = 0; 120 | $i = $css_chunk_length; // save initial iterations 121 | $l = strlen($css); 122 | 123 | 124 | // if the number of characters is 25000 or less, do not chunk 125 | if ($l <= $css_chunk_length) { 126 | $css_chunks[] = $css; 127 | } else { 128 | // chunk css code securely 129 | while ($i < $l) { 130 | $i += 50; // save iterations. 500 checks for a closing curly brace } 131 | if ($l - $start_index <= $css_chunk_length || $i >= $l) { 132 | $css_chunks[] = $this->str_slice($css, $start_index); 133 | break; 134 | } 135 | if ($css[$i - 1] === '}' && $i - $start_index > $css_chunk_length) { 136 | // If there are two ending curly braces }} separated or not by spaces, 137 | // join them in the same chunk (i.e. @media blocks) 138 | $next_chunk = substr($css, $i); 139 | if (preg_match('/^\s*\}/', $next_chunk)) { 140 | $i = $i + $this->index_of($next_chunk, '}') + 1; 141 | } 142 | 143 | $css_chunks[] = $this->str_slice($css, $start_index, $i); 144 | $start_index = $i; 145 | } 146 | } 147 | } 148 | 149 | // Minify each chunk 150 | for ($i = 0, $n = count($css_chunks); $i < $n; $i++) { 151 | $css_chunks[$i] = $this->min($css_chunks[$i], $linebreak_pos); 152 | // Keep the first @charset at-rule found 153 | if (empty($charset) && preg_match($charset_regexp, $css_chunks[$i], $matches)) { 154 | $charset = strtolower($matches[1]) . $matches[2]; 155 | } 156 | // Delete all @charset at-rules 157 | $css_chunks[$i] = preg_replace($charset_regexp, '', $css_chunks[$i]); 158 | } 159 | 160 | // Update the first chunk and push the charset to the top of the file. 161 | $css_chunks[0] = $charset . $css_chunks[0]; 162 | 163 | return implode('', $css_chunks); 164 | } 165 | 166 | /** 167 | * Sets the memory limit for this script 168 | * @param int|string $limit 169 | */ 170 | public function set_memory_limit($limit) 171 | { 172 | $this->memory_limit = $this->normalize_int($limit); 173 | } 174 | 175 | /** 176 | * Sets the maximum execution time for this script 177 | * @param int|string $seconds 178 | */ 179 | public function set_max_execution_time($seconds) 180 | { 181 | $this->max_execution_time = (int) $seconds; 182 | } 183 | 184 | /** 185 | * Sets the PCRE backtrack limit for this script 186 | * @param int $limit 187 | */ 188 | public function set_pcre_backtrack_limit($limit) 189 | { 190 | $this->pcre_backtrack_limit = (int) $limit; 191 | } 192 | 193 | /** 194 | * Sets the PCRE recursion limit for this script 195 | * @param int $limit 196 | */ 197 | public function set_pcre_recursion_limit($limit) 198 | { 199 | $this->pcre_recursion_limit = (int) $limit; 200 | } 201 | 202 | /** 203 | * Try to configure PHP to use at least the suggested minimum settings 204 | */ 205 | private function do_raise_php_limits() 206 | { 207 | $php_limits = array( 208 | 'memory_limit' => $this->memory_limit, 209 | 'max_execution_time' => $this->max_execution_time, 210 | 'pcre.backtrack_limit' => $this->pcre_backtrack_limit, 211 | 'pcre.recursion_limit' => $this->pcre_recursion_limit 212 | ); 213 | 214 | // If current settings are higher respect them. 215 | foreach ($php_limits as $name => $suggested) { 216 | $current = $this->normalize_int(ini_get($name)); 217 | // memory_limit exception: allow -1 for "no memory limit". 218 | if ($current > -1 && ($suggested == -1 || $current < $suggested)) { 219 | ini_set($name, $suggested); 220 | } 221 | } 222 | } 223 | 224 | /** 225 | * Does bulk of the minification 226 | * @param string $css 227 | * @param int|bool $linebreak_pos 228 | * @return string 229 | */ 230 | private function min($css, $linebreak_pos) 231 | { 232 | // strings are safe, now wrestle the comments 233 | for ($i = 0, $max = count($this->comments); $i < $max; $i++) { 234 | $token = $this->comments[$i]; 235 | $placeholder = '/' . self::COMMENT . $i . '___/'; 236 | 237 | // ! in the first position of the comment means preserve 238 | // so push to the preserved tokens keeping the ! 239 | if (substr($token, 0, 1) === '!') { 240 | $this->preserved_tokens[] = $token; 241 | $token_tring = self::TOKEN . (count($this->preserved_tokens) - 1) . '___'; 242 | $css = preg_replace($placeholder, $token_tring, $css, 1); 243 | // Preserve new lines for /*! important comments 244 | $css = preg_replace('/\s*[\n\r\f]+\s*(\/\*'. $token_tring .')/S', self::NL.'$1', $css); 245 | $css = preg_replace('/('. $token_tring .'\*\/)\s*[\n\r\f]+\s*/', '$1'.self::NL, $css); 246 | continue; 247 | } 248 | 249 | // \ in the last position looks like hack for Mac/IE5 250 | // shorten that to /*\*/ and the next one to /**/ 251 | if (substr($token, (strlen($token) - 1), 1) === '\\') { 252 | $this->preserved_tokens[] = '\\'; 253 | $css = preg_replace($placeholder, self::TOKEN . (count($this->preserved_tokens) - 1) . '___', $css, 1); 254 | $i = $i + 1; // attn: advancing the loop 255 | $this->preserved_tokens[] = ''; 256 | $css = preg_replace('/' . self::COMMENT . $i . '___/', self::TOKEN . (count($this->preserved_tokens) - 1) . '___', $css, 1); 257 | continue; 258 | } 259 | 260 | // keep empty comments after child selectors (IE7 hack) 261 | // e.g. html >/**/ body 262 | if (strlen($token) === 0) { 263 | $start_index = $this->index_of($css, $this->str_slice($placeholder, 1, -1)); 264 | if ($start_index > 2) { 265 | if (substr($css, $start_index - 3, 1) === '>') { 266 | $this->preserved_tokens[] = ''; 267 | $css = preg_replace($placeholder, self::TOKEN . (count($this->preserved_tokens) - 1) . '___', $css, 1); 268 | } 269 | } 270 | } 271 | 272 | // in all other cases kill the comment 273 | $css = preg_replace('/\/\*' . $this->str_slice($placeholder, 1, -1) . '\*\//', '', $css, 1); 274 | } 275 | 276 | 277 | // Normalize all whitespace strings to single spaces. Easier to work with that way. 278 | $css = preg_replace('/\s+/', ' ', $css); 279 | 280 | // Shorten & preserve calculations calc(...) since spaces are important 281 | $css = preg_replace_callback('/calc(\(((?:[^\(\)]+|(?1))*)\))/i', array($this, 'replace_calc'), $css); 282 | 283 | // Replace positive sign from numbers preceded by : or a white-space before the leading space is removed 284 | // +1.2em to 1.2em, +.8px to .8px, +2% to 2% 285 | $css = preg_replace('/((? -9.0 to -9 296 | $css = preg_replace('/((?\+\(\)\]\~\=,])/', '$1', $css); 308 | 309 | // Restore spaces for !important 310 | $css = preg_replace('/\!important/i', ' !important', $css); 311 | 312 | // bring back the colon 313 | $css = preg_replace('/' . self::CLASSCOLON . '/', ':', $css); 314 | 315 | // retain space for special IE6 cases 316 | $css = preg_replace_callback('/\:first\-(line|letter)(\{|,)/i', array($this, 'lowercase_pseudo_first'), $css); 317 | 318 | // no space after the end of a preserved comment 319 | $css = preg_replace('/\*\/ /', '*/', $css); 320 | 321 | // lowercase some popular @directives 322 | $css = preg_replace_callback('/@(font-face|import|(?:-(?:atsc|khtml|moz|ms|o|wap|webkit)-)?keyframe|media|page|namespace)/i', array($this, 'lowercase_directives'), $css); 323 | 324 | // lowercase some more common pseudo-elements 325 | $css = preg_replace_callback('/:(active|after|before|checked|disabled|empty|enabled|first-(?:child|of-type)|focus|hover|last-(?:child|of-type)|link|only-(?:child|of-type)|root|:selection|target|visited)/i', array($this, 'lowercase_pseudo_elements'), $css); 326 | 327 | // lowercase some more common functions 328 | $css = preg_replace_callback('/:(lang|not|nth-child|nth-last-child|nth-last-of-type|nth-of-type|(?:-(?:moz|webkit)-)?any)\(/i', array($this, 'lowercase_common_functions'), $css); 329 | 330 | // lower case some common function that can be values 331 | // NOTE: rgb() isn't useful as we replace with #hex later, as well as and() is already done for us 332 | $css = preg_replace_callback('/([:,\( ]\s*)(attr|color-stop|from|rgba|to|url|(?:-(?:atsc|khtml|moz|ms|o|wap|webkit)-)?(?:calc|max|min|(?:repeating-)?(?:linear|radial)-gradient)|-webkit-gradient)/iS', array($this, 'lowercase_common_functions_values'), $css); 333 | 334 | // Put the space back in some cases, to support stuff like 335 | // @media screen and (-webkit-min-device-pixel-ratio:0){ 336 | $css = preg_replace('/\band\(/i', 'and (', $css); 337 | 338 | // Remove the spaces after the things that should not have spaces after them. 339 | $css = preg_replace('/([\!\{\}\:;\>\+\(\[\~\=,])\s+/S', '$1', $css); 340 | 341 | // remove unnecessary semicolons 342 | $css = preg_replace('/;+\}/', '}', $css); 343 | 344 | // Fix for issue: #2528146 345 | // Restore semicolon if the last property is prefixed with a `*` (lte IE7 hack) 346 | // to avoid issues on Symbian S60 3.x browsers. 347 | $css = preg_replace('/(\*[a-z0-9\-]+\s*\:[^;\}]+)(\})/', '$1;$2', $css); 348 | 349 | // Replace 0 length units 0(px,em,%) with 0. 350 | $css = preg_replace('/(^|[^0-9])(?:0?\.)?0(?:em|ex|ch|rem|vw|vh|vm|vmin|cm|mm|in|px|pt|pc|%|deg|g?rad|m?s|k?hz)/iS', '${1}0', $css); 351 | 352 | // Replace 0 0; or 0 0 0; or 0 0 0 0; with 0. 353 | $css = preg_replace('/\:0(?: 0){1,3}(;|\}| \!)/', ':0$1', $css); 354 | 355 | // Fix for issue: #2528142 356 | // Replace text-shadow:0; with text-shadow:0 0 0; 357 | $css = preg_replace('/(text-shadow\:0)(;|\}| \!)/i', '$1 0 0$2', $css); 358 | 359 | // Replace background-position:0; with background-position:0 0; 360 | // same for transform-origin 361 | // Changing -webkit-mask-position: 0 0 to just a single 0 will result in the second parameter defaulting to 50% (center) 362 | $css = preg_replace('/(background\-position|webkit-mask-position|(?:webkit|moz|o|ms|)\-?transform\-origin)\:0(;|\}| \!)/iS', '$1:0 0$2', $css); 363 | 364 | // Shorten colors from rgb(51,102,153) to #336699, rgb(100%,0%,0%) to #ff0000 (sRGB color space) 365 | // Shorten colors from hsl(0, 100%, 50%) to #ff0000 (sRGB color space) 366 | // This makes it more likely that it'll get further compressed in the next step. 367 | $css = preg_replace_callback('/rgb\s*\(\s*([0-9,\s\-\.\%]+)\s*\)(.{1})/i', array($this, 'rgb_to_hex'), $css); 368 | $css = preg_replace_callback('/hsl\s*\(\s*([0-9,\s\-\.\%]+)\s*\)(.{1})/i', array($this, 'hsl_to_hex'), $css); 369 | 370 | // Shorten colors from #AABBCC to #ABC or short color name. 371 | $css = $this->compress_hex_colors($css); 372 | 373 | // border: none to border:0, outline: none to outline:0 374 | $css = preg_replace('/(border\-?(?:top|right|bottom|left|)|outline)\:none(;|\}| \!)/iS', '$1:0$2', $css); 375 | 376 | // shorter opacity IE filter 377 | $css = preg_replace('/progid\:DXImageTransform\.Microsoft\.Alpha\(Opacity\=/i', 'alpha(opacity=', $css); 378 | 379 | // Find a fraction that is used for Opera's -o-device-pixel-ratio query 380 | // Add token to add the "\" back in later 381 | $css = preg_replace('/\(([a-z\-]+):([0-9]+)\/([0-9]+)\)/i', '($1:$2'. self::QUERY_FRACTION .'$3)', $css); 382 | 383 | // Remove empty rules. 384 | $css = preg_replace('/[^\};\{\/]+\{\}/S', '', $css); 385 | 386 | // Add "/" back to fix Opera -o-device-pixel-ratio query 387 | $css = preg_replace('/'. self::QUERY_FRACTION .'/', '/', $css); 388 | 389 | // Some source control tools don't like it when files containing lines longer 390 | // than, say 8000 characters, are checked in. The linebreak option is used in 391 | // that case to split long lines after a specific column. 392 | if ($linebreak_pos !== false && (int) $linebreak_pos >= 0) { 393 | $linebreak_pos = (int) $linebreak_pos; 394 | $start_index = $i = 0; 395 | while ($i < strlen($css)) { 396 | $i++; 397 | if ($css[$i - 1] === '}' && $i - $start_index > $linebreak_pos) { 398 | $css = $this->str_slice($css, 0, $i) . "\n" . $this->str_slice($css, $i); 399 | $start_index = $i; 400 | } 401 | } 402 | } 403 | 404 | // Replace multiple semi-colons in a row by a single one 405 | // See SF bug #1980989 406 | $css = preg_replace('/;;+/', ';', $css); 407 | 408 | // Restore new lines for /*! important comments 409 | $css = preg_replace('/'. self::NL .'/', "\n", $css); 410 | 411 | // Lowercase all uppercase properties 412 | $css = preg_replace_callback('/(\{|\;)([A-Z\-]+)(\:)/', array($this, 'lowercase_properties'), $css); 413 | 414 | // restore preserved comments and strings 415 | for ($i = 0, $max = count($this->preserved_tokens); $i < $max; $i++) { 416 | $css = preg_replace('/' . self::TOKEN . $i . '___/', $this->preserved_tokens[$i], $css, 1); 417 | } 418 | 419 | // Trim the final string (for any leading or trailing white spaces) 420 | return trim($css); 421 | } 422 | 423 | /** 424 | * Utility method to replace all data urls with tokens before we start 425 | * compressing, to avoid performance issues running some of the subsequent 426 | * regexes against large strings chunks. 427 | * 428 | * @param string $css 429 | * @return string 430 | */ 431 | private function extract_data_urls($css) 432 | { 433 | // Leave data urls alone to increase parse performance. 434 | $max_index = strlen($css) - 1; 435 | $append_index = $index = $last_index = $offset = 0; 436 | $sb = array(); 437 | $pattern = '/url\(\s*(["\']?)data\:/i'; 438 | 439 | // Since we need to account for non-base64 data urls, we need to handle 440 | // ' and ) being part of the data string. Hence switching to indexOf, 441 | // to determine whether or not we have matching string terminators and 442 | // handling sb appends directly, instead of using matcher.append* methods. 443 | 444 | while (preg_match($pattern, $css, $m, 0, $offset)) { 445 | $index = $this->index_of($css, $m[0], $offset); 446 | $last_index = $index + strlen($m[0]); 447 | $start_index = $index + 4; // "url(".length() 448 | $end_index = $last_index - 1; 449 | $terminator = $m[1]; // ', " or empty (not quoted) 450 | $found_terminator = false; 451 | 452 | if (strlen($terminator) === 0) { 453 | $terminator = ')'; 454 | } 455 | 456 | while ($found_terminator === false && $end_index+1 <= $max_index) { 457 | $end_index = $this->index_of($css, $terminator, $end_index + 1); 458 | 459 | // endIndex == 0 doesn't really apply here 460 | if ($end_index > 0 && substr($css, $end_index - 1, 1) !== '\\') { 461 | $found_terminator = true; 462 | if (')' != $terminator) { 463 | $end_index = $this->index_of($css, ')', $end_index); 464 | } 465 | } 466 | } 467 | 468 | // Enough searching, start moving stuff over to the buffer 469 | $sb[] = $this->str_slice($css, $append_index, $index); 470 | 471 | if ($found_terminator) { 472 | $token = $this->str_slice($css, $start_index, $end_index); 473 | $token = preg_replace('/\s+/', '', $token); 474 | $this->preserved_tokens[] = $token; 475 | 476 | $preserver = 'url(' . self::TOKEN . (count($this->preserved_tokens) - 1) . '___)'; 477 | $sb[] = $preserver; 478 | 479 | $append_index = $end_index + 1; 480 | } else { 481 | // No end terminator found, re-add the whole match. Should we throw/warn here? 482 | $sb[] = $this->str_slice($css, $index, $last_index); 483 | $append_index = $last_index; 484 | } 485 | 486 | $offset = $last_index; 487 | } 488 | 489 | $sb[] = $this->str_slice($css, $append_index); 490 | 491 | return implode('', $sb); 492 | } 493 | 494 | /** 495 | * Utility method to compress hex color values of the form #AABBCC to #ABC or short color name. 496 | * 497 | * DOES NOT compress CSS ID selectors which match the above pattern (which would break things). 498 | * e.g. #AddressForm { ... } 499 | * 500 | * DOES NOT compress IE filters, which have hex color values (which would break things). 501 | * e.g. filter: chroma(color="#FFFFFF"); 502 | * 503 | * DOES NOT compress invalid hex values. 504 | * e.g. background-color: #aabbccdd 505 | * 506 | * @param string $css 507 | * @return string 508 | */ 509 | private function compress_hex_colors($css) 510 | { 511 | // Look for hex colors inside { ... } (to avoid IDs) and which don't have a =, or a " in front of them (to avoid filters) 512 | $pattern = '/(\=\s*?["\']?)?#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])(\}|[^0-9a-f{][^{]*?\})/iS'; 513 | $_index = $index = $last_index = $offset = 0; 514 | $sb = array(); 515 | // See: http://ajaxmin.codeplex.com/wikipage?title=CSS%20Colors 516 | $short_safe = array( 517 | '#808080' => 'gray', 518 | '#008000' => 'green', 519 | '#800000' => 'maroon', 520 | '#000080' => 'navy', 521 | '#808000' => 'olive', 522 | '#ffa500' => 'orange', 523 | '#800080' => 'purple', 524 | '#c0c0c0' => 'silver', 525 | '#008080' => 'teal', 526 | '#f00' => 'red' 527 | ); 528 | 529 | while (preg_match($pattern, $css, $m, 0, $offset)) { 530 | $index = $this->index_of($css, $m[0], $offset); 531 | $last_index = $index + strlen($m[0]); 532 | $is_filter = $m[1] !== null && $m[1] !== ''; 533 | 534 | $sb[] = $this->str_slice($css, $_index, $index); 535 | 536 | if ($is_filter) { 537 | // Restore, maintain case, otherwise filter will break 538 | $sb[] = $m[1] . '#' . $m[2] . $m[3] . $m[4] . $m[5] . $m[6] . $m[7]; 539 | } else { 540 | if (strtolower($m[2]) == strtolower($m[3]) && 541 | strtolower($m[4]) == strtolower($m[5]) && 542 | strtolower($m[6]) == strtolower($m[7])) { 543 | // Compress. 544 | $hex = '#' . strtolower($m[3] . $m[5] . $m[7]); 545 | } else { 546 | // Non compressible color, restore but lower case. 547 | $hex = '#' . strtolower($m[2] . $m[3] . $m[4] . $m[5] . $m[6] . $m[7]); 548 | } 549 | // replace Hex colors to short safe color names 550 | $sb[] = array_key_exists($hex, $short_safe) ? $short_safe[$hex] : $hex; 551 | } 552 | 553 | $_index = $offset = $last_index - strlen($m[8]); 554 | } 555 | 556 | $sb[] = $this->str_slice($css, $_index); 557 | 558 | return implode('', $sb); 559 | } 560 | 561 | /* CALLBACKS 562 | * --------------------------------------------------------------------------------------------- 563 | */ 564 | 565 | private function replace_string($matches) 566 | { 567 | $match = $matches[0]; 568 | $quote = substr($match, 0, 1); 569 | // Must use addcslashes in PHP to avoid parsing of backslashes 570 | $match = addcslashes($this->str_slice($match, 1, -1), '\\'); 571 | 572 | // maybe the string contains a comment-like substring? 573 | // one, maybe more? put'em back then 574 | if (($pos = $this->index_of($match, self::COMMENT)) >= 0) { 575 | for ($i = 0, $max = count($this->comments); $i < $max; $i++) { 576 | $match = preg_replace('/' . self::COMMENT . $i . '___/', $this->comments[$i], $match, 1); 577 | } 578 | } 579 | 580 | // minify alpha opacity in filter strings 581 | $match = preg_replace('/progid\:DXImageTransform\.Microsoft\.Alpha\(Opacity\=/i', 'alpha(opacity=', $match); 582 | 583 | $this->preserved_tokens[] = $match; 584 | return $quote . self::TOKEN . (count($this->preserved_tokens) - 1) . '___' . $quote; 585 | } 586 | 587 | private function replace_colon($matches) 588 | { 589 | return preg_replace('/\:/', self::CLASSCOLON, $matches[0]); 590 | } 591 | 592 | private function replace_calc($matches) 593 | { 594 | $this->preserved_tokens[] = trim(preg_replace('/\s*([\*\/\(\),])\s*/', '$1', $matches[2])); 595 | return 'calc('. self::TOKEN . (count($this->preserved_tokens) - 1) . '___' . ')'; 596 | } 597 | 598 | private function rgb_to_hex($matches) 599 | { 600 | // Support for percentage values rgb(100%, 0%, 45%); 601 | if ($this->index_of($matches[1], '%') >= 0) { 602 | $rgbcolors = explode(',', str_replace('%', '', $matches[1])); 603 | for ($i = 0; $i < count($rgbcolors); $i++) { 604 | $rgbcolors[$i] = $this->round_number(floatval($rgbcolors[$i]) * 2.55); 605 | } 606 | } else { 607 | $rgbcolors = explode(',', $matches[1]); 608 | } 609 | 610 | // Values outside the sRGB color space should be clipped (0-255) 611 | for ($i = 0; $i < count($rgbcolors); $i++) { 612 | $rgbcolors[$i] = $this->clamp_number(intval($rgbcolors[$i], 10), 0, 255); 613 | $rgbcolors[$i] = sprintf("%02x", $rgbcolors[$i]); 614 | } 615 | 616 | // Fix for issue #2528093 617 | if (!preg_match('/[\s\,\);\}]/', $matches[2])) { 618 | $matches[2] = ' ' . $matches[2]; 619 | } 620 | 621 | return '#' . implode('', $rgbcolors) . $matches[2]; 622 | } 623 | 624 | private function hsl_to_hex($matches) 625 | { 626 | $values = explode(',', str_replace('%', '', $matches[1])); 627 | $h = floatval($values[0]); 628 | $s = floatval($values[1]); 629 | $l = floatval($values[2]); 630 | 631 | // Wrap and clamp, then fraction! 632 | $h = ((($h % 360) + 360) % 360) / 360; 633 | $s = $this->clamp_number($s, 0, 100) / 100; 634 | $l = $this->clamp_number($l, 0, 100) / 100; 635 | 636 | if ($s == 0) { 637 | $r = $g = $b = $this->round_number(255 * $l); 638 | } else { 639 | $v2 = $l < 0.5 ? $l * (1 + $s) : ($l + $s) - ($s * $l); 640 | $v1 = (2 * $l) - $v2; 641 | $r = $this->round_number(255 * $this->hue_to_rgb($v1, $v2, $h + (1/3))); 642 | $g = $this->round_number(255 * $this->hue_to_rgb($v1, $v2, $h)); 643 | $b = $this->round_number(255 * $this->hue_to_rgb($v1, $v2, $h - (1/3))); 644 | } 645 | 646 | return $this->rgb_to_hex(array('', $r.','.$g.','.$b, $matches[2])); 647 | } 648 | 649 | private function lowercase_pseudo_first($matches) 650 | { 651 | return ':first-'. strtolower($matches[1]) .' '. $matches[2]; 652 | } 653 | 654 | private function lowercase_directives($matches) 655 | { 656 | return '@'. strtolower($matches[1]); 657 | } 658 | 659 | private function lowercase_pseudo_elements($matches) 660 | { 661 | return ':'. strtolower($matches[1]); 662 | } 663 | 664 | private function lowercase_common_functions($matches) 665 | { 666 | return ':'. strtolower($matches[1]) .'('; 667 | } 668 | 669 | private function lowercase_common_functions_values($matches) 670 | { 671 | return $matches[1] . strtolower($matches[2]); 672 | } 673 | 674 | private function lowercase_properties($matches) 675 | { 676 | return $matches[1].strtolower($matches[2]).$matches[3]; 677 | } 678 | 679 | /* HELPERS 680 | * --------------------------------------------------------------------------------------------- 681 | */ 682 | 683 | private function hue_to_rgb($v1, $v2, $vh) 684 | { 685 | $vh = $vh < 0 ? $vh + 1 : ($vh > 1 ? $vh - 1 : $vh); 686 | if ($vh * 6 < 1) { 687 | return $v1 + ($v2 - $v1) * 6 * $vh; 688 | } 689 | if ($vh * 2 < 1) { 690 | return $v2; 691 | } 692 | if ($vh * 3 < 2) { 693 | return $v1 + ($v2 - $v1) * ((2/3) - $vh) * 6; 694 | } 695 | return $v1; 696 | } 697 | 698 | private function round_number($n) 699 | { 700 | return intval(floor(floatval($n) + 0.5), 10); 701 | } 702 | 703 | private function clamp_number($n, $min, $max) 704 | { 705 | return min(max($n, $min), $max); 706 | } 707 | 708 | /** 709 | * PHP port of Javascript's "indexOf" function for strings only 710 | * Author: Tubal Martin http://blog.margenn.com 711 | * 712 | * @param string $haystack 713 | * @param string $needle 714 | * @param int $offset index (optional) 715 | * @return int 716 | */ 717 | private function index_of($haystack, $needle, $offset = 0) 718 | { 719 | $index = strpos($haystack, $needle, $offset); 720 | 721 | return ($index !== false) ? $index : -1; 722 | } 723 | 724 | /** 725 | * PHP port of Javascript's "slice" function for strings only 726 | * Author: Tubal Martin http://blog.margenn.com 727 | * Tests: http://margenn.com/tubal/str_slice/ 728 | * 729 | * @param string $str 730 | * @param int $start index 731 | * @param int|bool $end index (optional) 732 | * @return string 733 | */ 734 | private function str_slice($str, $start = 0, $end = false) 735 | { 736 | if ($end !== false && ($start < 0 || $end <= 0)) { 737 | $max = strlen($str); 738 | 739 | if ($start < 0) { 740 | if (($start = $max + $start) < 0) { 741 | return ''; 742 | } 743 | } 744 | 745 | if ($end < 0) { 746 | if (($end = $max + $end) < 0) { 747 | return ''; 748 | } 749 | } 750 | 751 | if ($end <= $start) { 752 | return ''; 753 | } 754 | } 755 | 756 | $slice = ($end === false) ? substr($str, $start) : substr($str, $start, $end - $start); 757 | return ($slice === false) ? '' : $slice; 758 | } 759 | 760 | /** 761 | * Convert strings like "64M" or "30" to int values 762 | * @param mixed $size 763 | * @return int 764 | */ 765 | private function normalize_int($size) 766 | { 767 | if (is_string($size)) { 768 | switch (substr($size, -1)) { 769 | case 'M': case 'm': return (int) substr($size, 0, -1) * 1048576; 770 | case 'K': case 'k': return (int) substr($size, 0, -1) * 1024; 771 | case 'G': case 'g': return (int) substr($size, 0, -1) * 1073741824; 772 | } 773 | } 774 | 775 | return (int) $size; 776 | } 777 | } 778 | --------------------------------------------------------------------------------