From 728385fb014f6cf4353db64ce4df3ef95a228c62 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:01:44 +0000 Subject: [PATCH 1/2] feat(media): composable stack image template Adds a stack template that renders an ordered list of blocks (text, image, qr, row, list, divider, spacer) with proportional fitting and overflow warnings. QR resolution and text fitting are extracted from PosterTemplate into shared TemplateHelpers. Closes #3661 --- docs/api/stack-image-template.md | 42 +++ .../Media/ImageTemplateAbilities.php | 13 +- .../Media/Templates/PosterTemplate.php | 28 +- .../Media/Templates/StackTemplate.php | 320 ++++++++++++++++++ .../Media/Templates/TemplateHelpers.php | 41 +++ inc/Cli/Commands/ImageCommand.php | 4 + .../HostIntegrationServiceProvider.php | 1 + .../Abilities/Media/StackTemplateTest.php | 313 +++++++++++++++++ tests/poster-template-smoke.php | 4 +- 9 files changed, 738 insertions(+), 28 deletions(-) create mode 100644 docs/api/stack-image-template.md create mode 100644 inc/Abilities/Media/Templates/StackTemplate.php create mode 100644 inc/Abilities/Media/Templates/TemplateHelpers.php create mode 100644 tests/Unit/Abilities/Media/StackTemplateTest.php diff --git a/docs/api/stack-image-template.md b/docs/api/stack-image-template.md new file mode 100644 index 000000000..fd2007b9b --- /dev/null +++ b/docs/api/stack-image-template.md @@ -0,0 +1,42 @@ +# Stack image template + +Use `datamachine/render-image-template` with `template_id: "stack"`, or: + +```sh +wp datamachine image render --template_id=stack --preset=print_pocket_flyer --data='{"background":"#182028","blocks":[{"type":"text","text":"Get involved","font":"heading","size":80,"fit":true},{"type":"qr","url":"https://example.org/join","size":0.45,"gap_before":24},{"type":"list","items":["Scan the code","Choose a project","Invite a friend"],"style":"numbered","size":36,"gap_before":24}]}' +``` + +All platform presets work. Pixel dimensions scale from a 1080px-wide base. +Top-level fields are `background` (hex or brand color token, default +`background_dark`), `padding` (64), `align` (`center` or `left`), and ordered +`blocks`. Colors accept hex or brand color keys; fonts accept `heading`, `body`, +`display`, or `brand`. + +Every block accepts `gap_before` in base pixels: + +| Type | Fields | +| --- | --- | +| `text` | `text`, `font`, `color`, `size` (32), `fit`, `max_size`, `wrap` (true), `line_height` (1.2), `align` | +| `image` | `path` or `attachment_id`, `width` (fraction <=1 or base pixels), `max_height` | +| `qr` | `url`, `size` (content-width fraction, default .5), `card_color` (white), `quiet_modules` (1–4) | +| `row` | `items` (text/image blocks), `gap` (16 base pixels); one line, vertically centered | +| `list` | string `items`, `style` (`numbered`, `bullets`, `plain`), `marker_color`, `font`, `color`, `size`, `line_height` | +| `divider` | `width` (fraction or base pixels), `thickness` (2), `color` | +| `spacer` | `height`, `flex` | + +`fit` text shrinks to fit its width; with wrapping enabled it fits the longest +word, and with `wrap: false` it fits the entire line. `max_size` optionally +sets a larger starting size. If the stack is too tall, fit-enabled text and +lists shrink proportionally, down to 1px, before reporting overflow. Fixed +images, QR codes, spacers and non-fitting text retain their dimensions. No +blocks are removed to resolve overflow. + +Flex spacers share spare height; without flex spacers, spare height is spread +between blocks. QR quiet-zone cards are included in layout bounds and use +nearest-neighbor scaling. A QR provider must answer the existing +`datamachine/image_generation/qr_image_path` filter; it receives the final QR +pixel size and returns a temporary image that the template deletes. + +Ability results include `warnings` for unresolved overflow, unavailable assets +and unsupported blocks. `dropped_fields` identifies skipped asset/block paths; +height overflow itself does not mark blocks as dropped. The CLI prints warnings. diff --git a/inc/Abilities/Media/ImageTemplateAbilities.php b/inc/Abilities/Media/ImageTemplateAbilities.php index 8d550dcb6..68b7b1b4e 100644 --- a/inc/Abilities/Media/ImageTemplateAbilities.php +++ b/inc/Abilities/Media/ImageTemplateAbilities.php @@ -111,7 +111,7 @@ private static function render_image_template_definition(): array { ), 'data' => array( 'type' => 'object', - 'description' => 'Structured data matching the template fields', + 'description' => 'Structured data matching the template fields. stack accepts background (hex/token), padding (1080px base), align (center|left), blocks: ordered text (text/font/color/size/fit/max_size/wrap/line_height/align), image (path or attachment_id/width/max_height), qr (url/size/card_color/quiet_modules), row (text/image items, gap), list (items/style/marker_color/font/color/size), divider (width/thickness/color), spacer (height/flex). Every block accepts gap_before. Widths are fractions or base px; QR size is a fraction.', ), 'brand' => BrandTokens::override_schema(), 'preset' => array( @@ -177,6 +177,11 @@ private static function render_image_template_definition(): array { 'items' => array( 'type' => 'string' ), 'description' => 'Field keys the template omitted to make the layout fit. Empty when every field was drawn.', ), + 'warnings' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => 'Layout and asset warnings, including unresolved stack overflow. No blocks are removed to fit a stack.', + ), 'message' => array( 'type' => 'string' ), ), ), @@ -308,6 +313,10 @@ public static function renderTemplate( array $input ): array|\WP_Error { ? array_values( array_map( 'strval', (array) $template->get_dropped_fields() ) ) : array(); + $warnings = method_exists( $template, 'get_warnings' ) + ? array_values( array_map( 'strval', (array) $template->get_warnings() ) ) + : array(); + // Keep local paths intact for in-process callers; publish temp renders // through the existing repository for remote callers. if ( 'cached_file' !== $output ) { @@ -336,6 +345,7 @@ public static function renderTemplate( array $input ): array|\WP_Error { 'file_urls' => $file_urls, 'template_id' => $template_id, 'dropped_fields' => $dropped_fields, + 'warnings' => $warnings, 'message' => sprintf( 'Generated %d image(s) using template "%s"', count( $file_paths ), $template_id ), ); } @@ -352,6 +362,7 @@ public static function renderTemplate( array $input ): array|\WP_Error { 'success' => true, 'template_id' => $template_id, 'dropped_fields' => $dropped_fields, + 'warnings' => $warnings, ), $cached_result ); diff --git a/inc/Abilities/Media/Templates/PosterTemplate.php b/inc/Abilities/Media/Templates/PosterTemplate.php index 6dedb71f7..0aa94d37e 100644 --- a/inc/Abilities/Media/Templates/PosterTemplate.php +++ b/inc/Abilities/Media/Templates/PosterTemplate.php @@ -1593,18 +1593,7 @@ private static function widest_line_width( GDRenderer $r, string $text, int $siz * @param int $w Column width. */ private static function fit_word_size( GDRenderer $r, string $text, int $size, string $font, int $w ): int { - $words = preg_split( '/\s+/', trim( $text ) ); - while ( $size > 12 ) { - $widest = 0; - foreach ( (array) $words as $word ) { - $widest = max( $widest, $r->measure_text_width( (string) $word, $size, $font ) ); - } - if ( $widest <= $w ) { - break; - } - --$size; - } - return $size; + return TemplateHelpers::fit_text( $r, $text, $size, $font, $w, true, 12 ); } /** @@ -1994,19 +1983,6 @@ public static function plan_layout( int $height, int $margin, int $content_heigh * @return string|null Readable temp PNG path the caller deletes, or null. */ private static function qr_image_path( string $url, int $size ): ?string { - if ( '' === $url ) { - return null; - } - /** - * Resolve a QR code image for a poster. - * - * @param string|null $path Null by default (no QR provider). - * @param string $url URL to encode. - * @param int $size Drawn size in pixels. - * @return string|null Path to a temp PNG the template owns and deletes. - */ - // phpcs:ignore WordPress.NamingConventions.ValidHookName -- Intentional slash-separated hook namespace. - $path = apply_filters( 'datamachine/image_generation/qr_image_path', null, $url, $size ); - return ( is_string( $path ) && '' !== $path && is_readable( $path ) ) ? $path : null; + return TemplateHelpers::qr_image_path( $url, $size ); } } diff --git a/inc/Abilities/Media/Templates/StackTemplate.php b/inc/Abilities/Media/Templates/StackTemplate.php new file mode 100644 index 000000000..02b7e2863 --- /dev/null +++ b/inc/Abilities/Media/Templates/StackTemplate.php @@ -0,0 +1,320 @@ +warnings; + } + public function get_dropped_fields(): array { + return array_values( $this->dropped ); + } + + public function get_fields(): array { + return array( + 'background' => array( + 'label' => 'Background hex or brand color token (default background_dark)', + 'type' => 'string', + 'required' => false, + ), + 'padding' => array( + 'label' => 'Padding in pixels at 1080px base width (default 64)', + 'type' => 'number', + 'required' => false, + ), + 'align' => array( + 'label' => 'Default alignment: center or left', + 'type' => 'string', + '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.', + 'type' => 'array', + 'required' => true, + ), + ); + } + + private function warn( string $key, string $message, bool $skip = true ): void { + $this->warnings[ $key ] = $message; + if ( $skip ) { + $this->dropped[ $key ] = $key; + } + } + + private function color( GDRenderer $r, string $value ): int { + $hex = $this->tokens['colors'][ $value ] ?? $value; + if ( ! preg_match( '/^#?[a-f0-9]{3}(?:[a-f0-9]{3})?$/i', $hex ) ) { + $hex = '#ffffff'; + } + $hex = ltrim( $hex, '#' ); + if ( 3 === strlen( $hex ) ) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + return $r->color_hex( $hex, $hex ); + } + + private function extent( float $value, int $width, float $scale ): int { + return max( 1, min( $width, (int) round( $value <= 1 ? $value * $width : $value * $scale ) ) ); + } + + /** Measure without drawing or requesting QR files. Returned boxes are also used to draw. */ + private function measure( GDRenderer $r, array $b, int $width, float $scale, float $shrink, string $key, string $align ): array { + $type = $b['type'] ?? ''; + $p = array( + 'type' => $type, + 'width' => $width, + 'height' => 0, + 'gap' => max( 0, (int) round( (float) ( $b['gap_before'] ?? 0 ) * $scale ) ), + 'align' => $b['align'] ?? $align, + 'key' => $key, + ); + if ( 'text' === $type || 'list' === $type ) { + $p['font'] = in_array( $b['font'] ?? 'body', array( 'heading', 'body', 'display', 'brand' ), true ) ? ( $b['font'] ?? 'body' ) : 'body'; + $p['size'] = max( 1, (int) round( (float) ( ! empty( $b['fit'] ) ? ( $b['max_size'] ?? $b['size'] ?? 32 ) : ( $b['size'] ?? 32 ) ) * $scale * ( 'list' === $type || ! empty( $b['fit'] ) ? $shrink : 1 ) ) ); + $p['line_height'] = max( 1, (float) ( $b['line_height'] ?? 1.2 ) ); + $p['color'] = $b['color'] ?? 'text_inverse'; + $p['wrap'] = $b['wrap'] ?? true; + $p['text'] = (string) ( $b['text'] ?? '' ); + if ( 'text' === $type ) { + if ( ! empty( $b['fit'] ) ) { + $p['size'] = TemplateHelpers::fit_text( $r, $p['text'], $p['size'], $p['font'], $width, (bool) $p['wrap'] ); + } + $p['height'] = $p['wrap'] ? $r->measure_text_height( $p['text'], $p['size'], $p['font'], $width, $p['line_height'] ) : (int) ceil( $p['size'] * $p['line_height'] ); + } 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 ); + 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'] ); + } + } + } elseif ( 'image' === $type ) { + $path = $b['path'] ?? $b['attachment_id'] ?? ''; + if ( is_numeric( $path ) ) { + $path = get_attached_file( (int) $path ); + } + $info = is_string( $path ) && is_readable( $path ) ? @getimagesize( $path ) : false; + if ( ! $info || ! in_array( $info[2], array( IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_WEBP ), true ) ) { + $p['width'] = 0; + $this->warn( $key, "$key: unreadable or unsupported image skipped." ); + } else { + $p['path'] = $path; + $p['width'] = $this->extent( (float) ( $b['width'] ?? 1 ), $width, $scale ); + $p['height'] = max( 1, (int) round( $p['width'] * $info[1] / $info[0] ) ); + $ratio = min( 1, max( 1, (float) ( $b['max_height'] ?? $p['height'] / $scale ) * $scale ) / $p['height'] ); + $p['width'] = max( 1, (int) round( $p['width'] * $ratio ) ); + $p['height'] = max( 1, (int) round( $p['height'] * $ratio ) ); + } + } elseif ( 'qr' === $type ) { + $p['size'] = $this->extent( min( 1, max( .01, (float) ( $b['size'] ?? .5 ) ) ), $width, $scale ); + $p['pad'] = (int) ceil( $p['size'] * max( 1, min( 4, (int) ( $b['quiet_modules'] ?? 4 ) ) ) / 33 ); + // Keep the quiet-zone card inside the content area even at size=1. + while ( $p['size'] > 1 && $p['size'] + 2 * $p['pad'] > $width ) { + --$p['size']; + $p['pad'] = (int) ceil( $p['size'] * max( 1, min( 4, (int) ( $b['quiet_modules'] ?? 4 ) ) ) / 33 ); + } + $p['width'] = $p['size'] + 2 * $p['pad']; + $p['height'] = $p['width']; + $p['url'] = (string) ( $b['url'] ?? '' ); + $p['color'] = $b['card_color'] ?? '#ffffff'; + } elseif ( 'row' === $type ) { + $p['children'] = array(); + $p['row_gap'] = max( 0, (int) round( (float) ( $b['gap'] ?? 16 ) * $scale ) ); + $used = 0; + foreach ( (array) ( $b['items'] ?? array() ) as $i => $item ) { + if ( ! is_array( $item ) || ! in_array( $item['type'] ?? '', array( 'text', 'image' ), true ) ) { + $this->warn( "$key.items.$i", "$key.items.$i: row only supports text and image." ); + continue; + } + $item['wrap'] = false; + $child = $this->measure( $r, $item, $width, $scale, $shrink, "$key.items.$i", 'left' ); + if ( 'text' === $child['type'] ) { + $child['width'] = $r->measure_text_width( $child['text'], $child['size'], $child['font'] ); + } + $child['x'] = $used; + $used += $child['width'] + $p['row_gap']; + $p['height'] = max( $p['height'], $child['height'] ); + $p['children'][] = $child; + } + $p['width'] = max( 0, $used - $p['row_gap'] ); + if ( $p['width'] > $width ) { + $this->warn( $key, "$key: row exceeds content width.", false ); + } + } elseif ( 'divider' === $type ) { + $p['width'] = $this->extent( (float) ( $b['width'] ?? 1 ), $width, $scale ); + $p['height'] = max( 1, (int) round( (float) ( $b['thickness'] ?? 2 ) * $scale ) ); + $p['color'] = $b['color'] ?? 'accent'; + } elseif ( 'spacer' === $type ) { + $p['height'] = max( 0, (int) round( (float) ( $b['height'] ?? 0 ) * $scale ) ); + $p['flex'] = ! empty( $b['flex'] ); + } else { + $this->warn( $key, "$key: unknown block type skipped." ); + } + return $p; + } + + /** Public measured layout for inspection; no assets are generated here. */ + public function plan_layout( GDRenderer $r, array $data ): array { + $this->warnings = array(); + $this->dropped = array(); + $this->tokens = BrandTokens::get( 'stack', $data ); + foreach ( array( 'heading', 'body', 'display', 'brand' ) as $font ) { + $r->register_font( $font, (string) ( $this->tokens['fonts'][ $font ] ?? $this->tokens['fonts']['body'] ?? '' ) ); + } + $scale = $r->get_width() / 1080; + $padding = max( 0, min( (int) round( (float) ( $data['padding'] ?? 64 ) * $scale ), (int) floor( min( $r->get_width(), $r->get_height() ) / 2 ) - 1 ) ); + $width = max( 1, $r->get_width() - 2 * $padding ); + $available = $r->get_height() - 2 * $padding; + $shrink = 1; + for ( $pass = 0; $pass < 80; ++$pass ) { + // Rebuild diagnostics too: a row may stop overflowing after fitting. + $this->warnings = array(); + $this->dropped = array(); + $boxes = array(); + $total = 0; + foreach ( (array) ( $data['blocks'] ?? array() ) as $i => $block ) { + if ( ! is_array( $block ) ) { + $this->warn( "blocks.$i", "blocks.$i: invalid block skipped." ); + continue; + } + $box = $this->measure( $r, $block, $width, $scale, $shrink, "blocks.$i", $data['align'] ?? 'center' ); + $total += $box['gap'] + $box['height']; + $boxes[] = $box; + } + if ( $total <= $available || $shrink <= .001 ) { + break; + } + $shrink *= max( .1, min( .9, $available / max( 1, $total ) ) ); + } + if ( $total > $available ) { + $this->warn( 'overflow', 'Stack content exceeds canvas height after fitting; no blocks were dropped.', false ); + } + $flex = count( array_filter( $boxes, static fn( $b ) => ! empty( $b['flex'] ) ) ); + $spare = max( 0, $available - $total ); + $between = ! $flex && count( $boxes ) > 1 ? $spare / ( count( $boxes ) - 1 ) : 0; + $y = $padding; + foreach ( $boxes as &$box ) { + $y += $box['gap']; + $box['x'] = $padding + ( 'left' === $box['align'] ? 0 : (int) round( ( $width - $box['width'] ) / 2 ) ); + $box['y'] = (int) round( $y ); + if ( ! empty( $box['flex'] ) ) { + $box['height'] += (int) floor( $spare / $flex ); + } + $y += $box['height'] + $between; + } + unset( $box ); + return $boxes; + } + + private function draw( GDRenderer $r, array $b, int $x, int $y ): void { + if ( 'text' === $b['type'] ) { + $color = $this->color( $r, $b['color'] ); + if ( $b['wrap'] ) { + $r->draw_text_wrapped( $b['text'], $b['size'], $x, $y, $color, $b['font'], $b['width'], $b['line_height'], $b['align'] ); + } else { + $offset = 'left' === $b['align'] ? 0 : (int) round( ( $b['width'] - $r->measure_text_width( $b['text'], $b['size'], $b['font'] ) ) / 2 ); + $r->draw_text( $b['text'], $b['size'], $x + $offset, $y + $b['size'], $color, $b['font'] ); + } + } elseif ( 'image' === $b['type'] && isset( $b['path'] ) ) { + $r->overlay_image( $b['path'], $x, $y, $b['width'], $b['height'] ); + } elseif ( 'qr' === $b['type'] ) { + $path = TemplateHelpers::qr_image_path( $b['url'], $b['size'] ); + if ( ! $path ) { + $this->warn( $b['key'], $b['key'] . ': QR provider unavailable.' ); + return; + } + try { + $r->filled_rect( $x, $y, $x + $b['width'] - 1, $y + $b['height'] - 1, $this->color( $r, $b['color'] ) ); + $r->overlay_image( $path, $x + $b['pad'], $y + $b['pad'], $b['size'], $b['size'], 100, false ); + } finally { + wp_delete_file( $path ); + } + } elseif ( 'row' === $b['type'] ) { + foreach ( $b['children'] as $child ) { + $this->draw( $r, $child, $x + $child['x'], $y + (int) round( ( $b['height'] - $child['height'] ) / 2 ) ); + } + } elseif ( 'list' === $b['type'] ) { + 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'] ); + } + $y = $r->draw_text_wrapped( (string) $item, $b['size'], $x + $b['gutter'], $y, $this->color( $r, $b['color'] ), $b['font'], max( 1, $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'] ) ); + } + } + + public function render( array $data, GDRenderer $renderer, array $options = array() ): array { + $renderer->create_canvas( (string) ( $options['preset'] ?? $this->get_default_preset() ) ); + if ( ! $renderer->get_image() ) { + return array(); + } + $boxes = $this->plan_layout( $renderer, $data ); + $renderer->fill( $this->color( $renderer, $data['background'] ?? 'background_dark' ) ); + foreach ( $boxes as $box ) { + $this->draw( $renderer, $box, $box['x'], $box['y'] ); + } + $format = (string) ( $options['format'] ?? 'png' ); + $context = (array) ( $options['context'] ?? array() ); + $filename = (string) ( $options['filename'] ?? 'stack.' . ( 'jpeg' === $format ? 'jpg' : 'png' ) ); + $path = ! empty( $context['pipeline_id'] ) && ! empty( $context['flow_id'] ) + ? $renderer->save_to_repository( $filename, $context, $format ) + : $renderer->save_temp( $format ); + return $path ? array( $path ) : array(); + } +} diff --git a/inc/Abilities/Media/Templates/TemplateHelpers.php b/inc/Abilities/Media/Templates/TemplateHelpers.php new file mode 100644 index 000000000..dc5aa9219 --- /dev/null +++ b/inc/Abilities/Media/Templates/TemplateHelpers.php @@ -0,0 +1,41 @@ + $minimum ) { + $widest = 0; + foreach ( (array) $parts as $part ) { + $widest = max( $widest, $r->measure_text_width( $part, $size, $font ) ); + } + if ( $widest <= $width ) { + break; + } + --$size; + } + return $size; + } +} diff --git a/inc/Cli/Commands/ImageCommand.php b/inc/Cli/Commands/ImageCommand.php index 78ea39df6..482f5e216 100644 --- a/inc/Cli/Commands/ImageCommand.php +++ b/inc/Cli/Commands/ImageCommand.php @@ -261,6 +261,10 @@ public function render( array $args, array $assoc_args ): void { WP_CLI::success( $result['message'] ?? 'Template rendered.' ); + foreach ( $result['warnings'] ?? array() as $warning ) { + WP_CLI::warning( $warning ); + } + $dropped_fields = $result['dropped_fields'] ?? array(); if ( ! empty( $dropped_fields ) ) { WP_CLI::warning( sprintf( 'Dropped fields to fit the layout: %s', implode( ', ', $dropped_fields ) ) ); diff --git a/inc/Core/Bootstrap/HostIntegrationServiceProvider.php b/inc/Core/Bootstrap/HostIntegrationServiceProvider.php index 3e75a71f8..174c99166 100644 --- a/inc/Core/Bootstrap/HostIntegrationServiceProvider.php +++ b/inc/Core/Bootstrap/HostIntegrationServiceProvider.php @@ -149,6 +149,7 @@ public static function pending_action_permission(): bool { public static function register_image_templates( array $templates ): array { $templates['flow_diagram'] ??= \DataMachine\Abilities\Media\Templates\FlowDiagramTemplate::class; $templates['poster'] ??= \DataMachine\Abilities\Media\Templates\PosterTemplate::class; + $templates['stack'] ??= \DataMachine\Abilities\Media\Templates\StackTemplate::class; return $templates; } diff --git a/tests/Unit/Abilities/Media/StackTemplateTest.php b/tests/Unit/Abilities/Media/StackTemplateTest.php new file mode 100644 index 000000000..49367f514 --- /dev/null +++ b/tests/Unit/Abilities/Media/StackTemplateTest.php @@ -0,0 +1,313 @@ +markTestSkipped( 'GD required.' ); } + $this->provider = static function ( $path, $url, $size ) { + return isset( $GLOBALS['dm_stack_qr'] ) ? ( $GLOBALS['dm_stack_qr'] )( $path, $url, $size ) : $path; + }; + if ( function_exists( 'add_filter' ) ) { + add_filter( 'datamachine/image_generation/qr_image_path', $this->provider, 10, 3 ); + } + } + + protected function tearDown(): void { + unset( $GLOBALS['dm_stack_qr'] ); + if ( function_exists( 'remove_filter' ) ) { + remove_filter( 'datamachine/image_generation/qr_image_path', $this->provider, 10 ); + } + foreach ( $this->files as $path ) { + if ( is_file( $path ) ) { + unlink( $path ); } + } + } + + private function image(): string { + $path = tempnam( sys_get_temp_dir(), 'stack-test-' ); + $image = imagecreatetruecolor( 120, 40 ); + imagefill( $image, 0, 0, imagecolorallocate( $image, 180, 20, 30 ) ); + imagepng( $image, $path ); + imagedestroy( $image ); + $this->files[] = $path; + return $path; + } + + public function test_core_registration_and_field_definitions(): void { + $templates = \DataMachine\Core\Bootstrap\HostIntegrationServiceProvider::register_image_templates( array() ); + $this->assertSame( StackTemplate::class, $templates['stack'] ); + $override = \DataMachine\Core\Bootstrap\HostIntegrationServiceProvider::register_image_templates( array( 'stack' => 'CustomTemplate' ) ); + $this->assertSame( 'CustomTemplate', $override['stack'] ); + $this->assertTrue( ( new StackTemplate() )->get_fields()['blocks']['required'] ); + } + + public function test_order_and_fit_width(): void { + $r = ( new GDRenderer() )->create_canvas( 1080, 1350 ); + $t = new StackTemplate(); + $boxes = $t->plan_layout( + $r, + array( + 'blocks' => array( + array( + 'type' => 'text', + 'text' => 'A headline far too wide at its original size', + 'size' => 250, + 'fit' => true, + 'wrap' => false, + ), + array( + 'type' => 'divider', + 'gap_before' => 20, + ), + array( + 'type' => 'text', + 'text' => 'Footer', + ), + ), + ) + ); + $this->assertLessThan( $boxes[1]['y'], $boxes[0]['y'] ); + $this->assertLessThan( $boxes[2]['y'], $boxes[1]['y'] ); + $this->assertLessThanOrEqual( $boxes[0]['width'], $r->measure_text_width( $boxes[0]['text'], $boxes[0]['size'], $boxes[0]['font'] ) ); + $this->assertSame( array(), $t->get_warnings() ); + } + + public function test_overflow_shrinks_text_and_lists_before_warning(): void { + $r = ( new GDRenderer() )->create_canvas( 1080, 600 ); + $t = new StackTemplate(); + $data = array( + 'blocks' => array( + array( + 'type' => 'text', + 'text' => 'Headline', + 'size' => 180, + 'fit' => true, + ), + array( + 'type' => 'list', + 'items' => array_fill( 0, 8, 'Read this step' ), + 'size' => 90, + ), + ), + ); + $boxes = $t->plan_layout( $r, $data ); + $this->assertLessThan( 180, $boxes[0]['size'] ); + $this->assertLessThan( 90, $boxes[1]['size'] ); + $this->assertSame( array(), $t->get_warnings() ); + $this->assertLessThanOrEqual( 536, $boxes[1]['y'] + $boxes[1]['height'] ); + $data['blocks'][] = array( + 'type' => 'spacer', + 'height' => 1000, + ); + $boxes = $t->plan_layout( $r, $data ); + $this->assertCount( 3, $boxes ); + $this->assertSame( 1, $boxes[0]['size'] ); + $this->assertSame( 1, $boxes[1]['size'] ); + $this->assertArrayHasKey( 'overflow', $t->get_warnings() ); + $this->assertSame( array(), $t->get_dropped_fields() ); + } + + public function test_missing_image_is_reported(): void { + $t = new StackTemplate(); + $boxes = $t->plan_layout( + ( new GDRenderer() )->create_canvas( 1080, 1350 ), + array( + 'blocks' => array( + array( + 'type' => 'image', + 'path' => '/missing/asset.png', + ), + ), + ) + ); + $this->assertSame( 0, $boxes[0]['height'] ); + $this->assertSame( array( 'blocks.0' ), $t->get_dropped_fields() ); + $this->assertStringContainsString( 'unreadable', $t->get_warnings()['blocks.0'] ); + } + + public function test_flex_spacer_and_row_centering(): void { + $t = new StackTemplate(); + $r = ( new GDRenderer() )->create_canvas( 1080, 1350 ); + $boxes = $t->plan_layout( + $r, + array( + 'align' => 'left', + 'blocks' => array( + array( + 'type' => 'row', + 'items' => array( + array( + 'type' => 'text', + 'text' => 'Presented by', + ), + array( + 'type' => 'image', + 'path' => $this->image(), + 'width' => 120, + ), + ), + ), + array( + 'type' => 'spacer', + 'flex' => true, + ), + array( 'type' => 'divider' ), + ), + ) + ); + $this->assertGreaterThan( 1000, $boxes[1]['height'] ); + $this->assertSame( 64, $boxes[0]['x'] ); + $this->assertSame( 1286, $boxes[2]['y'] + $boxes[2]['height'] ); + $this->assertCount( 2, $boxes[0]['children'] ); + } + + public function test_all_presets_render_and_missing_qr_warns(): void { + foreach ( PlatformPresets::names() as $preset ) { + $t = new StackTemplate(); + $r = new GDRenderer(); + $paths = $t->render( array( 'background' => '#123', 'blocks' => array( array( 'type' => 'text', 'text' => 'Hello', 'fit' => true ) ) ), $r, array( 'preset' => $preset ) ); + $this->files = array_merge( $this->files, $paths ); + $this->assertCount( 1, $paths, $preset ); + $pixel = imagecolorat( $r->get_image(), 0, 0 ); + $this->assertSame( 0x112233, $pixel & 0xffffff ); + } + $t = new StackTemplate(); + $GLOBALS['dm_stack_qr'] = static fn() => null; + $paths = $t->render( array( 'blocks' => array( array( 'type' => 'qr', 'url' => 'https://example.org', 'size' => 1 ) ) ), new GDRenderer() ); + $this->files = array_merge( $this->files, $paths ); + $this->assertSame( array( 'blocks.0' ), $t->get_dropped_fields() ); + $this->assertStringContainsString( 'QR provider unavailable', $t->get_warnings()['blocks.0'] ); + } + + public function test_qr_card_stays_inside_content_width(): void { + $t = new StackTemplate(); + $boxes = $t->plan_layout( ( new GDRenderer() )->create_canvas( 1080, 1350 ), array( 'blocks' => array( array( 'type' => 'qr', 'size' => 1, 'url' => 'https://example.org' ) ) ) ); + $this->assertLessThanOrEqual( 952, $boxes[0]['width'] ); + $this->assertGreaterThanOrEqual( 64, $boxes[0]['x'] ); + } + + public function test_representative_eight_block_render_on_print_and_feed(): void { + $requested = array(); + $GLOBALS['dm_stack_qr'] = function ( $path, $url, $size ) use ( &$requested ) { + $requested[] = $size; + return $this->image(); + }; + $data = array( + 'blocks' => array( + array( + 'type' => 'text', + 'text' => 'Join the community', + 'font' => 'heading', + 'size' => 64, + 'fit' => true, + ), + array( + 'type' => 'text', + 'text' => 'Scan to get started', + 'size' => 30, + ), + array( + 'type' => 'qr', + 'url' => 'https://example.org/join', + 'size' => .4, + ), + array( + 'type' => 'image', + 'path' => $this->image(), + 'width' => .6, + 'max_height' => 120, + ), + array( + 'type' => 'text', + 'text' => 'A shared project', + 'size' => 48, + 'fit' => true, + ), + array( + 'type' => 'row', + 'items' => array( + array( + 'type' => 'text', + 'text' => 'Presented by', + ), + array( + 'type' => 'image', + 'path' => $this->image(), + 'width' => 150, + ), + ), + ), + array( + 'type' => 'list', + 'items' => array( 'Scan the code', 'Choose a project', 'Share with friends' ), + 'size' => 34, + ), + array( + 'type' => 'text', + 'text' => 'Every Saturday at noon', + 'size' => 28, + ), + ), + ); + foreach ( array( 'print_pocket_flyer', 'instagram_feed_portrait' ) as $preset ) { + $t = new StackTemplate(); + $r = ( new GDRenderer() )->create_canvas( $preset ); + $boxes = $t->plan_layout( $r, $data ); + $paths = $t->render( $data, $r, array( 'preset' => $preset ) ); + $this->files = array_merge( $this->files, $paths ); + $this->assertCount( 1, $paths ); + $this->assertSame( $boxes[2]['size'], end( $requested ) ); + $this->assertSame( array(), $t->get_warnings() ); + $info = getimagesize( $paths[0] ); + $this->assertSame( $r->get_width(), $info[0] ); + $this->assertSame( $r->get_height(), $info[1] ); + foreach ( $boxes as $i => $box ) { + if ( $i ) { + $this->assertGreaterThanOrEqual( $boxes[ $i - 1 ]['y'] + $boxes[ $i - 1 ]['height'], $box['y'] ); } + } + } + } + } +} diff --git a/tests/poster-template-smoke.php b/tests/poster-template-smoke.php index 1d1051358..840508469 100644 --- a/tests/poster-template-smoke.php +++ b/tests/poster-template-smoke.php @@ -80,7 +80,8 @@ $assert( 'core ships the print_tabloid preset', str_contains( $presets_src, "'print_tabloid'" ) ); $assert( 'core ships the print_tabloid_300 preset', str_contains( $presets_src, "'print_tabloid_300'" ) ); $assert( 'core ships the print_pocket_flyer preset', str_contains( $presets_src, "'print_pocket_flyer'" ) ); -$assert( 'QR codes resolve through a filter', str_contains( $tmpl_source, "'datamachine/image_generation/qr_image_path'" ) ); +$helpers_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/Templates/TemplateHelpers.php' ); +$assert( 'QR codes resolve through a shared filter', str_contains( $helpers_source, "'datamachine/image_generation/qr_image_path'" ) && str_contains( $tmpl_source, 'TemplateHelpers::qr_image_path' ) ); $assert( 'template names no domain concept or vendor', ! preg_match( '/event|venue|rsvp|extrachill|extra chill|ticket/i', $tmpl_source ) @@ -134,6 +135,7 @@ function get_template_directory() { require_once $plugin_root . '/inc/Abilities/Media/TemplateInterface.php'; require_once $plugin_root . '/inc/Abilities/Media/BrandTokens.php'; require_once $plugin_root . '/inc/Abilities/Media/GDRenderer.php'; +require_once $plugin_root . '/inc/Abilities/Media/Templates/TemplateHelpers.php'; require_once $plugin_root . '/inc/Abilities/Media/Templates/PosterTemplate.php'; use DataMachine\Abilities\Media\GDRenderer; From 4790af3c3879a28e8f5c9d33e560cfd873ab9bc3 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:10:05 +0000 Subject: [PATCH 2/2] fix(media): satisfy PHPStan types and drop error silencer in stack template --- inc/Abilities/Media/Templates/StackTemplate.php | 4 ++-- inc/Abilities/Media/Templates/TemplateHelpers.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/inc/Abilities/Media/Templates/StackTemplate.php b/inc/Abilities/Media/Templates/StackTemplate.php index 02b7e2863..895ff121b 100644 --- a/inc/Abilities/Media/Templates/StackTemplate.php +++ b/inc/Abilities/Media/Templates/StackTemplate.php @@ -146,7 +146,7 @@ private function measure( GDRenderer $r, array $b, int $width, float $scale, flo if ( is_numeric( $path ) ) { $path = get_attached_file( (int) $path ); } - $info = is_string( $path ) && is_readable( $path ) ? @getimagesize( $path ) : false; + $info = is_string( $path ) && is_readable( $path ) ? getimagesize( $path ) : false; if ( ! $info || ! in_array( $info[2], array( IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_WEBP ), true ) ) { $p['width'] = 0; $this->warn( $key, "$key: unreadable or unsupported image skipped." ); @@ -292,7 +292,7 @@ private function draw( GDRenderer $r, array $b, int $x, int $y ): void { if ( '' !== $marker ) { $r->draw_text( $marker, $b['size'], $x, $y + $b['size'], $this->color( $r, $b['marker_color'] ), $b['font'] ); } - $y = $r->draw_text_wrapped( (string) $item, $b['size'], $x + $b['gutter'], $y, $this->color( $r, $b['color'] ), $b['font'], max( 1, $b['width'] - $b['gutter'] ), $b['line_height'] ); + $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'] ); } } 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/inc/Abilities/Media/Templates/TemplateHelpers.php b/inc/Abilities/Media/Templates/TemplateHelpers.php index dc5aa9219..1375720e5 100644 --- a/inc/Abilities/Media/Templates/TemplateHelpers.php +++ b/inc/Abilities/Media/Templates/TemplateHelpers.php @@ -29,7 +29,7 @@ public static function fit_text( GDRenderer $r, string $text, int $size, string while ( $size > $minimum ) { $widest = 0; foreach ( (array) $parts as $part ) { - $widest = max( $widest, $r->measure_text_width( $part, $size, $font ) ); + $widest = max( $widest, $r->measure_text_width( (string) $part, $size, $font ) ); } if ( $widest <= $width ) { break;