Completed
Pull Request — master (#17)
by
unknown
02:40
created

CssEmbed::embedString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 1 Features 1
Metric Value
c 5
b 1
f 1
dl 0
loc 8
rs 9.4285
cc 1
eloc 5
nc 1
nop 1
1
<?php
2
/**
3
 * For the full copyright and license information, please view the LICENSE
4
 * file that was distributed with this source code.
5
 *  08/08/12 15:22
6
 */
7
8
namespace CssEmbed;
9
10
/**
11
 * CssEmbed
12
 *
13
 * @author Pierre Tachoire <[email protected]>
14
 */
15
class CssEmbed
16
{
17
18
    const SEARCH_PATTERN = "%url\\(['\" ]*((?!data:)[^'\" ]+)['\" ]*\\)%U";
19
    const DATA_URI_PATTERN = "url(data:%s;base64,%s)";
20
    const URL_URI_PATTERN = "url('%s')";
21
    const MIME_MAGIC_URL = 'http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types';
22
    const EMBED_FONTS = 1;
23
    const EMBED_SVG = 2;
24
    const URL_ON_ERROR = 4;
25
    const HTTP_DEFAULT_HTTPS = 1;
26
    const HTTP_EMBED_SCHEME = 2;
27
    const HTTP_EMBED_URL_ONLY = 4;
28
29
    /** @var string the root directory for finding assets */
30
    protected $root_dir;
31
32
    /** @var string the path to the local mime.magic database */
33
    protected $mime_magic_path = null;
34
35
    /** @var integer flags that modify behavior, embed SVG by default for BC */
36
    protected $flags = 2;
37
38
    /** @var bool enable HTTP asset fetching */
39
    protected $http_enabled = false;
40
41
    /** @var integer flags that modify behavior in HTTP only */
42
    protected $http_flags = 0;
43
44
    /**
45
     * @param $root_dir
46
     */
47
    public function setRootDir($root_dir)
48
    {
49
        $this->root_dir = $root_dir;
50
    }
51
52
    /**
53
     * Set embedding options. Flags:
54
     *
55
     *     - CssEmbed::EMBED_FONTS: embedding fonts will usually break them
56
     *       in most browsers.  Enable this flag to force the embed. WARNING:
57
     *       this flag is currently not unit tested, but seems to work.
58
     *     - CssEmbed::EMBED_SVG: SVG is often used as a font face; however
59
     *       including these in a stylesheet will cause it to bloat for browsers
60
     *       that don't use it.  SVGs will be embedded by default.
61
     *     - CssEmbed::URL_ON_ERROR: if there is an error fetching an asset,
62
     *       embed a URL (or best guess at URL) instead of throwing an exception
63
     *
64
     * @param integer $flags
65
     *
66
     * @return void
67
     */
68
    public function setOptions($flags)
69
    {
70
        $this->flags = $flags;
71
    }
72
73
    /**
74
     * Enable embedding assets over HTTP, or processing stylesheets from HTTP
75
     * locations. Available flags:
76
     *
77
     *     - CssEmbed::HTTP_DEFAULT_HTTPS: when HTTP assets are enabled, use
78
     *       HTTPS for URLs with no scheme
79
     *     - CssEmbed::HTTP_EMBED_SCHEME: By default, assets that are converted
80
     *       to URLs instead of data urls have no scheme (eg, "//example.com").
81
     *       This is better for stylesheets that are maybe served over http or
82
     *       https, but it will break stylesheets served from a local HTML file.
83
     *       Set this option to force the schema (eg, "http://example.com").
84
     *     - CssEmbed::HTTP_EMBED_URL_ONLY: do not convert assets to data URLs,
85
     *       only the fully qualified URL.
86
     *
87
     * @note this method will turn the options URL_ON_ERROR on and EMBED_SVG
88
     * off. You will need to use setOptions() after this method to change that.
89
     *
90
     * @param bool $enable
91
     * @param int $flags flags that modify HTTP behaviour
92
     * @return void
93
     */
94
    public function enableHttp($enable = true, $flags = 0)
95
    {
96
        $this->http_enabled = (bool) $enable;
97
        $this->flags = $this->flags|self::URL_ON_ERROR;
98
        $this->flags = $this->flags & (~ self::EMBED_SVG);
99
        $this->http_flags = (int) $flags;
100
    }
101
102
    /**
103
     * Enable the functionality to compare mimes against a custom mime.types file.
104
     *
105
     * @param string $path the path to the mime types file
106
     * @param bool $create download and save the Apache mime types file if the
107
     * specified path does not exist
108
     * @throws \InvalidArgumentException if the mime file does not exist and
109
     * cannot be created.
110
     * @return void
111
     */
112
    public function enableEnhancedMimeTypes(
113
        $path = '/tmp/cssembed.mime.types',
114
        $create = true
115
    ) {
116
        if (!file_exists($path) && $create) {
117
            if ($mime_types = @file_get_contents(self::MIME_MAGIC_URL)) {
118
                // special case: woff2 is too new
119
                if (strpos($mime_types, 'woff2') === false) {
120
                    $mime_types .= "\napplication/font-woff2 woff2";
121
                }
122
                file_put_contents($path, $mime_types);
123
                clearstatcache();
124
            }
125
        }
126
        if (!file_exists($path)) {
127
            $msg = sprintf('mime.types does not exist and cannot be created: "%s"', $path);
128
            throw new \InvalidArgumentException($msg);            
129
        }
130
        if (!is_readable($path) || !is_file($path)) {
131
            $msg = sprintf('Invalid mime.types file: "%s"', $path);
132
            throw new \InvalidArgumentException($msg);        
133
        }
134
        $this->mime_magic_path = $path;
135
    }
136
137
    /**
138
     * @param $css_file
139
     * @return null|string
140
     * @throws \InvalidArgumentException
141
     */
142
    public function embedCss($css_file)
143
    {
144
        $this->setRootDir(dirname($css_file));
145
        $content = @file_get_contents($css_file);
146
        if ($content === false) {
147
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $css_file));
148
        }
149
        return $this->embedString($content);
150
    }
151
152
    /**
153
     * @param $content
154
     * @return mixed
155
     */
156
    public function embedString($content)
157
    {
158
        return preg_replace_callback(
159
            self::SEARCH_PATTERN,
160
            array($this, 'replace'),
161
            $content
162
        );
163
    }
164
165
    /**
166
     * preg_replace_callback callback for embedString.
167
     *
168
     * @param array $matches
169
     * @return string
170
     */
171
    protected function replace($matches)
172
    {
173
        if ($asset = $this->fetchAsset($matches[1])) {
174
            if ($this->assetIsEmbeddable($asset)) {
175
                return sprintf(
176
                    self::DATA_URI_PATTERN,
177
                    $asset['mime'],
178
                    base64_encode($asset['content'])
179
                );
180
            }
181
        }
182
        if ($url = $this->fetchAssetUrl($matches[1])) {
183
            return sprintf(self::URL_URI_PATTERN, $url);
184
        }
185
        return $matches[0];    
186
    }
187
188
    /**
189
     * Fetch an asset
190
     *
191
     * @param string $path the asset path
192
     * @return array|false an array with keys 'content' for the file content
193
     * and 'mime' for the mime type, or FALSE on error
194
     */
195
    protected function fetchAsset($path)
196
    {
197
        $asset = false;
198
        if ($this->isHttpAsset($path)) {
199
            if ($url = $this->resolveAssetUrl($path)) {
200
                $asset = $this->fetchHttpAsset($url);
201
            }
202
        } else {
203
            if ($absolute_path = $this->resolveAssetPath($path)) {
204
                $asset = $this->fetchLocalAsset($absolute_path);
205
            }
206
        }
207
        return $asset;
208
    }
209
    
210
    /**
211
     * Get the URL to an asset as it would be embedded in a stylesheet
212
     *
213
     * @param string $path the path to the asset as it appears in the stylesheet
214
     * @return string $url the URL to the asset
215
     */
216
    protected function fetchAssetUrl($path)
217
    {
218
        if (!$this->isHttpAsset($path)) {
219
            return $path;
220
        }
221
        $url = $this->resolveAssetUrl($path); 
222
        if (!($this->http_flags & self::HTTP_EMBED_SCHEME)) {
223
            $url = preg_replace('/^https?:/', '', $url);
224
        }
225
        return $url;
226
    }
227
228
    /**
229
     * Fetch an asset stored locally in the filesystem
230
     *
231
     * @param string $absolute_path the absolute path to the asset
232
     * @return array same as fetchAsset
233
     */
234
    protected function fetchLocalAsset($absolute_path)
235
    {
236
        if (!is_file($absolute_path) || !is_readable($absolute_path)) {
237
            $this->error('Cannot read file %s', $absolute_path);
238
            return false;
239
        }
240
        $content = file_get_contents($absolute_path);
241
242
        $mime = $this->detectMime($absolute_path);
243
244
        if (!$mime && function_exists('mime_content_type')) {
245
            $mime = @mime_content_type($absolute_path);
246
        }
247
248
        if (!$mime && $info = @getimagesize($absolute_path)) {
249
            $mime = $info['mime'];
250
        }
251
252
        if (!$mime) {
253
            $mime = 'application/octet-stream';
254
        }
255
256
        return compact('content', 'mime');
257
    }
258
259
    /**
260
     * Fetch an asset stored remotely over HTTP
261
     *
262
     * @param string $url the url to the asset
263
     * @return array same as fetchAsset
264
     */
265
    protected function fetchHttpAsset($url)
266
    {
267
        if ($this->http_flags & self::HTTP_EMBED_URL_ONLY) {
268
            return false;
269
        }
270
        if (false === ($content = @file_get_contents($url))) {
271
            $this->error('Cannot read url %s', $url);
272
            return false;
273
        }
274
        if (!empty($http_response_header)) {
275
            foreach ($http_response_header as $header) {
276
                $header = strtolower($header);
277
                if (strpos($header, 'content-type:') === 0) {
278
                    $mime = trim(substr($header, strlen('content-type:')));
279
                }
280
            }
281
        }
282
        if (empty($mime)) {
283
            $this->error('No mime type sent with "%s"', $url);
284
            return false;
285
        }
286
        return compact('content', 'mime');
287
    }
288
289
    /**
290
     * Check if a successfully fetched an asset is of a type that can be
291
     * embedded given the current options.
292
     *
293
     * @param array $asset the return value of fetchAsset
294
     * @return boolean
295
     */
296
    protected function assetIsEmbeddable(array $asset)
297
    {
298
        $embed_fonts = ($this->flags & self::EMBED_FONTS);
299
        $is_font = strpos($asset['mime'], 'font') !== false;
300
        if ($is_font && !$embed_fonts) {
301
            return false;
302
        }
303
        
304
        $embed_svg = ($this->flags & self::EMBED_SVG);
305
        $is_svg = strpos($asset['mime'], 'svg') !== false;
306
        if ($is_svg && !($embed_svg || $embed_fonts)) {
307
            return false;
308
        }
309
        
310
        return true;
311
    }
312
313
    /**
314
     * Check if an asset is remote or local
315
     *
316
     * @param string $path the path specified in the CSS file
317
     *
318
     * @return bool
319
     */
320
    protected function isHttpAsset($path)
321
    {
322
        if (!$this->http_enabled) {
323
            return false;
324
        }
325
        // if the root directory is remote, all assets are remote
326
        $schemes = array('http://', 'https://', '//');
327
        foreach ($schemes as $scheme) {
328
            if (strpos($this->root_dir, $scheme) === 0) {
329
                return true;
330
            }
331
        }
332
        // check for remote embedded assets
333
        $schemes[] = '/'; // absolutes should be remote
334
        foreach ($schemes as $scheme) {
335
            if (strpos($path, $scheme) === 0) {
336
                return true;
337
            }
338
        }
339
        // otherwise, it's a local asset
340
        return false;
341
    }
342
343
    /**
344
     * Resolve the absolute path to a local asset
345
     *
346
     * @param string $path the path to the asset, relative to root_dir
347
     * @return string|boolean the absolute path, or false if not found
348
     */
349
    protected function resolveAssetPath($path)
350
    {
351
        if (preg_match('/[:\?#]/', $path)) {
352
            return false;
353
        }
354
        return realpath($this->root_dir . DIRECTORY_SEPARATOR . $path);
355
    }
356
357
    /**
358
     * Resolve the URL to an http asset
359
     *
360
     * @param string
361
     */
362
    protected function resolveAssetUrl($path)
363
    {
364
        $root_url = $this->root_dir;
365
        $default_scheme = ($this->http_flags & self::HTTP_DEFAULT_HTTPS)
366
                        ? 'https:'
367
                        : 'http:'
368
                        ;
369
370
        // case 1: path is already fully qualified url
371
        if (strpos($path, '//') === 0) {
372
            $path = $default_scheme . $path;
373
        }
374
        if (preg_match('/^https?:\/\//', $path)) {
375
            if (!filter_var($path, FILTER_VALIDATE_URL)) {
376
                $this->error('Invalid asset url "%s"', $path);
377
                return false;
378
            }
379
            return $path;
380
        }
381
382
        if (strpos($root_url, '//') === 0) {
383
            $root_url = $default_scheme . $root_url;
384
        }
385
        $root_domain = preg_replace('#^(https?://[^/]+).*#', '$1', $root_url);
386
        $root_path = substr($root_url, strlen($root_domain));
387
388
        // case 2: asset is absolute path
389
        if (strpos($path, '/') === 0) {
390
            return $root_domain . $path;
391
        }
392
393
        // case 3: asset is relative path        
394
        // remove directory transversal (file_get_contents seems to choke on it)
395
        $path = explode('/', $path);
396
        $root_path = array_filter(explode('/', $root_path));
397
        $asset_path = array();
398
        while (NULL !== ($part = array_shift($path))) {
399
            if ($part == '..') {
400
                array_pop($root_path);
401
            } elseif ($part && $part !== '.') {
402
                $asset_path[] = $part;
403
            }
404
        }
405
        $asset_path = implode('/', $asset_path);
406
        $root_path = empty($root_path) ? '/' : '/' . implode('/', $root_path) . '/';
407
408
        // ... and build the URL
409
        $url = $root_domain . $root_path . $asset_path;
410
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
411
            $this->error('Could not resolve "%s" with root "%s"', $path, $this->root_dir);
412
            return false;
413
        }
414
        return $url;
415
    }
416
417
    /**
418
     * Check the file mime type against the mime.types file
419
     *
420
     * @param string $path the path to the file
421
     * @return string the mime, or false if it could not be identified
422
     */
423
    protected function detectMime($path)
424
    {
425
        if (!$this->mime_magic_path) {
426
            return false;
427
        }
428
        $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
429
        if (!$ext) {
430
            return false;
431
        }
432
        $mime_types = file($this->mime_magic_path);
433
        foreach ($mime_types as $line) {
434
            if ($mime = $this->compareMime($ext, $line)) {
435
                return $mime;
436
            }
437
        }
438
        return false;
439
    }
440
441
    /**
442
     * Compare an extention against the a line in the mime.types
443
     *
444
     * @param string $ext the file extension
445
     * @param string $line the line from the mime.types file
446
     * @return string|bool the mime type if there is a match, false if not
447
     */
448
    protected function compareMime($ext, $line)
449
    {
450
        if (strpos($line, '#') === 0) {
451
            return false;
452
        }
453
        $line = preg_replace('/\s+/', ' ', $line);
454
        $line = array_filter(explode(' ', $line));
455
        $mime = array_shift($line);
456
        return in_array($ext, $line) ? $mime : false;
457
    }
458
459
    /**
460
     * Throw an exception if URL_ON_ERROR is not set
461
     *
462
     * This method accepts an unlimited number of arguments. They will be passed
463
     * to sprintf to generate the error message in the exception.  For example:
464
     *
465
     *     $this->error('My exception about %d %s', 4, 'cats');
466
     *
467
     * would throw an exception with with the message "My error about 4 cats".
468
     *
469
     * @throws \InvalidArgmumentException
470
     * @return void
471
     */
472
    protected function error()
473
    {
474
        if ($this->flags & self::URL_ON_ERROR) {
475
            return;
476
        }
477
        $args = func_get_args();
478
        if (empty($args)) {
479
            $args[] = 'Unknown Error';
480
        }
481
        $msg = count($args) > 1
482
             ? call_user_func_array('sprintf', $args)
483
             : array_shift($args)
484
             ;
485
        throw new \InvalidArgumentException($msg);
486
    }
487
}
488