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..895ff121b --- /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, (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'] ) ); + } + } + + 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..1375720e5 --- /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( (string) $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;