• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

timber / timber / 15920925840

27 Jun 2025 07:39AM UTC coverage: 88.19%. Remained the same
15920925840

push

github

web-flow
chore(deps): update .lock file and update ci steps

* chore(deps): update package versions in composer.lock
* ci: run ci also on .lock file
* chore(deps): remove deprecated twig/cache-extension dependency
* chore: add step to remove composer.lock before installing dependencies
* chore: fix shell command syntax for removing composer.lock

3883 of 4403 relevant lines covered (88.19%)

64.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.86
/src/ImageHelper.php
1
<?php
2

3
namespace Timber;
4

5
use Timber\Image\Operation;
6

7
/**
8
 * Class ImageHelper
9
 *
10
 * Implements the Twig image filters:
11
 * https://timber.github.io/docs/v2/guides/cookbook-images/#arbitrary-resizing-of-images
12
 * - resize
13
 * - retina
14
 * - letterbox
15
 * - tojpg
16
 *
17
 * Implementation:
18
 * - public static functions provide the methods that are called by the filter
19
 * - most of the work is common to all filters (URL analysis, directory gymnastics, file caching, error management) and done by private static functions
20
 * - the specific part (actual image processing) is delegated to dedicated subclasses of TimberImageOperation
21
 *
22
 * @api
23
 */
24
class ImageHelper
25
{
26
    public const BASE_UPLOADS = 1;
27

28
    public const BASE_CONTENT = 2;
29

30
    public static $home_url;
31

32
    /**
33
     * Inits the object.
34
     */
35
    public static function init()
36
    {
37
        self::$home_url = \get_home_url();
1✔
38
        \add_action('delete_attachment', [self::class, 'delete_attachment']);
1✔
39
        \add_filter('wp_generate_attachment_metadata', [self::class, 'generate_attachment_metadata'], 10, 2);
1✔
40
        \add_filter('upload_dir', [self::class, 'add_relative_upload_dir_key']);
1✔
41
        return true;
1✔
42
    }
43

44
    /**
45
     * Generates a new image with the specified dimensions.
46
     *
47
     * New dimensions are achieved by cropping to maintain ratio.
48
     *
49
     * @api
50
     * @example
51
     * ```twig
52
     * <img src="{{ image.src | resize(300, 200, 'top') }}" />
53
     * ```
54
     * ```html
55
     * <img src="https://example.org/wp-content/uploads/pic-300x200-c-top.jpg" />
56
     * ```
57
     *
58
     * @param string     $src   A URL (absolute or relative) to the original image.
59
     * @param int|string $w     Target width (int) or WordPress image size (WP-set or
60
     *                          user-defined).
61
     * @param int        $h     Optional. Target height (ignored if `$w` is WP image size). If not
62
     *                          set, will ignore and resize based on `$w` only. Default `0`.
63
     * @param string     $crop  Optional. Your choices are `default`, `center`, `top`, `bottom`,
64
     *                          `left`, `right`. Default `default`.
65
     * @param bool       $force Optional. Whether to remove any already existing result file and
66
     *                          force file generation. Default `false`.
67
     * @return string The URL of the resized image.
68
     */
69
    public static function resize($src, $w, $h = 0, $crop = 'default', $force = false)
70
    {
71
        if (!\is_numeric($w) && \is_string($w)) {
49✔
72
            if ($sizes = self::find_wp_dimensions($w)) {
4✔
73
                $w = $sizes['w'];
3✔
74
                $h = $sizes['h'];
3✔
75
            } else {
76
                return $src;
1✔
77
            }
78
        }
79
        $op = new Operation\Resize($w, $h, $crop);
48✔
80
        return self::_operate($src, $op, $force);
48✔
81
    }
82

83
    /**
84
     * Finds the sizes of an image based on a defined image size.
85
     *
86
     * @internal
87
     * @param  string $size The image size to search for can be WordPress-defined ('medium') or
88
     *                      user-defined ('my-awesome-size').
89
     * @return false|array An array with `w` and `h` height key, corresponding to the width and the
90
     *                     height of the image.
91
     */
92
    private static function find_wp_dimensions($size)
93
    {
94
        global $_wp_additional_image_sizes;
95
        if (isset($_wp_additional_image_sizes[$size])) {
4✔
96
            $w = $_wp_additional_image_sizes[$size]['width'];
2✔
97
            $h = $_wp_additional_image_sizes[$size]['height'];
2✔
98
        } elseif (\in_array($size, ['thumbnail', 'medium', 'large'])) {
2✔
99
            $w = \get_option($size . '_size_w');
1✔
100
            $h = \get_option($size . '_size_h');
1✔
101
        }
102
        if (isset($w) && isset($h) && ($w || $h)) {
4✔
103
            return [
3✔
104
                'w' => $w,
3✔
105
                'h' => $h,
3✔
106
            ];
3✔
107
        }
108
        return false;
1✔
109
    }
110

111
    /**
112
     * Generates a new image with increased size, for display on Retina screens.
113
     *
114
     * @api
115
     *
116
     * @param string  $src        URL of the file to read from.
117
     * @param float   $multiplier Optional. Factor the original dimensions should be multiplied
118
     *                            with. Default `2`.
119
     * @param boolean $force      Optional. Whether to remove any already existing result file and
120
     *                            force file generation. Default `false`.
121
     * @return string URL to the new image.
122
     */
123
    public static function retina_resize($src, $multiplier = 2, $force = false)
124
    {
125
        $op = new Operation\Retina($multiplier);
7✔
126
        return self::_operate($src, $op, $force);
7✔
127
    }
128

129
    /**
130
     * Checks to see if the given file is an animated GIF.
131
     *
132
     * @api
133
     *
134
     * @param string $file Local filepath to a file, not a URL.
135
     * @return boolean True if it’s an animated GIF, false if not.
136
     */
137
    public static function is_animated_gif($file)
138
    {
139
        if (!\str_contains(\strtolower($file), '.gif')) {
41✔
140
            //doesn't have .gif, bail
141
            return false;
35✔
142
        }
143
        // Its a gif so test
144
        if (!($fh = @\fopen($file, 'rb'))) {
6✔
145
            return false;
1✔
146
        }
147
        $count = 0;
5✔
148
        // An animated gif contains multiple "frames", with each frame having a
149
        // header made up of:
150
        // * a static 4-byte sequence (\x00\x21\xF9\x04).
151
        // * 4 variable bytes.
152
        // * a static 2-byte sequence (\x00\x2C).
153
        // We read through the file til we reach the end of the file, or we've found.
154
        // at least 2 frame headers.
155
        while (!\feof($fh) && $count < 2) {
5✔
156
            $chunk = \fread($fh, 1024 * 100); //read 100kb at a time
5✔
157
            $count += \preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', $chunk, $matches);
5✔
158
        }
159

160
        \fclose($fh);
5✔
161
        return $count > 1;
5✔
162
    }
163

164
    /**
165
     * Checks if file is an SVG.
166
     *
167
     * @param string $file_path File path to check.
168
     * @return bool True if SVG, false if not SVG or file doesn't exist.
169
     */
170
    public static function is_svg($file_path)
171
    {
172
        if ('' === $file_path || !\file_exists($file_path)) {
73✔
173
            return false;
2✔
174
        }
175

176
        if (\str_ends_with(\strtolower($file_path), '.svg')) {
71✔
177
            return true;
8✔
178
        }
179

180
        /**
181
         * Try reading mime type.
182
         *
183
         * SVG images are not allowed by default in WordPress, so we have to pass a default mime
184
         * type for SVG images.
185
         */
186
        $mime = \wp_check_filetype_and_ext($file_path, PathHelper::basename($file_path), [
63✔
187
            'svg' => 'image/svg+xml',
63✔
188
        ]);
63✔
189

190
        return \in_array($mime['type'], [
63✔
191
            'image/svg+xml',
63✔
192
            'text/html',
63✔
193
            'text/plain',
63✔
194
            'image/svg',
63✔
195
        ]);
63✔
196
    }
197

198
    /**
199
     * Generate a new image with the specified dimensions.
200
     *
201
     * New dimensions are achieved by adding colored bands to maintain ratio.
202
     *
203
     * @api
204
     *
205
     * @param string      $src
206
     * @param int         $w
207
     * @param int         $h
208
     * @param string|bool $color
209
     * @param bool        $force
210
     * @return string
211
     */
212
    public static function letterbox($src, $w, $h, $color = false, $force = false)
213
    {
214
        $op = new Operation\Letterbox($w, $h, $color);
14✔
215
        return self::_operate($src, $op, $force);
14✔
216
    }
217

218
    /**
219
     * Generates a new image by converting the source GIF or PNG into JPG.
220
     *
221
     * @api
222
     *
223
     * @param string $src   A URL or path to the image
224
     *                      (https://example.org/wp-content/uploads/2014/image.jpg) or
225
     *                      (/wp-content/uploads/2014/image.jpg).
226
     * @param string $bghex The hex color to use for transparent zones.
227
     * @return string The URL of the processed image.
228
     */
229
    public static function img_to_jpg($src, $bghex = '#FFFFFF', $force = false)
230
    {
231
        $op = new Operation\ToJpg($bghex);
9✔
232
        return self::_operate($src, $op, $force);
9✔
233
    }
234

235
    /**
236
     * Generates a new image by converting the source into WEBP if supported by the server.
237
     *
238
     * @param string $src     A URL or path to the image
239
     *                        (https://example.org/wp-content/uploads/2014/image.webp) or
240
     *                        (/wp-content/uploads/2014/image.webp).
241
     * @param int    $quality Range from `0` (worst quality, smaller file) to `100` (best quality,
242
     *                        biggest file).
243
     * @param bool   $force   Optional. Whether to remove any already existing result file and
244
     *                        force file generation. Default `false`.
245
     * @return string The URL of the processed image. If webp is not supported, a jpeg image will be
246
     *                        generated.
247
     */
248
    public static function img_to_webp($src, $quality = 80, $force = false)
249
    {
250
        $op = new Operation\ToWebp($quality);
8✔
251
        return self::_operate($src, $op, $force);
8✔
252
    }
253

254
    //-- end of public methods --//
255

256
    /**
257
     * Deletes all resized versions of an image when the source is deleted.
258
     *
259
     * @since 1.5.0
260
     * @param int   $post_id An attachment ID.
261
     */
262
    public static function delete_attachment($post_id)
263
    {
264
        self::_delete_generated_if_image($post_id);
2✔
265
    }
266

267
    /**
268
     * Delete all resized version of an image when its meta data is regenerated.
269
     *
270
     * @since 1.5.0
271
     * @param array $metadata Existing metadata.
272
     * @param int   $post_id  An attachment ID.
273
     * @return array
274
     */
275
    public static function generate_attachment_metadata($metadata, $post_id)
276
    {
277
        self::_delete_generated_if_image($post_id);
56✔
278
        return $metadata;
56✔
279
    }
280

281
    /**
282
     * Adds a 'relative' key to wp_upload_dir() result.
283
     *
284
     * It will contain the relative url to upload dir.
285
     *
286
     * @since 1.5.0
287
     * @param array $arr
288
     * @return array
289
     */
290
    public static function add_relative_upload_dir_key($arr)
291
    {
292
        $arr['relative'] = \str_replace(self::$home_url, '', (string) $arr['baseurl']);
189✔
293
        return $arr;
189✔
294
    }
295

296
    /**
297
     * Checks if attachment is an image before deleting generated files.
298
     *
299
     * @param int $post_id An attachment ID.
300
     */
301
    public static function _delete_generated_if_image($post_id)
302
    {
303
        if (\wp_attachment_is_image($post_id)) {
57✔
304
            $attachment = Timber::get_post($post_id);
47✔
305
            /** @var Attachment $attachment */
306
            if ($file_loc = $attachment->file_loc()) {
47✔
307
                ImageHelper::delete_generated_files($file_loc);
47✔
308
            }
309
        }
310
    }
311

312
    /**
313
     * Deletes the auto-generated files for resize and letterboxing created by Timber.
314
     *
315
     * @param string $local_file ex: /var/www/wp-content/uploads/2015/my-pic.jpg
316
     *                           or: https://example.org/wp-content/uploads/2015/my-pic.jpg
317
     */
318
    public static function delete_generated_files($local_file)
319
    {
320
        if (URLHelper::is_absolute($local_file)) {
53✔
321
            $local_file = URLHelper::url_to_file_system($local_file);
1✔
322
        }
323
        $info = PathHelper::pathinfo($local_file);
53✔
324
        $dir = $info['dirname'];
53✔
325
        $ext = $info['extension'];
53✔
326
        $filename = $info['filename'];
53✔
327
        self::process_delete_generated_files($filename, $ext, $dir, '-[0-9999999]*', '-[0-9]*x[0-9]*-c-[a-z]*.');
53✔
328
        self::process_delete_generated_files($filename, $ext, $dir, '-lbox-[0-9999999]*', '-lbox-[0-9]*x[0-9]*-[a-zA-Z0-9]*.');
53✔
329
        self::process_delete_generated_files($filename, 'jpg', $dir, '-tojpg.*');
53✔
330
        self::process_delete_generated_files($filename, 'jpg', $dir, '-tojpg-[0-9999999]*');
53✔
331
    }
332

333
    /**
334
     * Deletes resized versions of the supplied file name.
335
     *
336
     * If passed a value like my-pic.jpg, this function will delete my-pic-500x200-c-left.jpg, my-pic-400x400-c-default.jpg, etc.
337
     *
338
     * Keeping these here so I know what the hell we’re matching
339
     * $match = preg_match("/\/srv\/www\/wordpress-develop\/src\/wp-content\/uploads\/2014\/05\/$filename-[0-9]*x[0-9]*-c-[a-z]*.jpg/", $found_file);
340
     * $match = preg_match("/\/srv\/www\/wordpress-develop\/src\/wp-content\/uploads\/2014\/05\/arch-[0-9]*x[0-9]*-c-[a-z]*.jpg/", $filename);
341
     *
342
     * @param string  $filename       ex: my-pic.
343
     * @param string  $ext            ex: jpg.
344
     * @param string  $dir            var/www/wp-content/uploads/2015/.
345
     * @param string  $search_pattern Pattern of files to pluck from.
346
     * @param string  $match_pattern  Pattern of files to go forth and delete.
347
     */
348
    protected static function process_delete_generated_files($filename, $ext, $dir, $search_pattern, $match_pattern = null)
349
    {
350
        $searcher = '/' . $filename . $search_pattern;
53✔
351
        $files = \glob($dir . $searcher);
53✔
352
        if ($files === false || empty($files)) {
53✔
353
            return;
53✔
354
        }
355
        foreach ($files as $found_file) {
51✔
356
            $pattern = '/' . \preg_quote($dir, '/') . '\/' . \preg_quote($filename, '/') . $match_pattern . \preg_quote($ext, '/') . '/';
51✔
357
            $match = \preg_match($pattern, $found_file);
51✔
358
            if (!$match_pattern || $match) {
51✔
359
                \unlink($found_file);
9✔
360
            }
361
        }
362
    }
363

364
    /**
365
     * Determines the filepath corresponding to a given URL.
366
     *
367
     * @param string $url
368
     * @return string
369
     */
370
    public static function get_server_location($url)
371
    {
372
        // if we're already an absolute dir, just return.
373
        if (\str_starts_with($url, (string) ABSPATH)) {
14✔
374
            return $url;
1✔
375
        }
376
        // otherwise, analyze URL then build mapping path
377
        $au = self::analyze_url($url);
13✔
378
        $result = self::_get_file_path($au['base'], $au['subdir'], $au['basename']);
13✔
379
        return $result;
13✔
380
    }
381

382
    /**
383
     * Determines the filepath where a given external file will be stored.
384
     *
385
     * @param string  $file
386
     * @return string
387
     */
388
    public static function get_sideloaded_file_loc($file)
389
    {
390
        $upload = \wp_upload_dir();
14✔
391
        $dir = $upload['path'];
14✔
392
        $filename = $file;
14✔
393
        $file = \parse_url($file);
14✔
394
        $path_parts = PathHelper::pathinfo($file['path']);
14✔
395
        $basename = \md5($filename);
14✔
396

397
        /**
398
         * Filters basename for sideloaded files.
399
         * @since 2.1.0
400
         * @example
401
         * ```php
402
         * // Change the basename used for sideloaded images.
403
         * add_filter( 'timber/sideload_image/basename', function ($basename, $path_parts) {
404
         *     return $path_parts['filename'] . '-' . substr($basename, 0, 6);
405
         * }, 10, 2)
406
         * ```
407
         *
408
         * @param string $basename Current basename for the sideloaded file.
409
         * @param array $path_parts Array with path info for the sideloaded file.
410
         */
411
        $basename = \apply_filters('timber/sideload_image/basename', $basename, $path_parts);
14✔
412

413
        $ext = 'jpg';
14✔
414
        if (isset($path_parts['extension'])) {
14✔
415
            $ext = $path_parts['extension'];
13✔
416
        }
417
        return $dir . '/' . $basename . '.' . $ext;
14✔
418
    }
419

420
    /**
421
     * Downloads an external image to the server and stores it on the server.
422
     *
423
     * External/sideloaded images are saved in a folder named **external** in the uploads folder. If you want to change
424
     * the folder that is used for your sideloaded images, you can use the
425
     * [`timber/sideload_image/subdir`](https://timber.github.io/docs/v2/hooks/filters/#timber/sideload_image/subdir)
426
     * filter. You can disable this behavior using the same filter.
427
     *
428
     * @param string $file The URL to the original file.
429
     *
430
     * @return string The URL to the downloaded file.
431
     */
432
    public static function sideload_image($file)
433
    {
434
        /**
435
         * Adds a filter to change the upload folder temporarily.
436
         *
437
         * This is necessary so that external images are not downloaded every month in case
438
         * year-month-based folders are used. We need to use the `upload_dir` filter, because we use
439
         * functions like `wp_upload_bits()` which uses `wp_upload_dir()` under the hood.
440
         *
441
         * @ticket 1098
442
         * @link https://github.com/timber/timber/issues/1098
443
         */
444
        \add_filter('upload_dir', [self::class, 'set_sideload_image_upload_dir']);
14✔
445

446
        $loc = self::get_sideloaded_file_loc($file);
14✔
447
        if (\file_exists($loc)) {
14✔
448
            $url = URLHelper::file_system_to_url($loc);
11✔
449

450
            \remove_filter('upload_dir', [self::class, 'set_sideload_image_upload_dir']);
11✔
451

452
            return $url;
11✔
453
        }
454
        // Download file to temp location
455
        if (!\function_exists('download_url')) {
3✔
456
            // @phpstan-ignore requireOnce.fileNotFound
457
            require_once ABSPATH . '/wp-admin/includes/file.php';
×
458
        }
459
        $tmp = \download_url($file);
3✔
460
        \preg_match('/[^\?]+\.(jpe?g|jpe|gif|png)\b/i', $file, $matches);
3✔
461

462
        $file_array = [];
3✔
463
        $file_array['tmp_name'] = $tmp;
3✔
464
        // If error storing temporarily, do not use
465
        if (\is_wp_error($tmp)) {
3✔
466
            $file_array['tmp_name'] = '';
×
467
        }
468
        // do the validation and storage stuff
469
        $locinfo = PathHelper::pathinfo($loc);
3✔
470
        $file = \wp_upload_bits($locinfo['basename'], null, \file_get_contents($file_array['tmp_name']));
3✔
471
        // delete tmp file
472
        @\unlink($file_array['tmp_name']);
3✔
473

474
        \remove_filter('upload_dir', [self::class, 'set_sideload_image_upload_dir']);
3✔
475

476
        return $file['url'];
3✔
477
    }
478

479
    /**
480
     * Gets upload folder definition for sideloaded images.
481
     *
482
     * Used by ImageHelper::sideload_image().
483
     *
484
     * @internal
485
     * @since 2.0.0
486
     * @see   \Timber\ImageHelper::sideload_image()
487
     *
488
     * @param array $upload Array of information about the upload directory.
489
     *
490
     * @return array         Array of information about the upload directory, modified by this
491
     *                        function.
492
     */
493
    public static function set_sideload_image_upload_dir(array $upload)
494
    {
495
        $subdir = 'external';
14✔
496

497
        /**
498
         * Filters to directory that should be used for sideloaded images.
499
         *
500
         * @since 2.0.0
501
         * @example
502
         * ```php
503
         * // Change the subdirectory used for sideloaded images.
504
         * add_filter( 'timber/sideload_image/subdir', function( $subdir ) {
505
         *     return 'sideloaded';
506
         * } );
507
         *
508
         * // Disable subdirectory used for sideloaded images.
509
         * add_filter( 'timber/sideload_image/subdir', '__return_false' );
510
         * ```
511
         *
512
         * @param string $subdir The subdir name to use for sideloaded images. Return an empty
513
         *                       string or a falsey value in order to not use a subfolder. Default
514
         *                       `external`.
515
         */
516
        $subdir = \apply_filters('timber/sideload_image/subdir', $subdir);
14✔
517

518
        if (!empty($subdir)) {
14✔
519
            // Remove slashes before or after.
520
            $subdir = \trim((string) $subdir, '/');
12✔
521

522
            $upload['subdir'] = '/' . $subdir;
12✔
523
            $upload['path'] = $upload['basedir'] . $upload['subdir'];
12✔
524
            $upload['url'] = $upload['baseurl'] . $upload['subdir'];
12✔
525
        }
526

527
        return $upload;
14✔
528
    }
529

530
    /**
531
     * Takes a URL and breaks it into components.
532
     *
533
     * The components can then be used in the different steps of image processing.
534
     * The image is expected to be either part of a theme, plugin, or an upload.
535
     *
536
     * @param  string $url A URL (absolute or relative) pointing to an image.
537
     * @return array<string, mixed> An array (see keys in code below).
538
     */
539
    public static function analyze_url(string $url): array
540
    {
541
        /**
542
         * Filters whether to short-circuit the ImageHelper::analyze_url()
543
         * file path of a URL located in a theme directory.
544
         *
545
         * Returning a non-null value from the filter will short-circuit
546
         * ImageHelper::analyze_url(), returning that value.
547
         *
548
         * @since 2.0.0
549
         *
550
         * @param array<string, mixed>|null $info The URL components array to short-circuit with. Default null.
551
         * @param string                    $url  The URL pointing to an image.
552
         */
553
        $result = \apply_filters('timber/image_helper/pre_analyze_url', null, $url);
90✔
554
        if (null === $result) {
90✔
555
            $result = self::get_url_components($url);
89✔
556
        }
557

558
        /**
559
         * Filters the array of analyzed URL components.
560
         *
561
         * @since 2.0.0
562
         *
563
         * @param array<string, mixed> $info The URL components.
564
         * @param string               $url  The URL pointing to an image.
565
         */
566
        return \apply_filters('timber/image_helper/analyze_url', $result, $url);
90✔
567
    }
568

569
    /**
570
     * Returns information about a URL.
571
     *
572
     * @param  string $url A URL (absolute or relative) pointing to an image.
573
     * @return array<string, mixed> An array (see keys in code below).
574
     */
575
    private static function get_url_components(string $url): array
576
    {
577
        $result = [
89✔
578
            // the initial url
579
            'url' => $url,
89✔
580
            // is the url absolute or relative (to home_url)
581
            'absolute' => URLHelper::is_absolute($url),
89✔
582
            // is the image in uploads dir, or in content dir (theme or plugin)
583
            'base' => 0,
89✔
584
            // the path between base (uploads or content) and file
585
            'subdir' => '',
89✔
586
            // the filename, without extension
587
            'filename' => '',
89✔
588
            // the file extension
589
            'extension' => '',
89✔
590
            // full file name
591
            'basename' => '',
89✔
592
        ];
89✔
593

594
        $upload_dir = \wp_upload_dir();
89✔
595
        $tmp = $url;
89✔
596
        if (\str_starts_with($tmp, (string) ABSPATH) || \str_starts_with($tmp, '/srv/www/')) {
89✔
597
            // we've been given a dir, not an url
598
            $result['absolute'] = true;
26✔
599
            if (\str_starts_with($tmp, (string) $upload_dir['basedir'])) {
26✔
600
                $result['base'] = self::BASE_UPLOADS; // upload based
24✔
601
                $tmp = URLHelper::remove_url_component($tmp, $upload_dir['basedir']);
24✔
602
            }
603
            if (\str_starts_with($tmp, (string) WP_CONTENT_DIR)) {
26✔
604
                $result['base'] = self::BASE_CONTENT; // content based
2✔
605
                $tmp = URLHelper::remove_url_component($tmp, WP_CONTENT_DIR);
26✔
606
            }
607
        } else {
608
            if (!$result['absolute']) {
63✔
609
                $tmp = \untrailingslashit(\network_home_url()) . $tmp;
5✔
610
            }
611
            if (URLHelper::starts_with($tmp, $upload_dir['baseurl'])) {
63✔
612
                $result['base'] = self::BASE_UPLOADS; // upload based
62✔
613
                $tmp = URLHelper::remove_url_component($tmp, $upload_dir['baseurl']);
62✔
614
            } elseif (URLHelper::starts_with($tmp, \content_url())) {
1✔
615
                $result['base'] = self::BASE_CONTENT; // content-based
1✔
616
                $tmp = self::theme_url_to_dir($tmp);
1✔
617
                $tmp = URLHelper::remove_url_component($tmp, WP_CONTENT_DIR);
1✔
618
            }
619
        }
620

621
        // Remove query and fragment from URL.
622
        if (($i = \strpos($tmp, '?')) !== false) {
89✔
623
            $tmp = \substr($tmp, 0, $i);
1✔
624
        } elseif (($i = \strpos($tmp, '#')) !== false) {
88✔
625
            $tmp = \substr($tmp, 0, $i);
1✔
626
        }
627

628
        $parts = PathHelper::pathinfo($tmp);
89✔
629
        $result['subdir'] = ($parts['dirname'] === '/') ? '' : $parts['dirname'];
89✔
630
        $result['filename'] = $parts['filename'];
89✔
631
        $result['extension'] = (isset($parts['extension']) ? \strtolower((string) $parts['extension']) : '');
89✔
632
        $result['basename'] = $parts['basename'];
89✔
633

634
        return $result;
89✔
635
    }
636

637
    /**
638
     * Converts a URL located in a theme directory into the raw file path.
639
     *
640
     * @param string  $src A URL (https://example.org/wp-content/themes/twentysixteen/images/home.jpg).
641
     * @return string Full path to the file in question.
642
     */
643
    public static function theme_url_to_dir(string $src): string
644
    {
645
        /**
646
         * Filters whether to short-circuit the ImageHelper::theme_url_to_dir()
647
         * file path of a URL located in a theme directory.
648
         *
649
         * Returning a non-null value from the filter will short-circuit
650
         * ImageHelper::theme_url_to_dir(), returning that value.
651
         *
652
         * @since 2.0.0
653
         *
654
         * @param string|null $path Full path to short-circuit with. Default null.
655
         * @param string      $src  The URL to be converted.
656
         */
657
        $path = \apply_filters('timber/image_helper/pre_theme_url_to_dir', null, $src);
3✔
658
        if (null === $path) {
3✔
659
            $path = self::get_dir_from_theme_url($src);
2✔
660
        }
661

662
        /**
663
         * Filters the raw file path of a URL located in a theme directory.
664
         *
665
         * @since 2.0.0
666
         *
667
         * @param string $path The resolved full path to $src.
668
         * @param string $src  The URL that was converted.
669
         */
670
        return \apply_filters('timber/image_helper/theme_url_to_dir', $path, $src);
3✔
671
    }
672

673
    /**
674
     * Converts a URL located in a theme directory into the raw file path.
675
     *
676
     * @param string  $src A URL (https://example.org/wp-content/themes/twentysixteen/images/home.jpg).
677
     * @return string Full path to the file in question.
678
     */
679
    private static function get_dir_from_theme_url(string $src): string
680
    {
681
        $site_root = \trailingslashit(\get_theme_root_uri()) . \get_stylesheet();
2✔
682
        $path = \str_replace($site_root, '', $src);
2✔
683
        //$path = \trailingslashit(\get_theme_root()).\get_stylesheet().$path;
684
        $path = \get_stylesheet_directory() . $path;
2✔
685
        if ($_path = \realpath($path)) {
2✔
686
            return $_path;
2✔
687
        }
688
        return $path;
×
689
    }
690

691
    /**
692
     * Checks if uploaded image is located in theme.
693
     *
694
     * @param string $path image path.
695
     * @return bool     If the image is located in the theme directory it returns true.
696
     *                  If not or $path doesn't exits it returns false.
697
     */
698
    protected static function is_in_theme_dir($path)
699
    {
700
        $root = \realpath(\get_stylesheet_directory());
76✔
701

702
        if (false === $root) {
76✔
703
            return false;
×
704
        }
705

706
        if (\str_starts_with($path, (string) $root)) {
76✔
707
            return true;
×
708
        } else {
709
            return false;
76✔
710
        }
711
    }
712

713
    /**
714
     * Builds the public URL of a file based on its different components.
715
     *
716
     * @param  int    $base     One of `self::BASE_UPLOADS`, `self::BASE_CONTENT` to indicate if
717
     *                          file is an upload or a content (theme or plugin).
718
     * @param  string $subdir   Subdirectory in which file is stored, relative to $base root
719
     *                          folder.
720
     * @param  string $filename File name, including extension (but no path).
721
     * @param  bool   $absolute Should the returned URL be absolute (include protocol+host), or
722
     *                          relative.
723
     * @return string           The URL.
724
     */
725
    private static function _get_file_url($base, $subdir, $filename, $absolute)
726
    {
727
        $url = '';
81✔
728
        if (self::BASE_UPLOADS == $base) {
81✔
729
            $upload_dir = \wp_upload_dir();
79✔
730
            $url = $upload_dir['baseurl'];
79✔
731
        }
732
        if (self::BASE_CONTENT == $base) {
81✔
733
            $url = \content_url();
2✔
734
        }
735
        if (!empty($subdir)) {
81✔
736
            $url .= $subdir;
80✔
737
        }
738
        $url = \untrailingslashit($url) . '/' . $filename;
81✔
739
        if (!$absolute) {
81✔
740
            $home = \home_url();
5✔
741
            $home = \apply_filters('timber/image_helper/_get_file_url/home_url', $home);
5✔
742
            $url = \str_replace($home, '', $url);
5✔
743
        }
744
        return $url;
81✔
745
    }
746

747
    /**
748
     * Runs realpath to resolve symbolic links (../, etc). But only if it’s a path and not a URL.
749
     *
750
     * @param  string $path
751
     * @return string The resolved path.
752
     */
753
    protected static function maybe_realpath($path)
754
    {
755
        if (\str_contains($path, '../')) {
76✔
756
            return \realpath($path);
×
757
        }
758
        return $path;
76✔
759
    }
760

761
    /**
762
     * Builds the absolute file system location of a file based on its different components.
763
     *
764
     * @param  int    $base     One of `self::BASE_UPLOADS`, `self::BASE_CONTENT` to indicate if
765
     *                          file is an upload or a content (theme or plugin).
766
     * @param  string $subdir   Subdirectory in which file is stored, relative to $base root
767
     *                          folder.
768
     * @param  string $filename File name, including extension (but no path).
769
     * @return string           The file location.
770
     */
771
    private static function _get_file_path($base, $subdir, $filename)
772
    {
773
        if (URLHelper::is_url($subdir)) {
76✔
774
            $subdir = URLHelper::url_to_file_system($subdir);
×
775
        }
776
        $subdir = self::maybe_realpath($subdir);
76✔
777

778
        $path = '';
76✔
779
        if (self::BASE_UPLOADS == $base) {
76✔
780
            //it is in the Uploads directory
781
            $upload_dir = \wp_upload_dir();
74✔
782
            $path = $upload_dir['basedir'];
74✔
783
        } elseif (self::BASE_CONTENT == $base) {
2✔
784
            //it is in the content directory, somewhere else ...
785
            $path = WP_CONTENT_DIR;
2✔
786
        }
787
        if (self::is_in_theme_dir(\trailingslashit($subdir) . $filename)) {
76✔
788
            //this is for weird installs when the theme folder is outside of /wp-content
789
            return \trailingslashit($subdir) . $filename;
×
790
        }
791
        if (!empty($subdir)) {
76✔
792
            $path = \trailingslashit($path) . $subdir;
75✔
793
        }
794
        $path = \trailingslashit($path) . $filename;
76✔
795

796
        return URLHelper::remove_double_slashes($path);
76✔
797
    }
798

799
    /**
800
     * Main method that applies operation to src image:
801
     * 1. break down supplied URL into components
802
     * 2. use components to determine result file and URL
803
     * 3. check if a result file already exists
804
     * 4. otherwise, delegate to supplied TimberImageOperation
805
     *
806
     * @param  string  $src   A URL (absolute or relative) to an image.
807
     * @param  object  $op    Object of class TimberImageOperation.
808
     * @param  boolean $force Optional. Whether to remove any already existing result file and
809
     *                        force file generation. Default `false`.
810
     * @return string URL to the new image - or the source one if error.
811
     */
812
    private static function _operate($src, $op, $force = false)
813
    {
814
        if (empty($src)) {
85✔
815
            return '';
8✔
816
        }
817

818
        $allow_fs_write = \apply_filters('timber/allow_fs_write', true);
78✔
819

820
        if ($allow_fs_write === false) {
78✔
821
            return $src;
2✔
822
        }
823

824
        $external = false;
76✔
825
        // if external image, load it first
826
        if (URLHelper::is_external_content($src)) {
76✔
827
            $src = self::sideload_image($src);
7✔
828
            $external = true;
7✔
829
        }
830

831
        // break down URL into components
832
        $au = self::analyze_url($src);
76✔
833

834
        // build URL and filenames
835
        $new_url = self::_get_file_url(
76✔
836
            $au['base'],
76✔
837
            $au['subdir'],
76✔
838
            $op->filename($au['filename'], $au['extension']),
76✔
839
            $au['absolute']
76✔
840
        );
76✔
841
        $destination_path = self::_get_file_path(
76✔
842
            $au['base'],
76✔
843
            $au['subdir'],
76✔
844
            $op->filename($au['filename'], $au['extension'])
76✔
845
        );
76✔
846
        $source_path = self::_get_file_path(
76✔
847
            $au['base'],
76✔
848
            $au['subdir'],
76✔
849
            $au['basename']
76✔
850
        );
76✔
851

852
        /**
853
         * Filters the URL for the resized version of a `Timber\Image`.
854
         *
855
         * You’ll probably need to use this in combination with `timber/image/new_path`.
856
         *
857
         * @since 1.0.0
858
         *
859
         * @param string $new_url The URL to the resized version of an image.
860
         */
861
        $new_url = \apply_filters('timber/image/new_url', $new_url);
76✔
862

863
        /**
864
         * Filters the destination path for the resized version of a `Timber\Image`.
865
         *
866
         * A possible use case for this would be to store all images generated by Timber in a
867
         * separate directory. You’ll probably need to use this in combination with
868
         * `timber/image/new_url`.
869
         *
870
         * @since 1.0.0
871
         *
872
         * @param string $destination_path Full path to the destination of a resized image.
873
         */
874
        $destination_path = \apply_filters('timber/image/new_path', $destination_path);
76✔
875

876
        // if already exists...
877
        if (\file_exists($source_path) && \file_exists($destination_path)) {
76✔
878
            if ($force || \filemtime($source_path) > \filemtime($destination_path)) {
15✔
879
                // Force operation - warning: will regenerate the image on every pageload, use for testing purposes only!
880
                \unlink($destination_path);
3✔
881
            } else {
882
                // return existing file (caching)
883
                return $new_url;
12✔
884
            }
885
        }
886
        // otherwise generate result file
887
        if ($op->run($source_path, $destination_path)) {
67✔
888
            if ($op::class === Operation\Resize::class && $external) {
58✔
889
                $new_url = \strtolower((string) $new_url);
×
890
            }
891
            return $new_url;
58✔
892
        } else {
893
            // in case of error, we return source file itself
894
            return $src;
8✔
895
        }
896
    }
897

898
    //-- the below methods are just used for
899
    // unit testing the URL generation code --//
900
    /**
901
     * @internal
902
     */
903
    public static function get_letterbox_file_url($url, $w, $h, $color)
904
    {
905
        $au = self::analyze_url($url);
1✔
906
        $op = new Operation\Letterbox($w, $h, $color);
1✔
907
        $new_url = self::_get_file_url(
1✔
908
            $au['base'],
1✔
909
            $au['subdir'],
1✔
910
            $op->filename($au['filename'], $au['extension']),
1✔
911
            $au['absolute']
1✔
912
        );
1✔
913
        return $new_url;
1✔
914
    }
915

916
    /**
917
     * @internal
918
     */
919
    public static function get_letterbox_file_path($url, $w, $h, $color)
920
    {
921
        $au = self::analyze_url($url);
1✔
922
        $op = new Operation\Letterbox($w, $h, $color);
1✔
923
        $new_path = self::_get_file_path(
1✔
924
            $au['base'],
1✔
925
            $au['subdir'],
1✔
926
            $op->filename($au['filename'], $au['extension'])
1✔
927
        );
1✔
928
        return $new_path;
1✔
929
    }
930

931
    /**
932
     * @internal
933
     */
934
    public static function get_resize_file_url($url, $w, $h, $crop)
935
    {
936
        $au = self::analyze_url($url);
4✔
937
        $op = new Operation\Resize($w, $h, $crop);
4✔
938
        $new_url = self::_get_file_url(
4✔
939
            $au['base'],
4✔
940
            $au['subdir'],
4✔
941
            $op->filename($au['filename'], $au['extension']),
4✔
942
            $au['absolute']
4✔
943
        );
4✔
944
        return $new_url;
4✔
945
    }
946

947
    /**
948
     * @internal
949
     */
950
    public static function get_resize_file_path($url, $w, $h, $crop)
951
    {
952
        $au = self::analyze_url($url);
5✔
953
        $op = new Operation\Resize($w, $h, $crop);
5✔
954
        $new_path = self::_get_file_path(
5✔
955
            $au['base'],
5✔
956
            $au['subdir'],
5✔
957
            $op->filename($au['filename'], $au['extension'])
5✔
958
        );
5✔
959
        return $new_path;
5✔
960
    }
961
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc