Fixed bug #14277: Improve t3lib_compressor (thanks to Steffen Gebert)
[Packages/TYPO3.CMS.git] / t3lib / class.t3lib_compressor.php
1 <?php
2 /***************************************************************
3 * Copyright notice
4 *
5 * (c) 2010 Steffen Gebert (steffen@steffen-gebert.de)
6 * All rights reserved
7 *
8 * This script is part of the TYPO3 project. The TYPO3 project is
9 * free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 2 of the License, or
12 * (at your option) any later version.
13 *
14 * The GNU General Public License can be found at
15 * http://www.gnu.org/copyleft/gpl.html.
16 * A copy is found in the textfile GPL.txt and important notices to the license
17 * from the author is found in LICENSE.txt distributed with these scripts.
18 *
19 *
20 * This script is distributed in the hope that it will be useful,
21 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23 * GNU General Public License for more details.
24 *
25 * This copyright notice MUST APPEAR in all copies of the script!
26 ***************************************************************/
27
28 /**
29 * Compressor
30 * This merges and compresses CSS and JavaScript files of the TYPO3 Backend.
31 *
32 * @author Steffen Gebert <steffen@steffen-gebert.de>
33 * @package TYPO3
34 * @subpackage t3lib
35 * $Id$
36 */
37 class t3lib_compressor {
38
39 protected $targetDirectory = 'typo3temp/compressor/';
40
41 // gzipped versions are only created if $TYPO3_CONF_VARS[TYPO3_MODE]['compressionLevel'] is set
42 protected $createGzipped = FALSE;
43 // default compression level is -1
44 protected $gzipCompressionLevel = -1;
45
46 /**
47 * Constructor
48 */
49 public function __construct() {
50
51 // we check for existance of our targetDirectory
52 if (!is_dir(PATH_site . $this->targetDirectory)) {
53 t3lib_div::mkdir(PATH_site . $this->targetDirectory);
54 }
55
56 // decide whether we should create gzipped versions or not
57 $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
58 // we need zlib for gzencode()
59 if (extension_loaded('zlib') && $compressionLevel) {
60 $this->createGzipped = TRUE;
61 // $compressionLevel can also be TRUE
62 if (t3lib_div::testInt($compressionLevel)) {
63 $this->gzipCompressionLevel = $compressionLevel;
64 }
65 }
66
67 // decide whether we should create gzipped versions or not
68 $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
69 // we need zlib for gzencode()
70 if (extension_loaded('zlib') && $compressionLevel) {
71 $this->createGzipped = TRUE;
72 // $compressionLevel can also be TRUE
73 if (t3lib_div::testInt($compressionLevel)) {
74 $this->gzipCompressionLevel = $compressionLevel;
75 }
76 }
77 }
78
79 /**
80 * Concatenates the cssFiles
81 *
82 * Options:
83 * baseDirectories If set, only include files below one of the base directories
84 *
85 * @param array $cssFiles CSS files to process
86 * @param array $options Additional options
87 * @return array CSS files
88 */
89 public function concatenateCssFiles(array $cssFiles, $options = array()) {
90 $filesToInclude = array();
91 foreach ($cssFiles as $filename => $fileOptions) {
92 // we remove BACK_PATH from $filename, so make it relative to TYPO3_mainDir
93 $filenameFromMainDir = substr($filename, strlen($GLOBALS['BACK_PATH']));
94 // if $options['baseDirectories'] set, we only include files below these directories
95 if ((!isset($options['baseDirectories'])
96 || $this->checkBaseDirectory($filenameFromMainDir, array_merge($options['baseDirectories'], array($this->targetDirectory))))
97 && ($fileOptions['media'] === 'all')
98 ) {
99
100 $filesToInclude[] = $filenameFromMainDir;
101 // remove the file from the incoming file array
102 unset($cssFiles[$filename]);
103 }
104 }
105
106 if (count($filesToInclude)) {
107 $targetFile = $this->createMergedCssFile($filesToInclude);
108 $concatenatedOptions = array(
109 'rel' => 'stylesheet',
110 'media' => 'all',
111 'compress' => TRUE,
112 );
113 $targetFileRelative = $GLOBALS['BACK_PATH'] . '../' . $targetFile;
114 // place the merged stylesheet on top of the stylesheets
115 $cssFiles = array_merge(array($targetFileRelative => $concatenatedOptions), $cssFiles);
116 }
117 return $cssFiles;
118 }
119
120 /**
121 * Creates a merged CSS file
122 *
123 * @param array $filesToInclude Files which should be merged, paths relative to TYPO3_mainDir
124 * @return mixed Filename of the merged file
125 */
126 protected function createMergedCssFile(array $filesToInclude) {
127 // we add up the filenames, filemtimes and filsizes to later build a checksum over
128 // it and include it in the temporary file name
129 $unique = '';
130
131 foreach ($filesToInclude as $filename) {
132 $filepath = t3lib_div::resolveBackPath(PATH_typo3 . $filename);
133 $unique .= $filename . filemtime($filepath) . filesize($filepath);
134 }
135 $targetFile = $this->targetDirectory . 'merged-'. md5($unique) . '.css';
136
137 // if the file doesn't already exist, we create it
138 if (!file_exists(PATH_site . $targetFile)) {
139 $concatenated = '';
140 // concatenate all the files together
141 foreach ($filesToInclude as $filename) {
142 $contents = t3lib_div::getUrl(t3lib_div::resolveBackPath(PATH_typo3 . $filename));
143 // only fix paths if files aren't already in typo3temp (already processed)
144 if (!t3lib_div::isFirstPartOfStr($filename, $this->targetDirectory)) {
145 $concatenated .= $this->cssFixRelativeUrlPaths($contents, dirname($filename) . '/');
146 } else {
147 $concatenated .= $contents;
148 }
149 }
150 t3lib_div::writeFile(PATH_site . $targetFile, $concatenated);
151 }
152 return $targetFile;
153 }
154
155 /**
156 * Compress multiple css files
157 *
158 * @param array $cssFiles The files to compress (array key = filename), relative to requested page
159 * @return array The CSS files after compression (array key = new filename), relative to requested page
160 */
161 public function compressCssFiles(array $cssFiles) {
162 $filesAfterCompression = array();
163 foreach ($cssFiles as $filename => $fileOptions) {
164 // we remove BACK_PATH from $filename, so make it relative to TYPO3_mainDir
165 $filenameFromMainDir = substr($filename, strlen($GLOBALS['BACK_PATH']));
166 // if compression is enabled
167 if ($fileOptions['compress']) {
168 $filesAfterCompression[$this->compressCssFile($filename)] = $fileOptions;
169 } else {
170 $filesAfterCompression[$filename] = $fileOptions;
171 }
172 }
173 return $filesAfterCompression;
174 }
175
176 /**
177 * Compresses a CSS file
178 *
179 * Options:
180 * baseDirectories If set, only include files below one of the base directories
181 *
182 * removes comments and whitespaces
183 * Adopted from http://drupal.org/files/issues/minify_css.php__1.txt
184 *
185 * @param string $filename Source filename, relative to requested page
186 * @return string Compressed filename, relative to requested page
187 */
188 public function compressCssFile($filename) {
189 // generate the unique name of the file
190 $filenameAbsolute = t3lib_div::resolveBackPath(PATH_typo3 . substr($filename, strlen($GLOBALS['BACK_PATH'])));
191 $unique = $filenameAbsolute . filemtime($filenameAbsolute) . filesize($filenameAbsolute);
192
193 $pathinfo = pathinfo($filename);
194 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
195 // only create it, if it doesn't exist, yet
196 if (!file_exists(PATH_site . $targetFile) || ($this->createGzipped && !file_exists(PATH_site . $targetFile . '.gz'))) {
197 $contents = t3lib_div::getUrl($filenameAbsolute);
198 // Perform some safe CSS optimizations.
199 $contents = str_replace("\r", '', $contents); // Strip any and all carriage returns.
200 // Match and process strings, comments and everything else, one chunk at a time.
201 // To understand this regex, read: "Mastering Regular Expressions 3rd Edition" chapter 6.
202 $contents = preg_replace_callback('%
203 # One-regex-to-rule-them-all! - version: 20100220_0100
204 # Group 1: Match a double quoted string.
205 ("[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+") | # or...
206 # Group 2: Match a single quoted string.
207 (\'[^\'\\\\]*+(?:\\\\.[^\'\\\\]*+)*+\') | # or...
208 # Group 3: Match a regular non-MacIE5-hack comment.
209 (/\*[^\\\\*]*+\*++(?:[^\\\\*/][^\\\\*]*+\*++)*+/) | # or...
210 # Group 4: Match a MacIE5-type1 comment.
211 (/\*(?:[^*\\\\]*+\**+(?!/))*+\\\\[^*]*+\*++(?:[^*/][^*]*+\*++)*+/(?<!\\\\\*/)) | # or...
212 # Group 5: Match a MacIE5-type2 comment.
213 (/\*[^*]*\*+(?:[^/*][^*]*\*+)*/(?<=\\\\\*/)) # folllowed by...
214 # Group 6: Match everything up to final closing regular comment
215 ([^/]*+(?:(?!\*)/[^/]*+)*?)
216 # Group 7: Match final closing regular comment
217 (/\*[^/]++(?:(?<!\*)/(?!\*)[^/]*+)*+/(?<=(?<!\\\\)\*/)) | # or...
218 # Group 8: Match regular non-string, non-comment text.
219 ([^"\'/]*+(?:(?!/\*)/[^"\'/]*+)*+)
220 %Ssx', array('self','compressCssPregCallback'), $contents); // Do it!
221 $contents = preg_replace('/^\s++/', '', $contents); // Strip leading whitespace.
222 $contents = preg_replace('/[ \t]*+\n\s*+/S', "\n", $contents); // Consolidate multi-lines space.
223 $contents = preg_replace('/(?<!\s)\s*+$/S', "\n", $contents); // Ensure file ends in newline.
224 // we have to fix relative paths, if we aren't working on a file in our target directory
225 if (!is_int(strpos($filename, $this->targetDirectory))) {
226 $filenameRelativeToMainDir = substr($filename, strlen($GLOBALS['BACK_PATH']));
227 $contents = $this->cssFixRelativeUrlPaths($contents, dirname($filenameRelativeToMainDir) . '/');
228 }
229 $this->writeFileAndCompressed($targetFile, $contents);
230 }
231
232 return $GLOBALS['BACK_PATH'] . '../' . $this->returnFileReference($targetFile);
233 }
234
235 /**
236 * Callback function for preg_replace
237 *
238 * @see compressCssFile
239 * @param array $matches
240 * @return string the compressed string
241 */
242 public static function compressCssPregCallback($matches) {
243 if ($matches[1]) { // Group 1: Double quoted string.
244 return $matches[1]; // Return the string unmodified.
245 } elseif ($matches[2]) { // Group 2: Single quoted string.
246 return $matches[2]; // Return the string unmodified.
247 } elseif ($matches[3]) { // Group 3: Regular non-MacIE5-hack comment.
248 return "\n"; // Return single space.
249 } elseif ($matches[4]) { // Group 4: MacIE5-hack-type-1 comment.
250 return "\n/*\\T1*/\n"; // Return minimal MacIE5-hack-type-1 comment.
251 }
252 elseif ($matches[5]) { // Group 5,6,7: MacIE5-hack-type-2 comment
253 $matches[6] = preg_replace('/\s++([+>{};,)])/S', '$1', $matches[6]); // Clean pre-punctuation.
254 $matches[6] = preg_replace('/([+>{}:;,(])\s++/S', '$1', $matches[6]); // Clean post-punctuation.
255 $matches[6] = preg_replace('/;?\}/S', "}\n", $matches[6]); // Add a touch of formatting.
256 return "\n/*T2\\*/" . $matches[6] . "\n/*T2E*/\n"; // Minify and reassemble composite type2 comment.
257 } elseif (isset($matches[8])) { // Group 8: Non-string, non-comment. Safe to clean whitespace here.
258 $matches[8] = preg_replace('/^\s++/', '', $matches[8]); // Strip all leading whitespace.
259 $matches[8] = preg_replace('/\s++$/', '', $matches[8]); // Strip all trailing whitespace.
260 $matches[8] = preg_replace('/\s{2,}+/', ' ', $matches[8]); // Consolidate multiple whitespace.
261 $matches[8] = preg_replace('/\s++([+>{};,)])/S', '$1', $matches[8]); // Clean pre-punctuation.
262 $matches[8] = preg_replace('/([+>{}:;,(])\s++/S', '$1', $matches[8]); // Clean post-punctuation.
263 $matches[8] = preg_replace('/;?\}/S', "}\n", $matches[8]); // Add a touch of formatting.
264 return $matches[8];
265 }
266 return $matches[0] . "\n/* ERROR! Unexpected _proccess_css_minify() parameter */\n"; // never get here
267 }
268
269 /**
270 * Compress multiple javascript files
271 *
272 * @param array $jsFiles The files to compress (array key = filename), relative to requested page
273 * @return array The js files after compression (array key = new filename), relative to requested page
274 */
275 public function compressJsFiles(array $jsFiles) {
276 $filesAfterCompression = array();
277 foreach ($jsFiles as $filename => $fileOptions) {
278 // we remove BACK_PATH from $filename, so make it relative to TYPO3_mainDir
279 $filenameFromMainDir = substr($filename, strlen($GLOBALS['BACK_PATH']));
280 // if compression is enabled
281 if ($fileOptions['compress']) {
282 $filesAfterCompression[$this->compressJsFile($filename)] = $fileOptions;
283 } else {
284 $filesAfterCompression[$filename] = $fileOptions;
285 }
286 }
287 return $filesAfterCompression;
288 }
289
290 /**
291 * Compresses a javascript file
292 *
293 * Options:
294 * baseDirectories If set, only include files below one of the base directories
295 *
296 * @param string $filename Source filename, relative to requested page
297 * @return string Filename of the compressed file, relative to requested page
298 */
299 public function compressJsFile($filename) {
300 // generate the unique name of the file
301 $filenameAbsolute = t3lib_div::resolveBackPath(PATH_typo3 . substr($filename, strlen($GLOBALS['BACK_PATH'])));
302 $unique = $filenameAbsolute . filemtime($filenameAbsolute) . filesize($filenameAbsolute);
303
304 $pathinfo = pathinfo($filename);
305 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
306 // only create it, if it doesn't exist, yet
307 if (!file_exists(PATH_site . $targetFile) || ($this->createGzipped && !file_exists(PATH_site . $targetFile . '.gz'))) {
308 $contents = t3lib_div::getUrl($filenameAbsolute);
309 $this->writeFileAndCompressed($targetFile, $contents);
310 }
311 return $GLOBALS['BACK_PATH'] . '../' . $this->returnFileReference($targetFile);
312 }
313
314 /**
315 * Decides whether a CSS file comes from one of the baseDirectories
316 *
317 * @param string $filename Filename
318 * @return boolean File belongs to a skin or not
319 */
320 protected function checkBaseDirectory($filename, array $baseDirectories) {
321 foreach ($baseDirectories as $baseDirectory) {
322 // check, if $filename starts with $skinStylesheetDirectory
323 if (t3lib_div::isFirstPartOfStr($filename, $baseDirectory)) {
324 return TRUE;
325 }
326 }
327 return FALSE;
328 }
329
330 /**
331 * Fixes the relative paths inside of url() references in CSS files
332 *
333 * @param string $contents Data to process
334 * @param string $oldDir Directory of the originial file, relative to TYPO3_mainDir
335 * @return string Processed data
336 */
337 protected function cssFixRelativeUrlPaths($contents, $oldDir) {
338 $matches = array();
339
340 preg_match_all('/url(\(\s*["\']?([^"\']+)["\']?\s*\))/iU', $contents, $matches);
341 foreach ($matches[2] as $matchCount => $match) {
342 // remove '," or white-spaces around
343 $match = preg_replace('/[\"\'\s]/', '', $match);
344 $newPath = t3lib_div::resolveBackPath('../../' . TYPO3_mainDir . $oldDir . $match);
345
346 $contents = str_replace($matches[1][$matchCount], '(\'' . $newPath . '\')', $contents);
347 }
348 return $contents;
349 }
350
351 /**
352 * Writes $contents into file $filename together with a gzipped version into $filename.gz
353 *
354 * @param string $filename Target filename
355 * @param strings $contents File contents
356 * @return void
357 */
358 protected function writeFileAndCompressed($filename, $contents) {
359 // write uncompressed file
360 t3lib_div::writeFile(PATH_site . $filename, $contents);
361
362 if ($this->createGzipped) {
363 // create compressed version
364 $compressionLevel = -1;
365 if (isset($GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel']) && is_numeric($GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'])) {
366 $compressionLevel = intval($GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel']);
367 }
368 t3lib_div::writeFile(PATH_site . $filename . '.gzip', gzencode($contents, $compressionLevel));
369 }
370 }
371
372 /**
373 * Decides whether a client can deal with gzipped content or not and returns the according file name,
374 * based on HTTP_ACCEPT_ENCODING
375 *
376 * @param string $filename File name
377 * @return string $filename suffixed with '.gz' or not - dependent on HTTP_ACCEPT_ENCODING
378 */
379 protected function returnFileReference($filename) {
380 // if the client accepts gzip and we can create gzipped files, we give him compressed versions
381 if ($this->createGzipped && strpos(t3lib_div::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== FALSE) {
382 return $filename . '.gzip';
383 } else {
384 return $filename;
385 }
386 }
387 }
388 ?>