diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c7ba6c022..963135ff7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Data Machine will be documented in this file. +## [Unreleased] + +### Changed +- `flow_diagram` template now renders onto a fixed target canvas and centers the diagram within it, defaulting to a 16:9 card (twitter_card, 1200x675) that works as a blog featured image or social share. The diagram grid scales down uniformly to fit the canvas, and callers can override via `preset` or explicit `width`/`height`. Very wide flows still grow the canvas rather than clip. + ## [0.163.1] - 2026-07-13 ### Fixed diff --git a/inc/Abilities/Media/Templates/FlowDiagramTemplate.php b/inc/Abilities/Media/Templates/FlowDiagramTemplate.php index 836213835..abf7442de 100644 --- a/inc/Abilities/Media/Templates/FlowDiagramTemplate.php +++ b/inc/Abilities/Media/Templates/FlowDiagramTemplate.php @@ -37,6 +37,7 @@ namespace DataMachine\Abilities\Media\Templates; use DataMachine\Abilities\Media\GDRenderer; +use DataMachine\Abilities\Media\PlatformPresets; use DataMachine\Abilities\Media\TemplateInterface; if ( ! defined( 'ABSPATH' ) ) { @@ -96,8 +97,14 @@ public function get_fields(): array { ); } + /** + * Default canvas: twitter_card is 1200x675, a true 16:9 card that works well + * as a blog featured image / social share. The diagram is centered within it. + */ + private const DEFAULT_PRESET = 'twitter_card'; + public function get_default_preset(): string { - return 'open_graph'; + return self::DEFAULT_PRESET; } /** @@ -131,17 +138,46 @@ public function render( array $data, GDRenderer $renderer, array $options = arra break; } } - $gap = $has_edge_labels ? self::GAP + 90 : self::GAP; - // Compute canvas from the node grid (ignore preset; diagrams size to content). - if ( 'vertical' === $direction ) { - $width = self::MARGIN * 2 + self::NODE_WIDTH; - $height = self::MARGIN * 2 + $title_band + $count * self::NODE_HEIGHT + ( $count - 1 ) * $gap; - } else { - $width = self::MARGIN * 2 + $count * self::NODE_WIDTH + ( $count - 1 ) * $gap; - $height = self::MARGIN * 2 + $title_band + self::NODE_HEIGHT; + // Resolve the target canvas first (default is a 16:9 card). The diagram + // grid is then scaled to fit inside it and centered, so a bare render + // always produces a clean, correctly-proportioned image. + $canvas = $this->resolve_canvas( $options ); + $width = $canvas['width']; + $height = $canvas['height']; + + // Base (unscaled) grid metrics. + $node_w = self::NODE_WIDTH; + $node_h = self::NODE_HEIGHT; + $gap = $has_edge_labels ? self::GAP + 90 : self::GAP; + $margin = self::MARGIN; + + $content_w = $this->grid_width( $direction, $count, $node_w, $gap, $margin ); + $content_h = $this->grid_height( $direction, $count, $node_h, $gap, $margin, $title_band ); + + // Scale the whole grid down uniformly if it overflows the canvas in + // either axis. Never scale up past the base size (keeps small diagrams + // from ballooning on a large canvas). + $scale = min( 1.0, $width / max( 1, $content_w ), $height / max( 1, $content_h ) ); + if ( $scale < 1.0 ) { + // floor() (not round()) guarantees the scaled grid never exceeds the + // target canvas by a rounding pixel, so preset dimensions stay exact. + $node_w = (int) floor( $node_w * $scale ); + $node_h = (int) floor( $node_h * $scale ); + $gap = (int) floor( $gap * $scale ); + $margin = (int) floor( $margin * $scale ); + $title_band = (int) floor( $title_band * $scale ); + $content_w = $this->grid_width( $direction, $count, $node_w, $gap, $margin ); + $content_h = $this->grid_height( $direction, $count, $node_h, $gap, $margin, $title_band ); } + // If content is still larger than the canvas (single very wide node), + // grow the canvas so nothing clips. + $width = max( $width, $content_w ); + $height = max( $height, $content_h ); + $off_x = intdiv( $width - $content_w, 2 ); + $off_y = intdiv( $height - $content_h, 2 ); + $renderer->create_canvas( $width, $height ); // Fonts: theme fonts if present, DejaVu fallback otherwise (handled by register_font). @@ -159,20 +195,22 @@ public function render( array $data, GDRenderer $renderer, array $options = arra $renderer->fill( $bg ); if ( '' !== $title ) { - $renderer->filled_rect( 0, 0, $width, $title_band, $surface ); - $renderer->draw_text_centered( $title, 30, self::MARGIN + 6, $title_c, 'title' ); + $title_fs = max( 16, (int) round( 30 * ( $title_band / self::TITLE_BAND ) ) ); + $renderer->filled_rect( 0, $off_y, $width, $off_y + $title_band, $surface ); + $renderer->draw_text_centered( $title, $title_fs, $off_y + $margin + 6, $title_c, 'title' ); } // Position nodes on a single row/column and index by id for edge routing. + // Offsets center the content grid within the (possibly larger) canvas. $positions = array(); - $top0 = self::MARGIN + $title_band; + $top0 = $off_y + $margin + $title_band; foreach ( $nodes as $i => $node ) { if ( 'vertical' === $direction ) { - $x = self::MARGIN; - $y = $top0 + $i * ( self::NODE_HEIGHT + $gap ); + $x = $off_x + $margin; + $y = $top0 + $i * ( $node_h + $gap ); } else { - $x = self::MARGIN + $i * ( self::NODE_WIDTH + $gap ); + $x = $off_x + $margin + $i * ( $node_w + $gap ); $y = $top0; } @@ -181,10 +219,10 @@ public function render( array $data, GDRenderer $renderer, array $options = arra $positions[ $id ] = array( 'x' => $x, 'y' => $y, - 'cx' => $x + intdiv( self::NODE_WIDTH, 2 ), - 'cy' => $y + intdiv( self::NODE_HEIGHT, 2 ), - 'right' => $x + self::NODE_WIDTH, - 'bottom' => $y + self::NODE_HEIGHT, + 'cx' => $x + intdiv( $node_w, 2 ), + 'cy' => $y + intdiv( $node_h, 2 ), + 'right' => $x + $node_w, + 'bottom' => $y + $node_h, ); } @@ -240,19 +278,21 @@ public function render( array $data, GDRenderer $renderer, array $options = arra $label = isset( $node['label'] ) ? (string) $node['label'] : ''; $label = str_replace( '\n', "\n", $label ); + $radius = max( 6, (int) round( 16 * ( $node_h / self::NODE_HEIGHT ) ) ); + switch ( $shape ) { case 'diamond': - $renderer->draw_diamond( $pos['cx'], $pos['cy'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, true ); + $renderer->draw_diamond( $pos['cx'], $pos['cy'], $node_w, $node_h, $fill, true ); break; case 'oval': - $renderer->draw_oval( $pos['cx'], $pos['cy'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, true ); + $renderer->draw_oval( $pos['cx'], $pos['cy'], $node_w, $node_h, $fill, true ); break; default: - $renderer->draw_rounded_rect( $pos['x'], $pos['y'], self::NODE_WIDTH, self::NODE_HEIGHT, $fill, 16 ); + $renderer->draw_rounded_rect( $pos['x'], $pos['y'], $node_w, $node_h, $fill, $radius ); break; } - $this->draw_node_label( $renderer, $label, $pos, $text_c ); + $this->draw_node_label( $renderer, $label, $pos, $text_c, $node_w ); } $path = $renderer->save_temp( $format ); @@ -260,6 +300,57 @@ public function render( array $data, GDRenderer $renderer, array $options = arra return $path ? array( $path ) : array(); } + /** + * Resolve the target output canvas size. + * + * Precedence: + * 1. Explicit options[width] + options[height] + * 2. options[preset] resolved via PlatformPresets + * 3. The template default preset (16:9 twitter_card, 1200x675) + * + * @param array $options Render options (preset, width, height). + * @return array{width: int, height: int} + */ + private function resolve_canvas( array $options ): array { + if ( ! empty( $options['width'] ) && ! empty( $options['height'] ) ) { + return array( + 'width' => (int) $options['width'], + 'height' => (int) $options['height'], + ); + } + + $preset = ! empty( $options['preset'] ) ? (string) $options['preset'] : self::DEFAULT_PRESET; + $dims = PlatformPresets::dimensions( $preset ); + if ( ! $dims ) { + $dims = PlatformPresets::dimensions( self::DEFAULT_PRESET ); + } + + return array( + 'width' => (int) ( $dims['width'] ?? 1200 ), + 'height' => (int) ( $dims['height'] ?? 675 ), + ); + } + + /** + * Total grid width for the given metrics. + */ + private function grid_width( string $direction, int $count, int $node_w, int $gap, int $margin ): int { + if ( 'vertical' === $direction ) { + return $margin * 2 + $node_w; + } + return $margin * 2 + $count * $node_w + ( $count - 1 ) * $gap; + } + + /** + * Total grid height for the given metrics. + */ + private function grid_height( string $direction, int $count, int $node_h, int $gap, int $margin, int $title_band ): int { + if ( 'vertical' === $direction ) { + return $margin * 2 + $title_band + $count * $node_h + ( $count - 1 ) * $gap; + } + return $margin * 2 + $title_band + $node_h; + } + /** * Draw a node's (possibly multi-line, possibly wrapped) label centered in the node box. * @@ -267,18 +358,19 @@ public function render( array $data, GDRenderer $renderer, array $options = arra * @param string $label Label text (may contain "\n"). * @param array $pos Node position record. * @param int $color Text color id. + * @param int $node_w Node width (scaled). */ - private function draw_node_label( GDRenderer $renderer, string $label, array $pos, int $color ): void { + private function draw_node_label( GDRenderer $renderer, string $label, array $pos, int $color, int $node_w ): void { if ( '' === $label ) { return; } - $inner = self::NODE_WIDTH - 40; + $inner = $node_w - 40; - // Auto-shrink the font until the longest single token fits the node - // width. Handles long unbroken strings (e.g. "ai-provider-for-claude-code") - // that word-wrapping alone can't break. - $font_size = 20; + // Base font scales with the node, then auto-shrinks until the longest + // single token fits the inner width. Handles long unbroken strings + // (e.g. "ai-provider-for-claude-code") that word-wrapping can't break. + $font_size = max( 11, (int) round( 20 * ( $node_w / self::NODE_WIDTH ) ) ); $longest = ''; foreach ( preg_split( '/\s+/', str_replace( "\n", ' ', $label ) ) as $token ) { if ( strlen( $token ) > strlen( $longest ) ) { diff --git a/tests/flow-diagram-template-smoke.php b/tests/flow-diagram-template-smoke.php index 75434e3f4..1ed266179 100644 --- a/tests/flow-diagram-template-smoke.php +++ b/tests/flow-diagram-template-smoke.php @@ -36,9 +36,9 @@ // Source-string assertions (hold in every backend, incl. real WordPress). // ------------------------------------------------------------------------- -$bootstrap = (string) file_get_contents( $plugin_root . '/inc/bootstrap.php' ); -$gd_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/GDRenderer.php' ); -$tmpl_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/Templates/FlowDiagramTemplate.php' ); +$bootstrap = (string) file_get_contents( $plugin_root . '/inc/bootstrap.php' ); +$gd_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/GDRenderer.php' ); +$tmpl_source = (string) file_get_contents( $plugin_root . '/inc/Abilities/Media/Templates/FlowDiagramTemplate.php' ); $assert( 'bootstrap registers flow_diagram on the core template filter', @@ -135,9 +135,19 @@ function get_template_directory() { $info = getimagesize( $path ); $assert( 'output is a valid PNG', is_array( $info ) && $info['mime'] === 'image/png' ); $assert( 'output has sane dimensions', is_array( $info ) && $info[0] > 100 && $info[1] > 100 ); + // Default canvas is a 16:9 twitter_card (1200x675). + $assert( 'default render is 16:9 (1200x675)', is_array( $info ) && $info[0] === 1200 && $info[1] === 675 ); wp_delete_file( $path ); } +// Explicit preset override is honored. +$og = $template->render( $spec, new GDRenderer(), array( 'preset' => 'open_graph' ) ); +if ( ! empty( $og[0] ) && file_exists( $og[0] ) ) { + $oginfo = getimagesize( $og[0] ); + $assert( 'preset override honored (open_graph 1200x630)', is_array( $oginfo ) && $oginfo[0] === 1200 && $oginfo[1] === 630 ); + wp_delete_file( $og[0] ); +} + // Empty nodes must fail gracefully (no fatal, empty result). $empty = $template->render( array( 'nodes' => array() ), new GDRenderer() ); $assert( 'empty nodes returns empty array without fatal', is_array( $empty ) && count( $empty ) === 0 );