PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / extensions / blocks / tiled-gallery / tiled-gallery.php

tiled-gallery.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at extensions/blocks/tiled-gallery/tiled-gallery.php

979 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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