Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.93% covered (warning)
89.93%
134 / 149
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Focal_Point
89.93% covered (warning)
89.93%
134 / 149
46.15% covered (danger)
46.15%
6 / 13
75.00
0.00% covered (danger)
0.00%
0 / 1
 get_for_image
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 get_cropped_image
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 get_cropped_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 crop_rect
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
5.06
 get_crop_data
91.23% covered (success)
91.23%
52 / 57
0.00% covered (danger)
0.00%
0 / 1
20.27
 needs_crop
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_dimensions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 is_supported_image_url
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 can_preserve_source_query_string
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 is_valid_focal_point
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
9.16
 is_default_focal_point
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 has_stored_focal_point_meta
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 clamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Focal point crop helpers.
4 *
5 * @package automattic/jetpack-publicize
6 */
7
8namespace Automattic\Jetpack\Publicize;
9
10use Automattic\Jetpack\Image_CDN\Image_CDN;
11use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
12use Automattic\Jetpack\Status;
13
14/**
15 * Shared focal point crop helpers for Jetpack Social images.
16 */
17class Focal_Point {
18
19    /**
20     * Target Open Graph image width.
21     *
22     * @var int
23     */
24    const OG_IMAGE_WIDTH = 1200;
25
26    /**
27     * Target Open Graph image height.
28     *
29     * @var int
30     */
31    const OG_IMAGE_HEIGHT = 630;
32
33    /**
34     * Get the stored focal point for an image.
35     *
36     * @param int $attachment_id Attachment ID.
37     * @return array|null {
38     *     Focal point, or null when not set or invalid.
39     *
40     *     @type float $x X axis, 0-1.
41     *     @type float $y Y axis, 0-1.
42     * }
43     */
44    public static function get_for_image( $attachment_id ) {
45        $attachment_id = absint( $attachment_id );
46
47        if ( ! $attachment_id ) {
48            return null;
49        }
50
51        $focal_point = get_metadata_raw( 'post', $attachment_id, Publicize_Base::ATTACHMENT_IMAGE_FOCAL_POINT, true );
52
53        if ( ! self::is_valid_focal_point( $focal_point ) ) {
54            return null;
55        }
56
57        if ( self::is_default_focal_point( $focal_point ) && ! self::has_stored_focal_point_meta( $attachment_id ) ) {
58            return null;
59        }
60
61        return array(
62            'x' => (float) $focal_point['x'],
63            'y' => (float) $focal_point['y'],
64        );
65    }
66
67    /**
68     * Get a focal-point cropped image for an attachment.
69     *
70     * @param int $attachment_id Attachment ID.
71     * @param int $target_width Target width.
72     * @param int $target_height Target height.
73     * @return array|null {
74     *     Image data, or null when a cropped image cannot be generated.
75     *
76     *     @type string $url Image source URL.
77     *     @type int    $width Image width in pixels.
78     *     @type int    $height Image height in pixels.
79     * }
80     */
81    public static function get_cropped_image( $attachment_id, $target_width = self::OG_IMAGE_WIDTH, $target_height = self::OG_IMAGE_HEIGHT ) {
82        $focal_point = self::get_for_image( $attachment_id );
83
84        if ( ! $focal_point ) {
85            return null;
86        }
87
88        $crop_data = self::get_crop_data( $attachment_id, $focal_point['x'], $focal_point['y'], $target_width, $target_height );
89
90        if ( ! $crop_data ) {
91            return null;
92        }
93
94        return array(
95            'url'    => $crop_data['url'],
96            'width'  => $crop_data['width'],
97            'height' => $crop_data['height'],
98        );
99    }
100
101    /**
102     * Get a focal-point cropped URL for an attachment.
103     *
104     * @param int   $attachment_id Attachment ID.
105     * @param float $focal_x Focal point x axis, 0-1.
106     * @param float $focal_y Focal point y axis, 0-1.
107     * @param int   $target_width Target width.
108     * @param int   $target_height Target height.
109     * @return string|null Cropped URL, or null when one cannot be generated.
110     */
111    public static function get_cropped_url( $attachment_id, $focal_x, $focal_y, $target_width = self::OG_IMAGE_WIDTH, $target_height = self::OG_IMAGE_HEIGHT ) {
112        $crop_data = self::get_crop_data( $attachment_id, $focal_x, $focal_y, $target_width, $target_height );
113
114        return $crop_data ? $crop_data['url'] : null;
115    }
116
117    /**
118     * Calculate a source crop rectangle for a focal point and target aspect ratio.
119     *
120     * The crop model matches the Social previews: center the crop on the focal
121     * point, then clamp the crop rectangle to the source image edges.
122     *
123     * @param int   $source_width Source image width.
124     * @param int   $source_height Source image height.
125     * @param float $focal_x Focal point x axis, 0-1.
126     * @param float $focal_y Focal point y axis, 0-1.
127     * @param float $aspect Target aspect ratio.
128     * @return array|null {
129     *     Crop rectangle, or null when inputs are invalid.
130     *
131     *     @type int $x Source x coordinate.
132     *     @type int $y Source y coordinate.
133     *     @type int $width Crop width.
134     *     @type int $height Crop height.
135     * }
136     */
137    public static function crop_rect( $source_width, $source_height, $focal_x, $focal_y, $aspect ) {
138        $source_width  = absint( $source_width );
139        $source_height = absint( $source_height );
140        $aspect        = (float) $aspect;
141
142        if ( ! $source_width || ! $source_height || $aspect <= 0 ) {
143            return null;
144        }
145
146        $focal_x = self::clamp( (float) $focal_x, 0, 1 );
147        $focal_y = self::clamp( (float) $focal_y, 0, 1 );
148
149        $crop_width  = min( $source_width, $source_height * $aspect );
150        $crop_height = $crop_width / $aspect;
151
152        if ( $crop_height > $source_height ) {
153            $crop_height = $source_height;
154            $crop_width  = $crop_height * $aspect;
155        }
156
157        $crop_width  = max( 1, min( $source_width, (int) round( $crop_width ) ) );
158        $crop_height = max( 1, min( $source_height, (int) round( $crop_height ) ) );
159        $crop_x      = (int) self::clamp( round( $focal_x * $source_width - $crop_width / 2 ), 0, $source_width - $crop_width );
160        $crop_y      = (int) self::clamp( round( $focal_y * $source_height - $crop_height / 2 ), 0, $source_height - $crop_height );
161
162        return array(
163            'x'      => $crop_x,
164            'y'      => $crop_y,
165            'width'  => $crop_width,
166            'height' => $crop_height,
167        );
168    }
169
170    /**
171     * Get all crop data needed for a Photon URL and dimensions.
172     *
173     * @param int   $attachment_id Attachment ID.
174     * @param float $focal_x Focal point x axis, 0-1.
175     * @param float $focal_y Focal point y axis, 0-1.
176     * @param int   $target_width Target width.
177     * @param int   $target_height Target height.
178     * @return array|null Crop data, or null.
179     */
180    private static function get_crop_data( $attachment_id, $focal_x, $focal_y, $target_width, $target_height ) {
181        $attachment_id = absint( $attachment_id );
182        $target_width  = absint( $target_width );
183        $target_height = absint( $target_height );
184
185        if ( ! $attachment_id || ! $target_width || ! $target_height || ! wp_attachment_is_image( $attachment_id ) ) {
186            return null;
187        }
188
189        if (
190            ! class_exists( Image_CDN_Core::class )
191            || ! method_exists( Image_CDN_Core::class, 'cdn_url' )
192            || ! method_exists( Image_CDN_Core::class, 'is_cdn_url' )
193        ) {
194            return null;
195        }
196
197        if ( ( new Status() )->is_private_site() ) {
198            return null;
199        }
200
201        $source_url = wp_get_attachment_url( $attachment_id );
202
203        if ( ! $source_url || ! self::is_supported_image_url( $source_url ) ) {
204            return null;
205        }
206
207        $dimensions = self::get_dimensions( $attachment_id );
208
209        if ( ! $dimensions ) {
210            return null;
211        }
212
213        $aspect    = $target_width / $target_height;
214        $crop_rect = self::crop_rect( $dimensions['width'], $dimensions['height'], $focal_x, $focal_y, $aspect );
215
216        if ( ! $crop_rect ) {
217            return null;
218        }
219
220        $args          = array();
221        $needs_crop    = self::needs_crop( $crop_rect, $dimensions );
222        $resize_width  = $crop_rect['width'];
223        $resize_height = $crop_rect['height'];
224
225        if ( $needs_crop ) {
226            $args['crop'] = sprintf(
227                '%dpx,%dpx,%dpx,%dpx',
228                $crop_rect['x'],
229                $crop_rect['y'],
230                $crop_rect['width'],
231                $crop_rect['height']
232            );
233        }
234
235        if ( $crop_rect['width'] > $target_width || $crop_rect['height'] > $target_height ) {
236            $scale          = min( $target_width / $crop_rect['width'], $target_height / $crop_rect['height'] );
237            $resize_width   = max( 1, (int) round( $crop_rect['width'] * $scale ) );
238            $resize_height  = max( 1, (int) round( $crop_rect['height'] * $scale ) );
239            $args['resize'] = $resize_width . ',' . $resize_height;
240        }
241
242        if ( ! $args ) {
243            return array(
244                'url'    => $source_url,
245                'width'  => $dimensions['width'],
246                'height' => $dimensions['height'],
247            );
248        }
249
250        if ( ! self::can_preserve_source_query_string( $source_url ) ) {
251            return null;
252        }
253
254        $cropped_url = Image_CDN_Core::cdn_url(
255            $source_url,
256            $args
257        );
258
259        if ( ! $cropped_url || $cropped_url === $source_url ) {
260            return null;
261        }
262
263        return array(
264            'url'    => $cropped_url,
265            'width'  => $resize_width,
266            'height' => $resize_height,
267        );
268    }
269
270    /**
271     * Check whether the crop rectangle changes the source image.
272     *
273     * @param array $crop_rect Crop rectangle.
274     * @param array $dimensions Source dimensions.
275     * @return bool Whether the source image needs a crop operation.
276     */
277    private static function needs_crop( $crop_rect, $dimensions ) {
278        return 0 !== $crop_rect['x']
279            || 0 !== $crop_rect['y']
280            || $crop_rect['width'] !== $dimensions['width']
281            || $crop_rect['height'] !== $dimensions['height'];
282    }
283
284    /**
285     * Get image dimensions from attachment metadata.
286     *
287     * @param int $attachment_id Attachment ID.
288     * @return array|null {
289     *     Dimensions, or null.
290     *
291     *     @type int $width Image width.
292     *     @type int $height Image height.
293     * }
294     */
295    private static function get_dimensions( $attachment_id ) {
296        $metadata = wp_get_attachment_metadata( $attachment_id );
297
298        if (
299            ! is_array( $metadata )
300            || empty( $metadata['width'] )
301            || empty( $metadata['height'] )
302            || ! is_numeric( $metadata['width'] )
303            || ! is_numeric( $metadata['height'] )
304            || $metadata['width'] <= 0
305            || $metadata['height'] <= 0
306        ) {
307            return null;
308        }
309
310        return array(
311            'width'  => absint( $metadata['width'] ),
312            'height' => absint( $metadata['height'] ),
313        );
314    }
315
316    /**
317     * Check whether a URL has an Image CDN supported extension.
318     *
319     * @param string $url Image URL.
320     * @return bool Whether Image CDN supports the URL extension.
321     */
322    private static function is_supported_image_url( $url ) {
323        if ( ! class_exists( Image_CDN::class ) || ! method_exists( Image_CDN::class, 'get_supported_extensions' ) ) {
324            return false;
325        }
326
327        $path = wp_parse_url( $url, PHP_URL_PATH );
328
329        if ( ! $path ) {
330            return false;
331        }
332
333        return in_array( strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ), Image_CDN::get_supported_extensions(), true );
334    }
335
336    /**
337     * Check whether Photon will preserve the source URL query string.
338     *
339     * Photon ignores source query strings by default. Some attachment providers put
340     * required signatures in the query string, while transformed CDN URLs can already
341     * contain ordered image manipulation args. Skip focal crops unless the domain opts
342     * in to query string preservation.
343     *
344     * @param string $url Image URL.
345     * @return bool Whether the source query string can be preserved.
346     */
347    private static function can_preserve_source_query_string( $url ) {
348        $url_parts = wp_parse_url( $url );
349
350        if ( ! is_array( $url_parts ) || empty( $url_parts['query'] ) ) {
351            return true;
352        }
353
354        $host = strtolower( $url_parts['host'] ?? '' );
355
356        if ( '' === $host ) {
357            return false;
358        }
359
360        if ( Image_CDN_Core::is_cdn_url( $url ) ) {
361            return false;
362        }
363
364        /**
365         * Allow Photon to add source query strings for opted-in domains.
366         *
367         * @module photon
368         *
369         * @param bool false Should query strings be added to the image URL. Default is false.
370         * @param string $host Image URL's host.
371         */
372        return (bool) apply_filters( 'jetpack_photon_add_query_string_to_domain', false, $host );
373    }
374
375    /**
376     * Validate focal point shape.
377     *
378     * @param mixed $value Value to validate.
379     * @return bool Whether the value is a valid focal point.
380     */
381    private static function is_valid_focal_point( $value ) {
382        if ( ! is_array( $value ) || ! array_key_exists( 'x', $value ) || ! array_key_exists( 'y', $value ) ) {
383            return false;
384        }
385
386        return is_numeric( $value['x'] )
387            && is_numeric( $value['y'] )
388            && $value['x'] >= 0
389            && $value['x'] <= 1
390            && $value['y'] >= 0
391            && $value['y'] <= 1;
392    }
393
394    /**
395     * Check whether the focal point is the registered default.
396     *
397     * @param array $value Focal point.
398     * @return bool Whether the value is the default center point.
399     */
400    private static function is_default_focal_point( $value ) {
401        return 0.5 === (float) $value['x'] && 0.5 === (float) $value['y'];
402    }
403
404    /**
405     * Check whether the focal point meta key is actually stored.
406     *
407     * Registered meta defaults can appear through the metadata API even when no
408     * row has been saved. Only use the default center point when the key exists.
409     *
410     * @param int $attachment_id Attachment ID.
411     * @return bool Whether the focal point key is stored on the attachment.
412     */
413    private static function has_stored_focal_point_meta( $attachment_id ) {
414        // metadata_exists() applies metadata filters, so registered defaults can look stored.
415        $stored_meta_keys = get_post_custom_keys( $attachment_id );
416
417        return is_array( $stored_meta_keys )
418            && in_array( Publicize_Base::ATTACHMENT_IMAGE_FOCAL_POINT, $stored_meta_keys, true );
419    }
420
421    /**
422     * Clamp a numeric value.
423     *
424     * @param float $value Value to clamp.
425     * @param float $min Minimum value.
426     * @param float $max Maximum value.
427     * @return float Clamped value.
428     */
429    private static function clamp( $value, $min, $max ) {
430        return min( max( $value, $min ), $max );
431    }
432}