PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / extensions / blocks / tiled-gallery / tiled-gallery.php
jetpack / extensions / blocks / tiled-gallery Last commit date
tiled-gallery.php 1 week ago
tiled-gallery.php
979 lines
1 <?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Tiled Gallery block.
4 * Relies on Photon, but can be used even when the module is not active.
5 *
6 * @since 6.9.0
7 *
8 * @package automattic/jetpack
9 */
10
11 namespace Automattic\Jetpack\Extensions;
12
13 use Automattic\Jetpack\Blocks;
14 use Automattic\Jetpack\Status;
15 use Automattic\Jetpack\Status\Host;
16 use Jetpack;
17 use Jetpack_Gutenberg;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit( 0 );
21 }
22
23 /**
24 * Jetpack Tiled Gallery Block class
25 *
26 * @since 7.3
27 */
28 class Tiled_Gallery {
29 /* Values for building srcsets */
30 const IMG_SRCSET_WIDTH_MAX = 2000;
31 const IMG_SRCSET_WIDTH_MIN = 600;
32 const IMG_SRCSET_WIDTH_STEP = 300;
33
34 /**
35 * Register the block
36 */
37 public static function register() {
38 if (
39 ( defined( 'IS_WPCOM' ) && IS_WPCOM )
40 || Jetpack::is_connection_ready()
41 || ( new Status() )->is_offline_mode()
42 ) {
43 Blocks::jetpack_register_block(
44 __DIR__,
45 array(
46 'render_callback' => array( __CLASS__, 'render' ),
47 'render_email_callback' => array( __CLASS__, 'render_email' ),
48 )
49 );
50 }
51 }
52
53 /**
54 * Tiled gallery block registration
55 *
56 * @param array $attr Array containing the block attributes.
57 * @param string $content String containing the block content.
58 *
59 * @return string
60 */
61 public static function render( $attr, $content ) {
62 Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
63
64 /*
65 * Note that the image host here comes from whatever the editor baked into $content when the post
66 * was last saved, so galleries published on a VIP site before the editor honoured that setting
67 * still serve unreachable i0.wp.com URLs until someone saves the post again. Rewriting the host
68 * from the data-url attribute below would fix those without an edit:
69 * https://github.com/Automattic/jetpack/issues/51075
70 */
71 $is_squareish_layout = self::is_squareish_layout( $attr );
72
73 if ( preg_match_all( '/<img [^>]+>/', $content, $images ) ) {
74 /**
75 * This block processes all of the images that are found and builds $find and $replace.
76 *
77 * The original img is added to the $find array and the replacement is made and added
78 * to the $replace array. This is so that the same find and replace operations can be
79 * made on the entire $content.
80 */
81 $find = array();
82 $replace = array();
83 $image_index = 0;
84 $number_images = count( $images[0] );
85
86 foreach ( $images[0] as $image_html ) {
87 if (
88 preg_match( '/data-width="([0-9]+)"/', $image_html, $img_width )
89 && preg_match( '/data-height="([0-9]+)"/', $image_html, $img_height )
90 && preg_match( '/src="([^"]+)"/', $image_html, $img_src )
91 ) {
92 ++$image_index;
93 // Drop img src query string so it can be used as a base to add photon params
94 // for the srcset.
95 $src_parts = explode( '?', $img_src[1], 2 );
96 $orig_src = $src_parts[0];
97 $orig_height = absint( $img_height[1] );
98 $orig_width = absint( $img_width[1] );
99
100 // Because URLs are already "photon", the photon function used short-circuits
101 // before ssl is added. Detect ssl and add is if necessary.
102 $is_ssl = ! empty( $src_parts[1] ) && str_contains( $src_parts[1], 'ssl=1' );
103
104 if ( ! $orig_width || ! $orig_height || ! $orig_src ) {
105 continue;
106 }
107
108 // data-width and data-height describe the original upload, but the
109 // candidates below are built on the src, which is usually a smaller
110 // intermediate size. Photon never upscales, so sizing the srcset from
111 // the original would advertise widths that resolve to a narrower image
112 // than the browser was told to expect, and the tile renders soft on a
113 // high density screen. Cap both to what the source file can produce.
114 $source_dimensions = self::get_source_file_dimensions( $orig_src );
115 if ( null !== $source_dimensions ) {
116 $orig_width = min( $orig_width, $source_dimensions[0] );
117 $orig_height = min( $orig_height, $source_dimensions[1] );
118 }
119
120 $srcset_parts = array();
121 if ( $is_squareish_layout ) {
122 $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width, $orig_height );
123 $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width, $orig_height );
124
125 for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
126 $srcset_src = add_query_arg(
127 array(
128 'resize' => $w . ',' . $w,
129 'strip' => 'info',
130 ),
131 $orig_src
132 );
133 if ( $is_ssl ) {
134 $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
135 }
136 $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
137 if ( $w >= $max_width ) {
138 break;
139 }
140 }
141 } else {
142 $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width );
143 $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width );
144
145 for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
146 $srcset_src = add_query_arg(
147 array(
148 'strip' => 'info',
149 'w' => $w,
150 ),
151 $orig_src
152 );
153 if ( $is_ssl ) {
154 $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
155 }
156 $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
157 if ( $w >= $max_width ) {
158 break;
159 }
160 }
161 }
162
163 $img_element = self::interactive_markup( $image_index, $number_images );
164
165 if ( ! empty( $srcset_parts ) ) {
166 $srcset = 'srcset="' . esc_attr( implode( ',', $srcset_parts ) ) . '"';
167
168 $find[] = $image_html;
169 $replace[] = str_replace( '<img', $img_element . $srcset, $image_html );
170 }
171 }
172 }
173
174 if ( ! empty( $find ) ) {
175 $content = str_replace( $find, $replace, $content );
176 }
177 }
178
179 /**
180 * Filter the output of the Tiled Galleries content.
181 *
182 * @module tiled-gallery
183 *
184 * @since 6.9.0
185 *
186 * @param string $content Tiled Gallery block content.
187 */
188 return apply_filters( 'jetpack_tiled_galleries_block_content', $content );
189 }
190
191 /**
192 * Dimensions of the file an image URL points at, when it names a WordPress intermediate size.
193 *
194 * WordPress appends -WIDTHxHEIGHT to the file name of every size it generates, so
195 * a URL ending that way tells us how large the file behind it actually is. Returns
196 * null for anything else, including the original upload, whose size is unknowable
197 * from the URL alone.
198 *
199 * @param string $url Image URL, with any query string already removed.
200 * @return array|null Array of width and height, or null if the URL names no size.
201 */
202 private static function get_source_file_dimensions( $url ) {
203 if ( ! preg_match( '/-(\d+)x(\d+)\.[a-zA-Z0-9]+$/', $url, $dimensions ) ) {
204 return null;
205 }
206
207 return array( absint( $dimensions[1] ), absint( $dimensions[2] ) );
208 }
209
210 /**
211 * Adds tabindex, role and aria-label markup for images that should be interactive (front-end only).
212 *
213 * @param integer $image_index Integer The current image index.
214 * @param integer $number_images Integer The total number of images.
215 */
216 private static function interactive_markup( $image_index, $number_images ) {
217
218 $host = new Host();
219 $is_module_active = $host->is_wpcom_simple()
220 ? get_option( 'carousel_enable_it' )
221 : Jetpack::is_module_active( 'carousel' );
222
223 if ( $is_module_active ) {
224 $aria_label_content = sprintf(
225 /* Translators: %1$d is the current image index, %2$d is the total number of images. */
226 __( 'Open image %1$d of %2$d in full-screen', 'jetpack' ),
227 $image_index,
228 $number_images
229 );
230 // The trailing space matters: render() appends the srcset directly onto
231 // this string, and without it the two run together as aria-label="…"srcset="…".
232 $img_element = '<img role="button" tabindex="0" aria-label="' . esc_attr( $aria_label_content ) . '" ';
233 } else {
234 $img_element = '<img ';
235 }
236 return $img_element;
237 }
238
239 /**
240 * Determines whether a Tiled Gallery block uses square or circle images (1:1 ratio)
241 *
242 * Layouts are block styles and will be available as `is-style-[LAYOUT]` in the className
243 * attribute. The default (rectangular) will be omitted.
244 *
245 * @param array $attr Attributes key/value array.
246 * @return boolean True if layout is squareish, otherwise false.
247 */
248 private static function is_squareish_layout( $attr ) {
249 return isset( $attr['className'] )
250 && (
251 'is-style-square' === $attr['className']
252 || 'is-style-circle' === $attr['className']
253 );
254 }
255
256 /**
257 * Render tiled gallery block for email.
258 *
259 * @since 15.0
260 *
261 * @param string $block_content The original block HTML content.
262 * @param array $parsed_block The parsed block data including attributes.
263 * @param object $rendering_context Email rendering context.
264 *
265 * @return string
266 */
267 public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
268 // Validate input parameters and required dependencies
269 if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
270 ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
271 ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
272 return '';
273 }
274
275 // Get spacing from email_attrs for better consistency with core blocks
276 $email_attrs = $parsed_block['email_attrs'] ?? array();
277 $table_margin_style = '';
278
279 if ( ! empty( $email_attrs ) && class_exists( '\WP_Style_Engine' ) ) {
280 // Get margin for table styling
281 $table_margin_style = \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin' ) ) ), '' ) ?? '';
282 }
283
284 // Email cell padding
285 $email_cell_padding = 2; // Cell padding
286
287 $attr = $parsed_block['attrs'];
288
289 // Determine layout style and columns from attributes (needed for both image processing and layout building)
290 $layout_info = self::get_layout_style_from_attributes( $attr );
291
292 // Process images for email rendering
293 $images = self::process_tiled_gallery_images_for_email( $attr, $layout_info );
294
295 if ( empty( $images ) ) {
296 return '';
297 }
298
299 // Determine target width from the email layout if available
300 $target_width = self::get_email_target_width( $rendering_context );
301
302 // Build layout content based on style and columns
303 $grid_content = self::build_email_layout_content( $images, $layout_info, $email_cell_padding, $attr );
304
305 // Use Table_Wrapper_Helper for consistent email rendering
306 $table_style = sprintf( 'width: 100%%; max-width: %dpx; padding: 0; border-collapse: collapse;', $target_width );
307 if ( ! empty( $table_margin_style ) ) {
308 $table_style = $table_margin_style . '; ' . $table_style;
309 } else {
310 $table_style = 'margin: 16px 0; ' . $table_style;
311 }
312
313 $image_table_attrs = array(
314 'style' => $table_style,
315 );
316
317 $html = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( $grid_content, $image_table_attrs );
318
319 return $html;
320 }
321
322 /**
323 * Get target width for email rendering.
324 *
325 * @param object $rendering_context Email rendering context.
326 * @return int Target width in pixels.
327 */
328 private static function get_email_target_width( $rendering_context ) {
329 $target_width = 600; // Default
330
331 if ( ! empty( $rendering_context ) && is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
332 $layout_width_px = $rendering_context->get_layout_width_without_padding();
333 if ( is_string( $layout_width_px ) ) {
334 $parsed_width = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $layout_width_px );
335 if ( $parsed_width > 0 ) {
336 $target_width = $parsed_width;
337 }
338 }
339 }
340
341 return $target_width;
342 }
343
344 /**
345 * Process tiled gallery images for email rendering.
346 *
347 * @param array $attr Block attributes containing image data.
348 * @param array $layout_info Layout information from get_layout_style_from_attributes.
349 * @return array Processed image data for email rendering.
350 */
351 private static function process_tiled_gallery_images_for_email( $attr, $layout_info ) {
352 $images = array();
353
354 // Determine if this is a squareish layout
355 $is_squareish = in_array( $layout_info['style'], array( 'square', 'circle' ), true );
356
357 // Get images from IDs (primary data source)
358 if ( ! empty( $attr['ids'] ) && is_array( $attr['ids'] ) ) {
359 foreach ( $attr['ids'] as $id ) {
360 // Validate ID is a positive integer and attachment exists
361 $id = absint( $id );
362 if ( ! $id || ! wp_attachment_is_image( $id ) ) {
363 continue;
364 }
365
366 // For square/circle layouts, get a high-quality square crop
367 if ( $is_squareish ) {
368 // Start with full size image for better quality when resizing
369 $image_url = wp_get_attachment_image_url( $id, 'full' );
370
371 // If we have Photon/Jetpack image processing, request high-quality square crop
372 if ( function_exists( 'jetpack_photon_url' ) && $image_url ) {
373 $image_url = add_query_arg(
374 array(
375 'resize' => '1300,1300', // High-quality square crop for email
376 'crop' => '1',
377 ),
378 $image_url
379 );
380 }
381 } else {
382 $image_url = wp_get_attachment_image_url( $id, 'large' );
383 }
384
385 // Sanitize alt text from post meta
386 $alt_text = get_post_meta( $id, '_wp_attachment_image_alt', true );
387 $alt_text = sanitize_text_field( $alt_text );
388
389 if ( $image_url ) {
390 $images[] = array(
391 'url' => $image_url,
392 'alt' => $alt_text,
393 'id' => $id,
394 );
395 }
396 }
397 } elseif ( ! empty( $attr['images'] ) && is_array( $attr['images'] ) ) {
398 // Fall back to images array if IDs aren't available
399 foreach ( $attr['images'] as $image_data ) {
400 if ( ! empty( $image_data['url'] ) ) {
401 // Validate and sanitize URL
402 $url = esc_url_raw( $image_data['url'] );
403 if ( ! $url || ! wp_http_validate_url( $url ) ) {
404 continue;
405 }
406
407 // Sanitize alt text
408 $alt_text = ! empty( $image_data['alt'] ) ? sanitize_text_field( $image_data['alt'] ) : '';
409
410 // Validate ID if present
411 $id = ! empty( $image_data['id'] ) ? absint( $image_data['id'] ) : 0;
412
413 $images[] = array(
414 'url' => $url,
415 'alt' => $alt_text,
416 'id' => $id,
417 );
418 }
419 }
420 }
421
422 return $images;
423 }
424
425 /**
426 * Get layout style and columns from block attributes.
427 *
428 * @param array $attr Block attributes.
429 * @return array Array with 'style', 'columns', and 'border_radius' keys.
430 */
431 private static function get_layout_style_from_attributes( $attr ) {
432 $layout_info = array(
433 'style' => 'rectangular', // Default to rectangular/mosaic layout
434 'columns' => 3, // Default to 3 columns
435 'border_radius' => 0, // Default to no border radius
436 );
437
438 // Get number of columns from attributes with validation
439 if ( ! empty( $attr['columns'] ) && is_numeric( $attr['columns'] ) ) {
440 $columns = absint( $attr['columns'] );
441 // Clamp columns between 1 and 6 for reasonable layouts
442 $layout_info['columns'] = max( 1, min( 6, $columns ) );
443 }
444
445 // Get border radius from roundedCorners attribute (preferred method)
446 if ( ! empty( $attr['roundedCorners'] ) && is_numeric( $attr['roundedCorners'] ) ) {
447 $border_radius_value = absint( $attr['roundedCorners'] );
448 // Clamp value between 0 and 20
449 $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
450 }
451
452 // Get layout style and border radius from className
453 if ( ! empty( $attr['className'] ) ) {
454 if ( str_contains( $attr['className'], 'is-style-square' ) ) {
455 $layout_info['style'] = 'square';
456 } elseif ( str_contains( $attr['className'], 'is-style-circle' ) ) {
457 $layout_info['style'] = 'circle';
458 } elseif ( str_contains( $attr['className'], 'is-style-columns' ) ) {
459 $layout_info['style'] = 'columns';
460 }
461
462 // Extract border radius from has-rounded-corners-{value} class (fallback method)
463 if ( $layout_info['border_radius'] === 0 && preg_match( '/has-rounded-corners-(\d+)/', $attr['className'], $matches ) ) {
464 $border_radius_value = absint( $matches[1] );
465 // Clamp value between 0 and 20
466 $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
467 }
468 }
469
470 return $layout_info;
471 }
472
473 /**
474 * Build email layout content based on layout style and columns.
475 *
476 * @param array $images Array of image data.
477 * @param array $layout_info Array with 'style' and 'columns' keys.
478 * @param int $cell_padding Cell padding.
479 * @param array $attr Block attributes.
480 * @return string HTML content.
481 */
482 private static function build_email_layout_content( $images, $layout_info, $cell_padding, $attr ) {
483 $layout_style = $layout_info['style'];
484 $columns = $layout_info['columns'];
485 $border_radius = $layout_info['border_radius'];
486
487 switch ( $layout_style ) {
488 case 'square':
489 return self::build_square_layout_content( $images, $cell_padding, $columns, 'square', $border_radius, $attr );
490 case 'circle':
491 return self::build_square_layout_content( $images, $cell_padding, $columns, 'circle', $border_radius, $attr );
492 case 'columns':
493 return self::build_columns_layout_content( $images, $cell_padding, $columns, $border_radius, $attr );
494 case 'rectangular':
495 default:
496 return self::build_mosaic_layout_content( $images, $cell_padding, $border_radius, $attr );
497 }
498 }
499
500 /**
501 * Build square/circle layout content.
502 *
503 * @param array $images Array of image data.
504 * @param int $cell_padding Cell padding.
505 * @param int $columns Number of columns for the layout.
506 * @param string $style Layout style (square or circle).
507 * @param int $border_radius Border radius value (0-20).
508 * @param array $attr Block attributes.
509 * @return string HTML content.
510 */
511 private static function build_square_layout_content( $images, $cell_padding, $columns, $style = 'square', $border_radius = 0, $attr = array() ) {
512 $content_parts = array();
513
514 // Create rows of images with hierarchical chunks for square/circle layouts
515 $image_chunks = self::create_hierarchical_chunks( $images, $columns );
516
517 $border_radius_style = self::generate_border_radius_style( $style, $border_radius );
518
519 foreach ( $image_chunks as $row_images ) {
520 $images_in_row = count( $row_images );
521 $cell_width_percent = ( 100 / $images_in_row );
522
523 // Build table cells for this row
524 $row_cells = '';
525 foreach ( $row_images as $image ) {
526 // Calculate cell attributes with consistent padding
527 $cell_attrs = array(
528 'style' => sprintf(
529 'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
530 $cell_width_percent,
531 $cell_padding
532 ),
533 );
534
535 $image_styles = self::generate_image_styles( false );
536
537 $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
538
539 $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
540 $cell_content,
541 $cell_attrs
542 );
543 }
544
545 // Use Table_Wrapper_Helper for email-compatible table rendering
546 $table_attrs = array(
547 'style' => 'width: 100%; border-collapse: collapse;',
548 );
549
550 $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
551 $row_cells,
552 $table_attrs
553 );
554 }
555
556 // Use Table_Wrapper_Helper for consistent email rendering
557 $wrapper_attrs = array(
558 'style' => 'width: 100%; border-collapse: collapse;',
559 );
560
561 return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
562 implode( '', $content_parts ),
563 $wrapper_attrs
564 );
565 }
566
567 /**
568 * Build columns layout content using mosaic logic organized into columns.
569 *
570 * @param array $images Array of image data.
571 * @param int $cell_padding Cell padding.
572 * @param int $columns Number of columns for the layout.
573 * @param int $border_radius Border radius value (0-20).
574 * @param array $attr Block attributes.
575 * @return string HTML content.
576 */
577 private static function build_columns_layout_content( $images, $cell_padding, $columns, $border_radius = 0, $attr = array() ) {
578 $content_parts = array();
579 $border_radius_style = self::generate_border_radius_style( '', $border_radius );
580
581 // Distribute images across columns using round-robin approach for better balance
582 $column_arrays = array_fill( 0, $columns, array() );
583 foreach ( $images as $index => $image ) {
584 $column_index = $index % $columns;
585 $column_arrays[ $column_index ][] = $image;
586 }
587
588 // Build table cells for columns layout
589 $row_cells = '';
590 foreach ( $column_arrays as $column_images ) {
591 if ( empty( $column_images ) ) {
592 // Add empty cell for balance
593 $cell_attrs = array(
594 'style' => sprintf(
595 'width: %s%%; padding: %dpx; vertical-align: top;',
596 ( 100 / $columns ),
597 $cell_padding
598 ),
599 );
600 $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
601 '',
602 $cell_attrs
603 );
604 continue;
605 }
606
607 // Calculate cell attributes
608 $cell_width_percent = ( 100 / $columns );
609 $cell_attrs = array(
610 'style' => sprintf(
611 'width: %s%%; padding: %dpx; vertical-align: top;',
612 $cell_width_percent,
613 $cell_padding
614 ),
615 );
616
617 // Generate mosaic-style groupings within this column
618 $column_rows = self::generate_column_mosaic_rows( $column_images );
619
620 $cell_content = '';
621 foreach ( $column_rows as $row_index => $row_images ) {
622 foreach ( $row_images as $image ) {
623 $image_styles = self::generate_image_styles( false );
624
625 // Add top margin to all images except the first one in the column
626 if ( $row_index > 0 || $cell_content !== '' ) {
627 $image_styles .= ' margin-top: ' . ( $cell_padding * 2 ) . 'px;';
628 }
629
630 $cell_content .= self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
631 }
632 }
633
634 $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
635 $cell_content,
636 $cell_attrs
637 );
638 }
639
640 // Use Table_Wrapper_Helper for email-compatible table rendering
641 $table_attrs = array(
642 'style' => 'width: 100%; border-collapse: collapse;',
643 );
644
645 $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
646 $row_cells,
647 $table_attrs
648 );
649
650 // Use Table_Wrapper_Helper for consistent email rendering
651 $wrapper_attrs = array(
652 'style' => 'width: 100%; border-collapse: collapse;',
653 );
654
655 return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
656 implode( '', $content_parts ),
657 $wrapper_attrs
658 );
659 }
660
661 /**
662 * Build mosaic layout content with flexible row/column structure.
663 *
664 * @param array $images Array of image data.
665 * @param int $cell_padding Cell padding.
666 * @param int $border_radius Border radius value (0-20).
667 * @param array $attr Block attributes.
668 * @return string HTML content.
669 */
670 private static function build_mosaic_layout_content( $images, $cell_padding, $border_radius = 0, $attr = array() ) {
671 $border_radius_style = self::generate_border_radius_style( '', $border_radius );
672
673 // Generate mosaic layout rows
674 $rows = self::generate_mosaic_rows( $images );
675
676 // Determine the maximum number of columns to ensure consistent layout
677 $max_columns = 0;
678 foreach ( $rows as $row ) {
679 $max_columns = max( $max_columns, count( $row ) );
680 }
681
682 // Build each row as a separate table to match flexbox behavior
683 $content_parts = array();
684 foreach ( $rows as $row ) {
685 $images_in_row = count( $row );
686
687 // Calculate width for each cell in this row (like flexbox)
688 $cell_width_percent = ( 100 / $images_in_row );
689
690 // Build table cells for this row
691 $row_cells = '';
692 foreach ( $row as $image ) {
693 $cell_style = sprintf(
694 'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
695 $cell_width_percent,
696 $cell_padding
697 );
698
699 // Set consistent height for all images in this row to ensure alignment
700 // Use progressive enhancement: object-fit for supported clients, natural layout for others
701 $image_styles = self::generate_image_styles( true );
702
703 $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
704
705 $row_cells .= sprintf(
706 '<td style="%s">%s</td>',
707 esc_attr( $cell_style ),
708 $cell_content
709 );
710 }
711
712 // Create a separate table for each row with flexible height for alignment
713 $row_table = sprintf(
714 '<table role="presentation" style="width: 100%%; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
715 $row_cells
716 );
717
718 $content_parts[] = $row_table;
719 }
720
721 // Use Table_Wrapper_Helper for the main container
722 $table_attrs = array(
723 'style' => 'width: 100%; border-collapse: collapse;',
724 );
725
726 return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
727 implode( '', $content_parts ),
728 $table_attrs
729 );
730 }
731
732 /**
733 * Generate mosaic layout rows based on image count.
734 *
735 * @param array $images Array of image data.
736 * @return array Array of rows, each containing images.
737 */
738 private static function generate_mosaic_rows( $images ) {
739 $rows = array();
740 $image_count = count( $images );
741
742 // More sophisticated mosaic algorithm based on image count
743 if ( $image_count <= 3 ) {
744 // For 3 or fewer images, use simple layout
745 $rows[] = $images;
746 } else {
747 // For more images, create varied row patterns
748 $patterns = array(
749 4 => array( 2, 2 ), // 4 images: 2 + 2
750 5 => array( 2, 3 ), // 5 images: 2 + 3
751 6 => array( 3, 3 ), // 6 images: 3 + 3
752 7 => array( 3, 2, 2 ), // 7 images: 3 + 2 + 2
753 8 => array( 3, 3, 2 ), // 8 images: 3 + 3 + 2
754 9 => array( 3, 3, 3 ), // 9 images: 3 + 3 + 3
755 );
756
757 if ( isset( $patterns[ $image_count ] ) ) {
758 // Use predefined pattern for 4-9 images
759 $pattern = $patterns[ $image_count ];
760 $image_index = 0;
761
762 foreach ( $pattern as $images_in_row ) {
763 $row = array();
764 for ( $i = 0; $i < $images_in_row; $i++ ) {
765 $row[] = $images[ $image_index ];
766 ++$image_index;
767 }
768 $rows[] = $row;
769 }
770 } else {
771 // For 10+ images, create rows of 3 with remainder handling
772 $full_rows = intval( $image_count / 3 );
773 $remainder = $image_count % 3;
774
775 $image_index = 0;
776
777 // Create full rows of 3
778 for ( $row = 0; $row < $full_rows; $row++ ) {
779 $rows[] = array(
780 $images[ $image_index ],
781 $images[ $image_index + 1 ],
782 $images[ $image_index + 2 ],
783 );
784 $image_index += 3;
785 }
786
787 // Handle remainder
788 if ( $remainder > 0 ) {
789 $remaining = array_slice( $images, $image_index );
790 $rows[] = $remaining;
791 }
792 }
793 }
794
795 return $rows;
796 }
797
798 /**
799 * Generate mosaic-style rows within a single column for columns layout.
800 *
801 * @param array $images Array of image data for this column.
802 * @return array Array of rows, each containing 1-2 images for variety.
803 */
804 private static function generate_column_mosaic_rows( $images ) {
805 $rows = array();
806 $image_count = count( $images );
807
808 if ( $image_count <= 2 ) {
809 // For 2 or fewer images, each gets its own row
810 foreach ( $images as $image ) {
811 $rows[] = array( $image );
812 }
813 } else {
814 // Create varied patterns: mix of single and paired images
815 $image_index = 0;
816
817 while ( $image_index < $image_count ) {
818 $remaining = $image_count - $image_index;
819
820 if ( $remaining === 1 ) {
821 // Last image - single row
822 $rows[] = array( $images[ $image_index ] );
823 ++$image_index;
824 } elseif ( $remaining === 3 ) {
825 // 3 remaining - do 1 + 2 for better balance
826 $rows[] = array( $images[ $image_index ] );
827 ++$image_index;
828 $rows[] = array( $images[ $image_index ], $images[ $image_index + 1 ] );
829 $image_index += 2;
830 } else {
831 // 2 or more remaining - alternate between single and pairs
832 $use_pair = ( count( $rows ) % 2 === 1 ); // Alternate pattern
833
834 if ( $use_pair && $remaining >= 2 ) {
835 // Create a pair
836 $rows[] = array( $images[ $image_index ], $images[ $image_index + 1 ] );
837 $image_index += 2;
838 } else {
839 // Single image
840 $rows[] = array( $images[ $image_index ] );
841 ++$image_index;
842 }
843 }
844 }
845 }
846
847 return $rows;
848 }
849
850 /**
851 * Generate border radius style based on layout style and border radius value.
852 *
853 * @param string $style Layout style (square, circle, etc.).
854 * @param int $border_radius Border radius value.
855 * @return string CSS border-radius style.
856 */
857 private static function generate_border_radius_style( $style, $border_radius ) {
858 if ( 'circle' === $style ) {
859 return 'border-radius:50%;';
860 } elseif ( $border_radius > 0 ) {
861 return 'border-radius:' . $border_radius . 'px;';
862 }
863 return '';
864 }
865
866 /**
867 * Generate image styles for email rendering.
868 *
869 * @param bool $use_fixed_height Whether to use fixed height with object-fit.
870 * @return string CSS style string.
871 */
872 private static function generate_image_styles( $use_fixed_height = false ) {
873 $base_styles = 'margin: 0; width: 100%; max-width: 100%; display: block;';
874
875 if ( $use_fixed_height ) {
876 return $base_styles . ' height: 200px; object-fit: cover; object-position: center;';
877 }
878
879 return $base_styles . ' height: auto;';
880 }
881
882 /**
883 * Generate image HTML with consistent styling.
884 *
885 * @param array $image Image data array.
886 * @param string $additional_styles Additional CSS styles.
887 * @param string $border_radius_style Border radius CSS.
888 * @param array $attr Block attributes (optional, for link processing).
889 * @return string Image HTML.
890 */
891 private static function generate_image_html( $image, $additional_styles = '', $border_radius_style = '', $attr = array() ) {
892 $base_styles = 'border:none;background-color:#0000001a;display:block;height:auto;max-width:100%;padding:0;';
893 $combined_styles = $base_styles . $additional_styles . $border_radius_style;
894
895 $img_html = sprintf(
896 '<img alt="%s" src="%s" style="%s" />',
897 esc_attr( $image['alt'] ),
898 esc_url( $image['url'] ),
899 $combined_styles
900 );
901
902 // Handle link settings for email
903 $link_to = ! empty( $attr['linkTo'] ) ? $attr['linkTo'] : 'none';
904 $href = self::get_image_link_href( $image, $attr, $link_to );
905
906 if ( ! empty( $href ) ) {
907 return sprintf( '<a href="%s">%s</a>', esc_url( $href ), $img_html );
908 }
909
910 return $img_html;
911 }
912
913 /**
914 * Get the href for an image based on link settings (used for email rendering).
915 * Excludes custom links which email clients will replace with the image.
916 *
917 * @since 15.0
918 *
919 * @param array $image Image data array.
920 * @param array $attr Block attributes.
921 * @param string $link_to Link setting.
922 * @return string The href URL or empty string.
923 */
924 private static function get_image_link_href( $image, $attr, $link_to ) {
925 switch ( $link_to ) {
926 case 'media':
927 return ! empty( $image['url'] ) ? $image['url'] : '';
928
929 case 'attachment':
930 // For email, we need to generate the attachment page URL from the image ID
931 if ( ! empty( $image['id'] ) ) {
932 $attachment_url = get_permalink( $image['id'] );
933 return $attachment_url ? $attachment_url : '';
934 }
935 return '';
936 default:
937 return '';
938 }
939 }
940
941 /**
942 * Create hierarchical chunks for square/circle layouts with larger items first.
943 *
944 * @param array $images Array of image data.
945 * @param int $columns Number of columns for the layout.
946 * @return array Array of rows with different sized chunks.
947 */
948 private static function create_hierarchical_chunks( $images, $columns ) {
949 $image_count = count( $images );
950 $chunks = array();
951
952 if ( $image_count <= $columns ) {
953 // For column count or fewer, single row
954 $chunks[] = $images;
955 } else {
956 // Calculate remainder when dividing by columns
957 $remainder = $image_count % $columns;
958 $start_index = 0;
959
960 // Handle all remainder cases to create proper hierarchy
961 if ( $remainder > 0 ) {
962 // Create a row with the remainder images (larger items first)
963 $chunks[] = array_slice( $images, 0, $remainder );
964 $start_index = $remainder;
965 }
966 // If remainder === 0, start_index stays 0
967
968 // Rest in groups of $columns
969 $remaining = array_slice( $images, $start_index );
970 $remaining_chunks = array_chunk( $remaining, $columns );
971 $chunks = array_merge( $chunks, $remaining_chunks );
972 }
973
974 return $chunks;
975 }
976 }
977
978 add_action( 'init', array( Tiled_Gallery::class, 'register' ) );
979