Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions inc/Abilities/Media/Templates/StackTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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'] ) );
Expand Down
88 changes: 88 additions & 0 deletions tests/Unit/Abilities/Media/StackTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading