Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
16.08% covered (danger)
16.08%
69 / 429
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tiled_Gallery
16.20% covered (danger)
16.20%
69 / 426
0.00% covered (danger)
0.00%
0 / 20
10230.93
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
73.02% covered (warning)
73.02%
46 / 63
0.00% covered (danger)
0.00%
0 / 1
27.86
 get_source_file_dimensions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 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\Status;
15use Automattic\Jetpack\Status\Host;
16use Jetpack;
17use Jetpack_Gutenberg;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23/**
24 * Jetpack Tiled Gallery Block class
25 *
26 * @since 7.3
27 */
28class 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
978add_action( 'init', array( Tiled_Gallery::class, 'register' ) );