diff --git a/inc/Abilities/Media/Templates/StackTemplate.php b/inc/Abilities/Media/Templates/StackTemplate.php index 895ff121b..dac9362c9 100644 --- a/inc/Abilities/Media/Templates/StackTemplate.php +++ b/inc/Abilities/Media/Templates/StackTemplate.php @@ -14,8 +14,9 @@ * - qr: url, size (content-width fraction, default .5), card_color (white), * quiet_modules (1..4, default 4). Provider receives final QR pixel size. * - row: items (text/image blocks), gap (16px); centered vertically, one line. - * - list: items (strings), style (numbered|bullets|plain), marker_color, - * font, color, size (32), line_height (1.2). + * - list: items (strings or objects with text and optional color, font, + * marker_color overrides), style (numbered|bullets|plain), marker_color, + * font (heading|body|display|brand), color, size (32), line_height (1.2). * - divider: width (fraction or px), thickness (2), color. * - spacer: height (px), flex (false). Flex spacers receive spare height; * otherwise spare height is distributed between blocks. @@ -79,7 +80,7 @@ public function get_fields(): array { 'required' => false, ), 'blocks' => array( - 'label' => 'Ordered blocks; each accepts gap_before (base px). text: text/font/color/size/fit/max_size/wrap/line_height/align; image: path or attachment_id, width (fraction or px), max_height; qr: url/size (fraction)/card_color/quiet_modules; row: items (text/image), gap; list: items/style (numbered|bullets|plain)/marker_color/font/color/size; divider: width/thickness/color; spacer: height/flex. Pixels scale from 1080px. Fonts and colors accept brand tokens; colors also accept hex.', + 'label' => 'Ordered blocks; each accepts gap_before (base px). text: text/font/color/size/fit/max_size/wrap/line_height/align; image: path or attachment_id, width (fraction or px), max_height; qr: url/size (fraction)/card_color/quiet_modules; row: items (text/image), gap; list: items (strings or objects with text and optional color/font/marker_color overrides)/style (numbered|bullets|plain)/marker_color/font (heading|body|display|brand)/color/size; divider: width/thickness/color; spacer: height/flex. Pixels scale from 1080px. Fonts and colors accept brand tokens; colors also accept hex.', 'type' => 'array', 'required' => true, ), @@ -135,10 +136,31 @@ private function measure( GDRenderer $r, array $b, int $width, float $scale, flo } else { $p['style'] = $b['style'] ?? 'numbered'; $p['marker_color'] = $b['marker_color'] ?? 'accent'; - $p['items'] = array_values( array_filter( (array) ( $b['items'] ?? array() ), 'is_scalar' ) ); - $p['gutter'] = 'plain' === $p['style'] ? 0 : $r->measure_text_width( (string) count( $p['items'] ) . '.', $p['size'], $p['font'] ) + (int) ceil( $p['size'] * .6 ); + $p['items'] = array(); + foreach ( (array) ( $b['items'] ?? array() ) as $i => $item ) { + if ( is_scalar( $item ) ) { + $item = array( 'text' => (string) $item ); + } elseif ( ! is_array( $item ) || ! isset( $item['text'] ) || ! is_scalar( $item['text'] ) ) { + $this->warn( "$key.items.$i", "$key.items.$i: list item requires scalar text; skipped." ); + continue; + } + $p['items'][] = array( + 'text' => (string) $item['text'], + 'font' => in_array( $item['font'] ?? '', array( 'heading', 'body', 'display', 'brand' ), true ) ? $item['font'] : $p['font'], + 'color' => $item['color'] ?? $p['color'], + 'marker_color' => $item['marker_color'] ?? $p['marker_color'], + ); + } + // Reserve one shared gutter, wide enough for every item's font. + $p['gutter'] = 0; + if ( 'plain' !== $p['style'] ) { + $p['gutter'] = $r->measure_text_width( (string) count( $p['items'] ) . '.', $p['size'], $p['font'] ) + (int) ceil( $p['size'] * .6 ); + foreach ( $p['items'] as $item ) { + $p['gutter'] = max( $p['gutter'], $r->measure_text_width( (string) count( $p['items'] ) . '.', $p['size'], $item['font'] ) + (int) ceil( $p['size'] * .6 ) ); + } + } foreach ( $p['items'] as $item ) { - $p['height'] += $r->measure_text_height( (string) $item, $p['size'], $p['font'], max( 1, $width - $p['gutter'] ), $p['line_height'] ); + $p['height'] += $r->measure_text_height( $item['text'], $p['size'], $item['font'], max( 1, $width - $p['gutter'] ), $p['line_height'] ); } } } elseif ( 'image' === $type ) { @@ -290,9 +312,9 @@ private function draw( GDRenderer $r, array $b, int $x, int $y ): void { foreach ( $b['items'] as $i => $item ) { $marker = 'numbered' === $b['style'] ? ( $i + 1 ) . '.' : ( 'bullets' === $b['style'] ? '•' : '' ); if ( '' !== $marker ) { - $r->draw_text( $marker, $b['size'], $x, $y + $b['size'], $this->color( $r, $b['marker_color'] ), $b['font'] ); + $r->draw_text( $marker, $b['size'], $x, $y + $b['size'], $this->color( $r, $item['marker_color'] ), $item['font'] ); } - $y = $r->draw_text_wrapped( (string) $item, $b['size'], $x + $b['gutter'], $y, $this->color( $r, $b['color'] ), $b['font'], max( 1, (int) ( $b['width'] - $b['gutter'] ) ), $b['line_height'] ); + $y = $r->draw_text_wrapped( $item['text'], $b['size'], $x + $b['gutter'], $y, $this->color( $r, $item['color'] ), $item['font'], max( 1, (int) ( $b['width'] - $b['gutter'] ) ), $b['line_height'] ); } } elseif ( 'divider' === $b['type'] ) { $r->filled_rect( $x, $y, $x + $b['width'] - 1, $y + $b['height'] - 1, $this->color( $r, $b['color'] ) ); diff --git a/tests/Unit/Abilities/Media/StackTemplateTest.php b/tests/Unit/Abilities/Media/StackTemplateTest.php index 49367f514..20fd9e35e 100644 --- a/tests/Unit/Abilities/Media/StackTemplateTest.php +++ b/tests/Unit/Abilities/Media/StackTemplateTest.php @@ -149,6 +149,94 @@ public function test_overflow_shrinks_text_and_lists_before_warning(): void { $this->assertSame( array(), $t->get_dropped_fields() ); } + public function test_string_list_items_keep_block_styles_and_measurement(): void { + $r = ( new GDRenderer() )->create_canvas( 1080, 1350 ); + $t = new StackTemplate(); + $items = array( 'First step', 'A longer second step to wrap across the available width' ); + $box = $t->plan_layout( $r, array( 'blocks' => array( array( 'type' => 'list', 'items' => $items, 'font' => 'heading', 'color' => '#123456', 'marker_color' => '#abcdef' ) ) ) )[0]; + $gutter = $r->measure_text_width( '2.', 32, 'heading' ) + (int) ceil( 32 * .6 ); + $this->assertSame( $gutter, $box['gutter'] ); + $height = 0; + foreach ( $items as $i => $text ) { + $this->assertSame( array( 'text' => $text, 'font' => 'heading', 'color' => '#123456', 'marker_color' => '#abcdef' ), $box['items'][ $i ] ); + $height += $r->measure_text_height( $text, 32, 'heading', 952 - $gutter, 1.2 ); + } + $this->assertSame( $height, $box['height'] ); + $this->assertSame( array(), $t->get_warnings() ); + } + + public function test_list_item_colors_render_without_leaking_to_other_items(): void { + $r = new GDRenderer(); + $t = new StackTemplate(); + $data = array( + 'background' => '#000000', + 'brand' => array( 'colors' => array( 'accent' => '#ff0000' ) ), + 'blocks' => array( array( + 'type' => 'list', 'color' => '#ffffff', 'marker_color' => '#ffffff', 'size' => 64, + 'items' => array( 'MMMM', array( 'text' => 'MMMM', 'color' => 'accent', 'marker_color' => '#00ff00', 'font' => 'heading' ), 'MMMM' ), + ) ), + ); + $paths = \DataMachine\Abilities\Media\BrandTokens::with_overrides( $data['brand'], static fn() => $t->render( $data, $r ) ); + $this->files = array_merge( $this->files, $paths ); + $box = $t->plan_layout( $r, $data )[0]; + $line = (int) ( 64 * 1.2 ); + foreach ( array( 0 => 0xffffff, 1 => 0xff0000, 2 => 0xffffff ) as $i => $expected ) { + $colors = array(); + for ( $y = $box['y'] + $i * $line; $y < $box['y'] + ( $i + 1 ) * $line; ++$y ) { + for ( $x = $box['x'] + $box['gutter']; $x < $box['x'] + $box['width']; ++$x ) { + $colors[ imagecolorat( $r->get_image(), $x, $y ) & 0xffffff ] = true; + } + } + $this->assertArrayHasKey( $expected, $colors ); + $this->assertArrayNotHasKey( 1 === $i ? 0xffffff : 0xff0000, $colors ); + } + $green = false; + for ( $y = $box['y'] + $line; $y < $box['y'] + 2 * $line; ++$y ) { + for ( $x = $box['x']; $x < $box['x'] + $box['gutter']; ++$x ) { + $green = $green || 0x00ff00 === ( imagecolorat( $r->get_image(), $x, $y ) & 0xffffff ); + } + } + $this->assertTrue( $green, 'The item marker uses its own color.' ); + } + + public function test_item_font_controls_wrapping_height_and_shared_gutter(): void { + $body = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; + $heading = '/usr/share/fonts/truetype/freefont/FreeMono.ttf'; + if ( ! is_readable( $body ) || ! is_readable( $heading ) ) { + $this->markTestSkipped( 'Two distinct test fonts required.' ); + } + $r = ( new GDRenderer() )->create_canvas( 1080, 3000 ); + $t = new StackTemplate(); + $text = implode( ' ', array_fill( 0, 30, 'iiiiiiii' ) ); + $data = array( + 'brand' => array( 'fonts' => array( 'body' => $body, 'heading' => $heading ) ), + 'blocks' => array( array( 'type' => 'list', 'items' => array( $text, array( 'text' => $text, 'font' => 'heading' ) ) ) ), + ); + $box = \DataMachine\Abilities\Media\BrandTokens::with_overrides( $data['brand'], static fn() => $t->plan_layout( $r, $data ) )[0]; + $width = 952 - $box['gutter']; + $body_height = $r->measure_text_height( $text, 32, 'body', $width, 1.2 ); + $heading_height = $r->measure_text_height( $text, 32, 'heading', $width, 1.2 ); + $this->assertGreaterThan( $body_height, $heading_height ); + $this->assertSame( $body_height + $heading_height, $box['height'] ); + foreach ( array( 'body', 'heading' ) as $font ) { + $this->assertGreaterThanOrEqual( $r->measure_text_width( '2.', 32, $font ) + (int) ceil( 32 * .6 ), $box['gutter'] ); + } + $data['blocks'][0]['items'][1] = $text; + $this->assertLessThan( $box['height'], \DataMachine\Abilities\Media\BrandTokens::with_overrides( $data['brand'], static fn() => $t->plan_layout( $r, $data ) )[0]['height'] ); + $data['blocks'][0]['style'] = 'plain'; + $this->assertSame( 0, $t->plan_layout( $r, $data )[0]['gutter'] ); + } + + public function test_invalid_list_object_reports_original_item_path(): void { + $t = new StackTemplate(); + $box = $t->plan_layout( ( new GDRenderer() )->create_canvas( 1080, 1350 ), array( 'blocks' => array( array( + 'type' => 'list', 'items' => array( 'First', array( 'color' => 'accent' ), array( 'text' => 'Last' ) ), + ) ) ) )[0]; + $this->assertSame( array( 'First', 'Last' ), array_column( $box['items'], 'text' ) ); + $this->assertSame( array( 'blocks.0.items.1' ), $t->get_dropped_fields() ); + $this->assertStringContainsString( 'blocks.0.items.1', $t->get_warnings()['blocks.0.items.1'] ); + } + public function test_missing_image_is_reported(): void { $t = new StackTemplate(); $boxes = $t->plan_layout(