Completed
Pull Request — master (#15)
by
unknown
02:08
created

CssEmbed::embedCss()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 2 Features 3
Metric Value
c 5
b 2
f 3
dl 0
loc 15
rs 9.4285
cc 3
eloc 10
nc 3
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 URI_PATTERN = "url(data:%s;base64,%s)";
20
21
    const HTTP_SEARCH_PATTERN = "%url\\(['\" ]*((?!data:)[^'\" ]+)['\" ]*\\)%U";
22
    const HTTP_ENABLED = 1;
23
    const HTTP_DEFAULT_HTTPS = 2;
24
    const HTTP_URL_ON_ERROR = 4;
25
    const HTTP_EMBED_FONTS = 8;
26
    const HTTP_EMBED_SVG = 16;
27
    const HTTP_EMBED_SCHEME = 32;
28
    const HTTP_EMBED_URL_ONLY = 64;
29
30
    protected $root_dir;
31
32
    /** @var integer the http flags */
33
    protected $http_flags = 0;
34
35
    /**
36
     * @param $root_dir
37
     */
38
    public function setRootDir($root_dir)
39
    {
40
        $this->root_dir = $root_dir;
41
    }
42
43
    /**
44
     * Allow assets referenced over HTTP to be embedded, or assets in a css
45
     * file loaded over HTTP. Flags:
46
     *
47
     *     - CssEmbed::HTTP_ENABLED: enable embedding over http;
48
     *     - CssEmbed::HTTP_DEFAULT_HTTPS: for URLs with no scheme, use https to
49
     *       instead of http
50
     *     - CssEmbed::HTTP_URL_ON_ERROR: if there is an error fetching a remote
51
     *       asset, embed the URL instead of throwing an exception
52
     *     - CssEmbed::HTTP_EMBED_FONTS: embedding fonts will usually break them
53
     *       in most browsers.  Enable this flag to force the embed. **WARNING**
54
     *       this flag is currently not tested, but seems to work.
55
     *     - CssEmbed::HTTP_EMBED_SVG: SVG is often used as a font face; however
56
     *       including these in a stylesheet will cause it to bloat for browsers
57
     *       that don't use it.  By default SVGs will be replaced with the URL
58
     *       to the asset; set this flag to force the embed of SVG files.
59
     *     - CssEmbed::HTTP_EMBED_SCHEME: By default, assets that are converted
60
     *       to URLs instead of data urls have no scheme (eg, "//example.com").
61
     *       This is better for stylesheets that are maybe served over http or
62
     *       https, but it will break stylesheets served from a local HTML file.
63
     *       Set this option to force the schema (eg, "http://example.com").
64
     *     - CssEmbed::HTTP_EMBED_URL_ONLY: do not convert assets to data URLs,
65
     *       only the fully qualified URL.
66
     *
67
     *
68
     * @param integer $flags
69
     *
70
     * @return void
71
     */
72
    public function enableHttp(
73
        $flags = CssEmbed::HTTP_ENABLED|CssEmbed::HTTP_URL_ON_ERROR
74
    ) {
75
        $this->http_flags = (int) $flags;
76
    }
77
78
    /**
79
     * Set a single http option flag. See `enableHttp` for a description of
80
     * available flags.
81
     *
82
     * @param integer $flag
83
     *
84
     * @return void
85
     */
86
    public function setHttpFlag($flag)
87
    {
88
        $this->http_flags |= $flag;
89
    }
90
91
    /**
92
     * unset a single http option flag. See `enableHttp` for a description of
93
     * available flags.
94
     *
95
     * @param integer $flag
96
     *
97
     * @return void
98
     */
99
    public function unsetHttpFlag($flag)
100
    {
101
        $this->http_flags = $this->http_flags & (~ $flag);
102
    }
103
104
    /**
105
     * @param $css_file
106
     * @return null|string
107
     * @throws \InvalidArgumentException
108
     */
109
    public function embedCss($css_file)
110
    {
111
        $this->setRootDir(dirname($css_file));
112
        $return = null;
113
        $handle = fopen($css_file, "r");
114
        if ($handle === false) {
115
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $css_file));
116
        }
117
        while (($line = fgets($handle)) !== false) {
118
            $return .= $this->embedString($line);
119
        }
120
        fclose($handle);
121
122
        return $return;
123
    }
124
125
    /**
126
     * @param $content
127
     * @return mixed
128
     */
129
    public function embedString($content)
130
    {
131
        if ($this->http_flags & self::HTTP_ENABLED) {
132
            return preg_replace_callback(
133
                self::HTTP_SEARCH_PATTERN,
134
                array($this, 'httpEnabledReplace'),
135
                $content
136
            );
137
        }
138
        return preg_replace_callback(self::SEARCH_PATTERN, array($this, 'replace'), $content);
139
    }
140
141
142
    /**
143
     * @param $matches
144
     * @return string
145
     */
146
    protected function replace($matches)
147
    {
148
        return $this->embedFile($this->root_dir . DIRECTORY_SEPARATOR . $matches[1]);
149
    }
150
151
    /**
152
     * @param $file
153
     * @return string
154
     */
155
    protected function embedFile($file)
156
    {
157
        return sprintf(self::URI_PATTERN, $this->mimeType($file), $this->base64($file));
158
    }
159
160
    /**
161
     * @param $file
162
     * @return string
163
     */
164
    protected function mimeType($file)
165
    {
166
        if (function_exists('mime_content_type')) {
167
            return mime_content_type($file);
168
        }
169
170
        if ($info = @getimagesize($file)) {
171
            return($info['mime']);
172
        }
173
174
        return 'application/octet-stream';
175
    }
176
177
    /**
178
     * @param $file
179
     * @return string
180
     * @throws \InvalidArgumentException
181
     */
182
    protected function base64($file)
183
    {
184
        if (is_file($file) === false || is_readable($file) === false) {
185
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $file));
186
        }
187
188
        return base64_encode(file_get_contents($file));
189
    }
190
191
    /**
192
     * @param $matches
193
     * @return string
194
     */
195
    protected function httpEnabledReplace($matches)
196
    {
197
        // fall back to default functionality for non-remote assets
198
        if (!$this->isHttpAsset($matches[1])) {
199
            if (preg_match('/[#\?:]/', $matches[1])) {
200
                return $matches[0];
201
            }
202
            return $this->replace($matches);
203
        }
204
        if ($asset_url = $this->resolveHttpAssetUrl($this->root_dir, $matches[1])) {
205
            if ($replacement = $this->embedHttpAsset($asset_url)) {
206
                return $replacement;
207
            }
208
            return $this->embedHttpAssetUrl($asset_url);
209
        }
210
        return $matches[0];
211
    }
212
213
    /**
214
     * Get the contents of a URL and return it as a data uri within url()
215
     *
216
     * @param string $url the URL to the file to embed
217
     * @return string|bool the string for the CSS url property, or FALSE if the
218
     * url could not/should not be embedded.
219
     */
220
    protected function embedHttpAsset($url)
221
    {
222
        if ($this->http_flags & self::HTTP_EMBED_URL_ONLY) {
223
            return false;
224
        }
225
        if (false === ($content = @file_get_contents($url))) {
226
            $this->httpError('Cannot read url %s', $url);
227
            return false;
228
        }
229
        if (!empty($http_response_header)) {
230
            foreach ($http_response_header as $header) {
231
                $header = strtolower($header);
232
                if (strpos($header, 'content-type:') === 0) {
233
                    $mime = trim(substr($header, strlen('content-type:')));
234
                }
235
            }
236
        }
237
        if (empty($mime)) {
238
            $this->httpError('No mime type sent with "%s"', $url);
239
            return false;
240
        }
241
242
        // handle a special case: fonts will usually break if embedded, but
243
        // user can force
244
        $embed_fonts = ($this->http_flags & self::HTTP_EMBED_FONTS);
245
        $is_font = strpos($mime, 'font') !== false;
246
        if ($is_font && !$embed_fonts) {
247
            return false;
248
        }
249
        
250
        // another special case:  SVG is often a font and will cause the
251
        // stylesheet to bloat if it's embeded for browsers that don't use it.
252
        $embed_svg = ($this->http_flags & self::HTTP_EMBED_SVG);
253
        $is_svg = strpos($mime, 'svg') !== false;
254
        if ($is_svg && !($embed_svg || $embed_fonts)) {
255
            return false;
256
        }
257
        
258
        return sprintf(self::URI_PATTERN, $mime, base64_encode($content));
259
    }
260
261
    /**
262
     * For URLs that could not/should not be embedded, embed the resolved URL
263
     * instead.
264
     *
265
     * @param string $url
266
     * @return string
267
     */
268
    protected function embedHttpAssetUrl($url)
269
    {
270
        if (!($this->http_flags & self::HTTP_EMBED_SCHEME)) {
271
            $url = preg_replace('/^https?:/', '', $url);
272
        }
273
        return sprintf("url('%s')", $url);
274
    }
275
276
    /**
277
     * Check if an asset is remote or local
278
     *
279
     * @param string $path the path specified in the CSS file
280
     *
281
     * @return bool
282
     */
283
    protected function isHttpAsset($path)
284
    {
285
        // if the root directory is remote, all assets are remote
286
        $schemes = array('http://', 'https://', '//');
287
        foreach ($schemes as $scheme) {
288
            if (strpos($this->root_dir, $scheme) === 0) {
289
                return true;
290
            }
291
        }
292
        // check for remote embedded assets
293
        foreach ($schemes as $scheme) {
294
            if (strpos($path, $scheme) === 0) {
295
                return true;
296
            }
297
        }
298
        // absolutes should be remote
299
        if (strpos($path, '/') === 0) {
300
            return true;
301
        }
302
        // otherwise, it's a local asset
303
        return false;
304
    }
305
306
    /**
307
     * Resolve the URL to an http asset
308
     *
309
     * @param string $root_url the root URL
310
     * @param string
311
     */
312
    protected function resolveHttpAssetUrl($root_url, $path)
313
    {
314
        $default_scheme = ($this->http_flags & self::HTTP_DEFAULT_HTTPS)
315
                        ? 'https:'
316
                        : 'http:'
317
                        ;
318
319
        // case 1: path is already fully qualified url
320
        if (strpos($path, '//') === 0) {
321
            $path = $default_scheme . $path;
322
        }
323
        if (preg_match('/^https?:\/\//', $path)) {
324
            if (!filter_var($path, FILTER_VALIDATE_URL)) {
325
                $this->httpError('Invalid asset url "%s"', $path);
326
                return false;
327
            }
328
            return $path;
329
        }
330
331
        if (strpos($root_url, '//') === 0) {
332
            $root_url = $default_scheme . $root_url;
333
        }
334
        $root_domain = preg_replace('#^(https?://[^/]+).*#', '$1', $root_url);
335
        $root_path = substr($root_url, strlen($root_domain));
336
337
        // case 2: asset is absolute path
338
        if (strpos($path, '/') === 0) {
339
            return $root_domain . $path;
340
        }
341
342
        // case 3: asset is relative path        
343
        // remove directory transversal (file_get_contents seems to choke on it)
344
        $path = explode('/', $path);
345
        $root_path = array_filter(explode('/', $root_path));
346
        $asset_path = array();
0 ignored issues
show
Unused Code introduced by
$asset_path is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
347
        while (NULL !== ($part = array_shift($path))) {
348
            if (!$part || $part === '.') {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
349
                // drop the empty part
350
            } elseif ($part == '..') {
351
                array_pop($root_path);
352
            } else {
353
                $_path[] = $part;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$_path was never initialized. Although not strictly required by PHP, it is generally a good practice to add $_path = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
354
            }
355
        }
356
        $asset_path = implode('/', $_path);
0 ignored issues
show
Bug introduced by
The variable $_path does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
357
        $root_path = empty($root_path) ? '/' : '/' . implode('/', $root_path) . '/';
358
359
        // ... and build the URL
360
        $url = $root_domain . $root_path . $asset_path;
361
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
362
            $this->httpError('Could not resolve "%s" with root "%s"', $path, $this->root_dir);
363
            return false;
364
        }
365
        return $url;
366
    }
367
368
    /**
369
     * Throw an exception if HTTP_URL_ON_ERROR is not set
370
     *
371
     * @param string $msg the message
372
     * @param string $interpolations... strings to interpolate in the error message
0 ignored issues
show
Documentation introduced by
There is no parameter named $interpolations.... Did you maybe mean $interpolations?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

Consider the following example. The parameter $ireland is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $ireland
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was changed, but the annotation was not.

Loading history...
373
     * @throws \InvalidArgmumentException
374
     * @return void
375
     */
376
    protected function httpError($msg, $interpolations)
0 ignored issues
show
Unused Code introduced by
The parameter $msg is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $interpolations is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
377
    {
378
        if ($this->http_flags & self::HTTP_URL_ON_ERROR) {
379
            return;
380
        }
381
        $msg = call_user_func_array('sprintf', func_get_args());
382
        throw new \InvalidArgumentException($msg);
383
    }
384
}
385