Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.42% covered (danger)
5.42%
23 / 424
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tiled_Gallery
5.46% covered (danger)
5.46%
23 / 421
0.00% covered (danger)
0.00%
0 / 19
13970.77
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 render
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
380
 interactive_markup
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 is_squareish_layout
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 render_email
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
9.04
 get_email_target_width
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 process_tiled_gallery_images_for_email
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
342
 get_layout_style_from_attributes
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 build_email_layout_content
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 build_square_layout_content
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
12
 build_columns_layout_content
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
72
 build_mosaic_layout_content
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 generate_mosaic_rows
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 generate_column_mosaic_rows
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 generate_border_radius_style
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 generate_image_styles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 generate_image_html
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 get_image_link_href
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 create_hierarchical_chunks
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
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
11namespace Automattic\Jetpack\Extensions;
12
13use Automattic\Jetpack\Blocks;
14use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
15use Automattic\Jetpack\Status;
16use Automattic\Jetpack\Status\Host;
17use Jetpack;
18use Jetpack_Gutenberg;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24/**
25 * Jetpack Tiled Gallery Block class
26 *
27 * @since 7.3
28 */
29class Tiled_Gallery {
30    /* Values for building srcsets */
31    const IMG_SRCSET_WIDTH_MAX  = 2000;
32    const IMG_SRCSET_WIDTH_MIN  = 600;
33    const IMG_SRCSET_WIDTH_STEP = 300;
34
35    /**
36     * Register the block
37     */
38    public static function register() {
39        if (
40            ( defined( 'IS_WPCOM' ) && IS_WPCOM )
41            || Jetpack::is_connection_ready()
42            || ( new Status() )->is_offline_mode()
43        ) {
44            Blocks::jetpack_register_block(
45                __DIR__,
46                array(
47                    'render_callback'       => array( __CLASS__, 'render' ),
48                    'render_email_callback' => array( __CLASS__, 'render_email' ),
49                )
50            );
51        }
52    }
53
54    /**
55     * Tiled gallery block registration
56     *
57     * @param array  $attr    Array containing the block attributes.
58     * @param string $content String containing the block content.
59     *
60     * @return string
61     */
62    public static function render( $attr, $content ) {
63        Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
64
65        $is_squareish_layout = self::is_squareish_layout( $attr );
66        // For backward compatibility (ensuring Tiled Galleries using now deprecated versions of the block are not affected).
67        // See isVIP() in utils/index.js.
68        $jetpack_plan = Jetpack_Plan::get();
69        wp_localize_script( 'jetpack-gallery-settings', 'jetpack_plan', array( 'data' => $jetpack_plan['product_slug'] ) );
70
71        if ( preg_match_all( '/<img [^>]+>/', $content, $images ) ) {
72            /**
73             * This block processes all of the images that are found and builds $find and $replace.
74             *
75             * The original img is added to the $find array and the replacement is made and added
76             * to the $replace array. This is so that the same find and replace operations can be
77             * made on the entire $content.
78             */
79            $find          = array();
80            $replace       = array();
81            $image_index   = 0;
82            $number_images = count( $images[0] );
83
84            foreach ( $images[0] as $image_html ) {
85                if (
86                    preg_match( '/data-width="([0-9]+)"/', $image_html, $img_width )
87                    && preg_match( '/data-height="([0-9]+)"/', $image_html, $img_height )
88                    && preg_match( '/src="([^"]+)"/', $image_html, $img_src )
89                ) {
90                    ++$image_index;
91                    // Drop img src query string so it can be used as a base to add photon params
92                    // for the srcset.
93                    $src_parts   = explode( '?', $img_src[1], 2 );
94                    $orig_src    = $src_parts[0];
95                    $orig_height = absint( $img_height[1] );
96                    $orig_width  = absint( $img_width[1] );
97
98                    // Because URLs are already "photon", the photon function used short-circuits
99                    // before ssl is added. Detect ssl and add is if necessary.
100                    $is_ssl = ! empty( $src_parts[1] ) && str_contains( $src_parts[1], 'ssl=1' );
101
102                    if ( ! $orig_width || ! $orig_height || ! $orig_src ) {
103                        continue;
104                    }
105
106                    $srcset_parts = array();
107                    if ( $is_squareish_layout ) {
108                        $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width, $orig_height );
109                        $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width, $orig_height );
110
111                        for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
112                            $srcset_src = add_query_arg(
113                                array(
114                                    'resize' => $w . ',' . $w,
115                                    'strip'  => 'info',
116                                ),
117                                $orig_src
118                            );
119                            if ( $is_ssl ) {
120                                $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
121                            }
122                            $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
123                            if ( $w >= $max_width ) {
124                                break;
125                            }
126                        }
127                    } else {
128                        $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width );
129                        $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width );
130
131                        for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
132                            $srcset_src = add_query_arg(
133                                array(
134                                    'strip' => 'info',
135                                    'w'     => $w,
136                                ),
137                                $orig_src
138                            );
139                            if ( $is_ssl ) {
140                                $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
141                            }
142                            $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
143                            if ( $w >= $max_width ) {
144                                break;
145                            }
146                        }
147                    }
148
149                    $img_element = self::interactive_markup( $image_index, $number_images );
150
151                    if ( ! empty( $srcset_parts ) ) {
152                        $srcset = 'srcset="' . esc_attr( implode( ',', $srcset_parts ) ) . '"';
153
154                        $find[]    = $image_html;
155                        $replace[] = str_replace( '<img', $img_element . $srcset, $image_html );
156                    }
157                }
158            }
159
160            if ( ! empty( $find ) ) {
161                $content = str_replace( $find, $replace, $content );
162            }
163        }
164
165        /**
166         * Filter the output of the Tiled Galleries content.
167         *
168         * @module tiled-gallery
169         *
170         * @since 6.9.0
171         *
172         * @param string $content Tiled Gallery block content.
173         */
174        return apply_filters( 'jetpack_tiled_galleries_block_content', $content );
175    }
176
177    /**
178     * Adds tabindex, role and aria-label markup for images that should be interactive (front-end only).
179     *
180     * @param integer $image_index Integer The current image index.
181     * @param integer $number_images Integer The total number of images.
182     */
183    private static function interactive_markup( $image_index, $number_images ) {
184
185        $host             = new Host();
186        $is_module_active = $host->is_wpcom_simple()
187        ? get_option( 'carousel_enable_it' )
188        : Jetpack::is_module_active( 'carousel' );
189
190        if ( $is_module_active ) {
191            $aria_label_content = sprintf(
192                /* Translators: %1$d is the current image index, %2$d is the total number of images. */
193                __( 'Open image %1$d of %2$d in full-screen', 'jetpack' ),
194                $image_index,
195                $number_images
196            );
197            $img_element = '<img role="button" tabindex="0" aria-label="' . esc_attr( $aria_label_content ) . '"';
198        } else {
199            $img_element = '<img ';
200        }
201        return $img_element;
202    }
203
204    /**
205     * Determines whether a Tiled Gallery block uses square or circle images (1:1 ratio)
206     *
207     * Layouts are block styles and will be available as `is-style-[LAYOUT]` in the className
208     * attribute. The default (rectangular) will be omitted.
209     *
210     * @param array $attr Attributes key/value array.
211     * @return boolean True if layout is squareish, otherwise false.
212     */
213    private static function is_squareish_layout( $attr ) {
214        return isset( $attr['className'] )
215            && (
216                'is-style-square' === $attr['className']
217                || 'is-style-circle' === $attr['className']
218            );
219    }
220
221    /**
222     * Render tiled gallery block for email.
223     *
224     * @since 15.0
225     *
226     * @param string $block_content     The original block HTML content.
227     * @param array  $parsed_block      The parsed block data including attributes.
228     * @param object $rendering_context Email rendering context.
229     *
230     * @return string
231     */
232    public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
233        // Validate input parameters and required dependencies
234        if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
235            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
236            ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
237            return '';
238        }
239
240        // Get spacing from email_attrs for better consistency with core blocks
241        $email_attrs        = $parsed_block['email_attrs'] ?? array();
242        $table_margin_style = '';
243
244        if ( ! empty( $email_attrs ) && class_exists( '\WP_Style_Engine' ) ) {
245            // Get margin for table styling
246            $table_margin_style = \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin' ) ) ), '' ) ?? '';
247        }
248
249        // Email cell padding
250        $email_cell_padding = 2;  // Cell padding
251
252        $attr = $parsed_block['attrs'];
253
254        // Determine layout style and columns from attributes (needed for both image processing and layout building)
255        $layout_info = self::get_layout_style_from_attributes( $attr );
256
257        // Process images for email rendering
258        $images = self::process_tiled_gallery_images_for_email( $attr, $layout_info );
259
260        if ( empty( $images ) ) {
261            return '';
262        }
263
264        // Determine target width from the email layout if available
265        $target_width = self::get_email_target_width( $rendering_context );
266
267        // Build layout content based on style and columns
268        $grid_content = self::build_email_layout_content( $images, $layout_info, $email_cell_padding, $attr );
269
270        // Use Table_Wrapper_Helper for consistent email rendering
271        $table_style = sprintf( 'width: 100%%; max-width: %dpx; padding: 0; border-collapse: collapse;', $target_width );
272        if ( ! empty( $table_margin_style ) ) {
273            $table_style = $table_margin_style . '; ' . $table_style;
274        } else {
275            $table_style = 'margin: 16px 0; ' . $table_style;
276        }
277
278        $image_table_attrs = array(
279            'style' => $table_style,
280        );
281
282        $html = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( $grid_content, $image_table_attrs );
283
284        return $html;
285    }
286
287    /**
288     * Get target width for email rendering.
289     *
290     * @param object $rendering_context Email rendering context.
291     * @return int Target width in pixels.
292     */
293    private static function get_email_target_width( $rendering_context ) {
294        $target_width = 600; // Default
295
296        if ( ! empty( $rendering_context ) && is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
297            $layout_width_px = $rendering_context->get_layout_width_without_padding();
298            if ( is_string( $layout_width_px ) ) {
299                $parsed_width = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $layout_width_px );
300                if ( $parsed_width > 0 ) {
301                    $target_width = $parsed_width;
302                }
303            }
304        }
305
306        return $target_width;
307    }
308
309    /**
310     * Process tiled gallery images for email rendering.
311     *
312     * @param array $attr Block attributes containing image data.
313     * @param array $layout_info Layout information from get_layout_style_from_attributes.
314     * @return array Processed image data for email rendering.
315     */
316    private static function process_tiled_gallery_images_for_email( $attr, $layout_info ) {
317        $images = array();
318
319        // Determine if this is a squareish layout
320        $is_squareish = in_array( $layout_info['style'], array( 'square', 'circle' ), true );
321
322        // Get images from IDs (primary data source)
323        if ( ! empty( $attr['ids'] ) && is_array( $attr['ids'] ) ) {
324            foreach ( $attr['ids'] as $id ) {
325                // Validate ID is a positive integer and attachment exists
326                $id = absint( $id );
327                if ( ! $id || ! wp_attachment_is_image( $id ) ) {
328                    continue;
329                }
330
331                // For square/circle layouts, get a high-quality square crop
332                if ( $is_squareish ) {
333                    // Start with full size image for better quality when resizing
334                    $image_url = wp_get_attachment_image_url( $id, 'full' );
335
336                    // If we have Photon/Jetpack image processing, request high-quality square crop
337                    if ( function_exists( 'jetpack_photon_url' ) && $image_url ) {
338                        $image_url = add_query_arg(
339                            array(
340                                'resize' => '1300,1300', // High-quality square crop for email
341                                'crop'   => '1',
342                            ),
343                            $image_url
344                        );
345                    }
346                } else {
347                    $image_url = wp_get_attachment_image_url( $id, 'large' );
348                }
349
350                // Sanitize alt text from post meta
351                $alt_text = get_post_meta( $id, '_wp_attachment_image_alt', true );
352                $alt_text = sanitize_text_field( $alt_text );
353
354                if ( $image_url ) {
355                    $images[] = array(
356                        'url' => $image_url,
357                        'alt' => $alt_text,
358                        'id'  => $id,
359                    );
360                }
361            }
362        } elseif ( ! empty( $attr['images'] ) && is_array( $attr['images'] ) ) {
363            // Fall back to images array if IDs aren't available
364            foreach ( $attr['images'] as $image_data ) {
365                if ( ! empty( $image_data['url'] ) ) {
366                    // Validate and sanitize URL
367                    $url = esc_url_raw( $image_data['url'] );
368                    if ( ! $url || ! wp_http_validate_url( $url ) ) {
369                        continue;
370                    }
371
372                    // Sanitize alt text
373                    $alt_text = ! empty( $image_data['alt'] ) ? sanitize_text_field( $image_data['alt'] ) : '';
374
375                    // Validate ID if present
376                    $id = ! empty( $image_data['id'] ) ? absint( $image_data['id'] ) : 0;
377
378                    $images[] = array(
379                        'url' => $url,
380                        'alt' => $alt_text,
381                        'id'  => $id,
382                    );
383                }
384            }
385        }
386
387        return $images;
388    }
389
390    /**
391     * Get layout style and columns from block attributes.
392     *
393     * @param array $attr Block attributes.
394     * @return array Array with 'style', 'columns', and 'border_radius' keys.
395     */
396    private static function get_layout_style_from_attributes( $attr ) {
397        $layout_info = array(
398            'style'         => 'rectangular', // Default to rectangular/mosaic layout
399            'columns'       => 3, // Default to 3 columns
400            'border_radius' => 0, // Default to no border radius
401        );
402
403        // Get number of columns from attributes with validation
404        if ( ! empty( $attr['columns'] ) && is_numeric( $attr['columns'] ) ) {
405            $columns = absint( $attr['columns'] );
406            // Clamp columns between 1 and 6 for reasonable layouts
407            $layout_info['columns'] = max( 1, min( 6, $columns ) );
408        }
409
410        // Get border radius from roundedCorners attribute (preferred method)
411        if ( ! empty( $attr['roundedCorners'] ) && is_numeric( $attr['roundedCorners'] ) ) {
412            $border_radius_value = absint( $attr['roundedCorners'] );
413            // Clamp value between 0 and 20
414            $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
415        }
416
417        // Get layout style and border radius from className
418        if ( ! empty( $attr['className'] ) ) {
419            if ( str_contains( $attr['className'], 'is-style-square' ) ) {
420                $layout_info['style'] = 'square';
421            } elseif ( str_contains( $attr['className'], 'is-style-circle' ) ) {
422                $layout_info['style'] = 'circle';
423            } elseif ( str_contains( $attr['className'], 'is-style-columns' ) ) {
424                $layout_info['style'] = 'columns';
425            }
426
427            // Extract border radius from has-rounded-corners-{value} class (fallback method)
428            if ( $layout_info['border_radius'] === 0 && preg_match( '/has-rounded-corners-(\d+)/', $attr['className'], $matches ) ) {
429                $border_radius_value = absint( $matches[1] );
430                    // Clamp value between 0 and 20
431                $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
432            }
433        }
434
435        return $layout_info;
436    }
437
438    /**
439     * Build email layout content based on layout style and columns.
440     *
441     * @param array $images Array of image data.
442     * @param array $layout_info Array with 'style' and 'columns' keys.
443     * @param int   $cell_padding Cell padding.
444     * @param array $attr Block attributes.
445     * @return string HTML content.
446     */
447    private static function build_email_layout_content( $images, $layout_info, $cell_padding, $attr ) {
448        $layout_style  = $layout_info['style'];
449        $columns       = $layout_info['columns'];
450        $border_radius = $layout_info['border_radius'];
451
452        switch ( $layout_style ) {
453            case 'square':
454                return self::build_square_layout_content( $images, $cell_padding, $columns, 'square', $border_radius, $attr );
455            case 'circle':
456                return self::build_square_layout_content( $images, $cell_padding, $columns, 'circle', $border_radius, $attr );
457            case 'columns':
458                return self::build_columns_layout_content( $images, $cell_padding, $columns, $border_radius, $attr );
459            case 'rectangular':
460            default:
461                return self::build_mosaic_layout_content( $images, $cell_padding, $border_radius, $attr );
462        }
463    }
464
465    /**
466     * Build square/circle layout content.
467     *
468     * @param array  $images Array of image data.
469     * @param int    $cell_padding Cell padding.
470     * @param int    $columns Number of columns for the layout.
471     * @param string $style Layout style (square or circle).
472     * @param int    $border_radius Border radius value (0-20).
473     * @param array  $attr Block attributes.
474     * @return string HTML content.
475     */
476    private static function build_square_layout_content( $images, $cell_padding, $columns, $style = 'square', $border_radius = 0, $attr = array() ) {
477        $content_parts = array();
478
479        // Create rows of images with hierarchical chunks for square/circle layouts
480        $image_chunks = self::create_hierarchical_chunks( $images, $columns );
481
482        $border_radius_style = self::generate_border_radius_style( $style, $border_radius );
483
484        foreach ( $image_chunks as $row_images ) {
485            $images_in_row      = count( $row_images );
486            $cell_width_percent = ( 100 / $images_in_row );
487
488            // Build table cells for this row
489            $row_cells = '';
490            foreach ( $row_images as $image ) {
491                // Calculate cell attributes with consistent padding
492                $cell_attrs = array(
493                    'style' => sprintf(
494                        'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
495                        $cell_width_percent,
496                        $cell_padding
497                    ),
498                );
499
500                $image_styles = self::generate_image_styles( false );
501
502                $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
503
504                $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
505                    $cell_content,
506                    $cell_attrs
507                );
508            }
509
510            // Use Table_Wrapper_Helper for email-compatible table rendering
511            $table_attrs = array(
512                'style' => 'width: 100%; border-collapse: collapse;',
513            );
514
515            $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
516                $row_cells,
517                $table_attrs
518            );
519        }
520
521        // Use Table_Wrapper_Helper for consistent email rendering
522        $wrapper_attrs = array(
523            'style' => 'width: 100%; border-collapse: collapse;',
524        );
525
526        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
527            implode( '', $content_parts ),
528            $wrapper_attrs
529        );
530    }
531
532    /**
533     * Build columns layout content using mosaic logic organized into columns.
534     *
535     * @param array $images Array of image data.
536     * @param int   $cell_padding Cell padding.
537     * @param int   $columns Number of columns for the layout.
538     * @param int   $border_radius Border radius value (0-20).
539     * @param array $attr Block attributes.
540     * @return string HTML content.
541     */
542    private static function build_columns_layout_content( $images, $cell_padding, $columns, $border_radius = 0, $attr = array() ) {
543        $content_parts       = array();
544        $border_radius_style = self::generate_border_radius_style( '', $border_radius );
545
546        // Distribute images across columns using round-robin approach for better balance
547        $column_arrays = array_fill( 0, $columns, array() );
548        foreach ( $images as $index => $image ) {
549            $column_index                     = $index % $columns;
550            $column_arrays[ $column_index ][] = $image;
551        }
552
553        // Build table cells for columns layout
554        $row_cells = '';
555        foreach ( $column_arrays as $column_images ) {
556            if ( empty( $column_images ) ) {
557                // Add empty cell for balance
558                $cell_attrs = array(
559                    'style' => sprintf(
560                        'width: %s%%; padding: %dpx; vertical-align: top;',
561                        ( 100 / $columns ),
562                        $cell_padding
563                    ),
564                );
565                $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
566                    '',
567                    $cell_attrs
568                );
569                continue;
570            }
571
572            // Calculate cell attributes
573            $cell_width_percent = ( 100 / $columns );
574            $cell_attrs         = array(
575                'style' => sprintf(
576                    'width: %s%%; padding: %dpx; vertical-align: top;',
577                    $cell_width_percent,
578                    $cell_padding
579                ),
580            );
581
582            // Generate mosaic-style groupings within this column
583            $column_rows = self::generate_column_mosaic_rows( $column_images );
584
585            $cell_content = '';
586            foreach ( $column_rows as $row_index => $row_images ) {
587                foreach ( $row_images as $image ) {
588                    $image_styles = self::generate_image_styles( false );
589
590                    // Add top margin to all images except the first one in the column
591                    if ( $row_index > 0 || $cell_content !== '' ) {
592                        $image_styles .= ' margin-top: ' . ( $cell_padding * 2 ) . 'px;';
593                    }
594
595                    $cell_content .= self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
596                }
597            }
598
599            $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
600                $cell_content,
601                $cell_attrs
602            );
603        }
604
605        // Use Table_Wrapper_Helper for email-compatible table rendering
606        $table_attrs = array(
607            'style' => 'width: 100%; border-collapse: collapse;',
608        );
609
610        $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
611            $row_cells,
612            $table_attrs
613        );
614
615        // Use Table_Wrapper_Helper for consistent email rendering
616        $wrapper_attrs = array(
617            'style' => 'width: 100%; border-collapse: collapse;',
618        );
619
620        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
621            implode( '', $content_parts ),
622            $wrapper_attrs
623        );
624    }
625
626    /**
627     * Build mosaic layout content with flexible row/column structure.
628     *
629     * @param array $images Array of image data.
630     * @param int   $cell_padding Cell padding.
631     * @param int   $border_radius Border radius value (0-20).
632     * @param array $attr Block attributes.
633     * @return string HTML content.
634     */
635    private static function build_mosaic_layout_content( $images, $cell_padding, $border_radius = 0, $attr = array() ) {
636        $border_radius_style = self::generate_border_radius_style( '', $border_radius );
637
638        // Generate mosaic layout rows
639        $rows = self::generate_mosaic_rows( $images );
640
641        // Determine the maximum number of columns to ensure consistent layout
642        $max_columns = 0;
643        foreach ( $rows as $row ) {
644            $max_columns = max( $max_columns, count( $row ) );
645        }
646
647        // Build each row as a separate table to match flexbox behavior
648        $content_parts = array();
649        foreach ( $rows as $row ) {
650            $images_in_row = count( $row );
651
652            // Calculate width for each cell in this row (like flexbox)
653            $cell_width_percent = ( 100 / $images_in_row );
654
655            // Build table cells for this row
656            $row_cells = '';
657            foreach ( $row as $image ) {
658                $cell_style = sprintf(
659                    'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
660                    $cell_width_percent,
661                    $cell_padding
662                );
663
664                // Set consistent height for all images in this row to ensure alignment
665                // Use progressive enhancement: object-fit for supported clients, natural layout for others
666                $image_styles = self::generate_image_styles( true );
667
668                $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
669
670                $row_cells .= sprintf(
671                    '<td style="%s">%s</td>',
672                    esc_attr( $cell_style ),
673                    $cell_content
674                );
675            }
676
677            // Create a separate table for each row with flexible height for alignment
678            $row_table = sprintf(
679                '<table role="presentation" style="width: 100%%; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
680                $row_cells
681            );
682
683            $content_parts[] = $row_table;
684        }
685
686        // Use Table_Wrapper_Helper for the main container
687        $table_attrs = array(
688            'style' => 'width: 100%; border-collapse: collapse;',
689        );
690
691        return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
692            implode( '', $content_parts ),
693            $table_attrs
694        );
695    }
696
697    /**
698     * Generate mosaic layout rows based on image count.
699     *
700     * @param array $images Array of image data.
701     * @return array Array of rows, each containing images.
702     */
703    private static function generate_mosaic_rows( $images ) {
704        $rows        = array();
705        $image_count = count( $images );
706
707        // More sophisticated mosaic algorithm based on image count
708        if ( $image_count <= 3 ) {
709            // For 3 or fewer images, use simple layout
710            $rows[] = $images;
711        } else {
712            // For more images, create varied row patterns
713            $patterns = array(
714                4 => array( 2, 2 ),      // 4 images: 2 + 2
715                5 => array( 2, 3 ),      // 5 images: 2 + 3
716                6 => array( 3, 3 ),      // 6 images: 3 + 3
717                7 => array( 3, 2, 2 ),   // 7 images: 3 + 2 + 2
718                8 => array( 3, 3, 2 ),   // 8 images: 3 + 3 + 2
719                9 => array( 3, 3, 3 ),   // 9 images: 3 + 3 + 3
720            );
721
722            if ( isset( $patterns[ $image_count ] ) ) {
723                // Use predefined pattern for 4-9 images
724                $pattern     = $patterns[ $image_count ];
725                $image_index = 0;
726
727                foreach ( $pattern as $images_in_row ) {
728                    $row = array();
729                    for ( $i = 0; $i < $images_in_row; $i++ ) {
730                        $row[] = $images[ $image_index ];
731                        ++$image_index;
732                    }
733                    $rows[] = $row;
734                }
735            } else {
736                // For 10+ images, create rows of 3 with remainder handling
737                $full_rows = intval( $image_count / 3 );
738                $remainder = $image_count % 3;
739
740                $image_index = 0;
741
742                // Create full rows of 3
743                for ( $row = 0; $row < $full_rows; $row++ ) {
744                    $rows[]       = array(
745                        $images[ $image_index ],
746                        $images[ $image_index + 1 ],
747                        $images[ $image_index + 2 ],
748                    );
749                    $image_index += 3;
750                }
751
752                // Handle remainder
753                if ( $remainder > 0 ) {
754                    $remaining = array_slice( $images, $image_index );
755                    $rows[]    = $remaining;
756                }
757            }
758        }
759
760        return $rows;
761    }
762
763    /**
764     * Generate mosaic-style rows within a single column for columns layout.
765     *
766     * @param array $images Array of image data for this column.
767     * @return array Array of rows, each containing 1-2 images for variety.
768     */
769    private static function generate_column_mosaic_rows( $images ) {
770        $rows        = array();
771        $image_count = count( $images );
772
773        if ( $image_count <= 2 ) {
774            // For 2 or fewer images, each gets its own row
775            foreach ( $images as $image ) {
776                $rows[] = array( $image );
777            }
778        } else {
779            // Create varied patterns: mix of single and paired images
780            $image_index = 0;
781
782            while ( $image_index < $image_count ) {
783                $remaining = $image_count - $image_index;
784
785                if ( $remaining === 1 ) {
786                    // Last image - single row
787                    $rows[] = array( $images[ $image_index ] );
788                    ++$image_index;
789                } elseif ( $remaining === 3 ) {
790                    // 3 remaining - do 1 + 2 for better balance
791                    $rows[] = array( $images[ $image_index ] );
792                    ++$image_index;
793                    $rows[]       = array( $images[ $image_index ], $images[ $image_index + 1 ] );
794                    $image_index += 2;
795                } else {
796                    // 2 or more remaining - alternate between single and pairs
797                    $use_pair = ( count( $rows ) % 2 === 1 ); // Alternate pattern
798
799                    if ( $use_pair && $remaining >= 2 ) {
800                        // Create a pair
801                        $rows[]       = array( $images[ $image_index ], $images[ $image_index + 1 ] );
802                        $image_index += 2;
803                    } else {
804                        // Single image
805                        $rows[] = array( $images[ $image_index ] );
806                        ++$image_index;
807                    }
808                }
809            }
810        }
811
812        return $rows;
813    }
814
815    /**
816     * Generate border radius style based on layout style and border radius value.
817     *
818     * @param string $style Layout style (square, circle, etc.).
819     * @param int    $border_radius Border radius value.
820     * @return string CSS border-radius style.
821     */
822    private static function generate_border_radius_style( $style, $border_radius ) {
823        if ( 'circle' === $style ) {
824            return 'border-radius:50%;';
825        } elseif ( $border_radius > 0 ) {
826            return 'border-radius:' . $border_radius . 'px;';
827        }
828        return '';
829    }
830
831    /**
832     * Generate image styles for email rendering.
833     *
834     * @param bool $use_fixed_height Whether to use fixed height with object-fit.
835     * @return string CSS style string.
836     */
837    private static function generate_image_styles( $use_fixed_height = false ) {
838        $base_styles = 'margin: 0; width: 100%; max-width: 100%; display: block;';
839
840        if ( $use_fixed_height ) {
841            return $base_styles . ' height: 200px; object-fit: cover; object-position: center;';
842        }
843
844        return $base_styles . ' height: auto;';
845    }
846
847    /**
848     * Generate image HTML with consistent styling.
849     *
850     * @param array  $image Image data array.
851     * @param string $additional_styles Additional CSS styles.
852     * @param string $border_radius_style Border radius CSS.
853     * @param array  $attr Block attributes (optional, for link processing).
854     * @return string Image HTML.
855     */
856    private static function generate_image_html( $image, $additional_styles = '', $border_radius_style = '', $attr = array() ) {
857        $base_styles     = 'border:none;background-color:#0000001a;display:block;height:auto;max-width:100%;padding:0;';
858        $combined_styles = $base_styles . $additional_styles . $border_radius_style;
859
860        $img_html = sprintf(
861            '<img alt="%s" src="%s" style="%s" />',
862            esc_attr( $image['alt'] ),
863            esc_url( $image['url'] ),
864            $combined_styles
865        );
866
867        // Handle link settings for email
868        $link_to = ! empty( $attr['linkTo'] ) ? $attr['linkTo'] : 'none';
869        $href    = self::get_image_link_href( $image, $attr, $link_to );
870
871        if ( ! empty( $href ) ) {
872            return sprintf( '<a href="%s">%s</a>', esc_url( $href ), $img_html );
873        }
874
875        return $img_html;
876    }
877
878    /**
879     * Get the href for an image based on link settings (used for email rendering).
880     * Excludes custom links which email clients will replace with the image.
881     *
882     * @since 15.0
883     *
884     * @param array  $image Image data array.
885     * @param array  $attr Block attributes.
886     * @param string $link_to Link setting.
887     * @return string The href URL or empty string.
888     */
889    private static function get_image_link_href( $image, $attr, $link_to ) {
890        switch ( $link_to ) {
891            case 'media':
892                return ! empty( $image['url'] ) ? $image['url'] : '';
893
894            case 'attachment':
895                // For email, we need to generate the attachment page URL from the image ID
896                if ( ! empty( $image['id'] ) ) {
897                    $attachment_url = get_permalink( $image['id'] );
898                    return $attachment_url ? $attachment_url : '';
899                }
900                return '';
901            default:
902                return '';
903        }
904    }
905
906    /**
907     * Create hierarchical chunks for square/circle layouts with larger items first.
908     *
909     * @param array $images Array of image data.
910     * @param int   $columns Number of columns for the layout.
911     * @return array Array of rows with different sized chunks.
912     */
913    private static function create_hierarchical_chunks( $images, $columns ) {
914        $image_count = count( $images );
915        $chunks      = array();
916
917        if ( $image_count <= $columns ) {
918            // For column count or fewer, single row
919            $chunks[] = $images;
920        } else {
921            // Calculate remainder when dividing by columns
922            $remainder   = $image_count % $columns;
923            $start_index = 0;
924
925            // Handle all remainder cases to create proper hierarchy
926            if ( $remainder > 0 ) {
927                // Create a row with the remainder images (larger items first)
928                $chunks[]    = array_slice( $images, 0, $remainder );
929                $start_index = $remainder;
930            }
931            // If remainder === 0, start_index stays 0
932
933            // Rest in groups of $columns
934            $remaining        = array_slice( $images, $start_index );
935            $remaining_chunks = array_chunk( $remaining, $columns );
936            $chunks           = array_merge( $chunks, $remaining_chunks );
937        }
938
939        return $chunks;
940    }
941}
942
943add_action( 'init', array( Tiled_Gallery::class, 'register' ) );