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