From b3f9f153990e07ee744dd3e5f8cf4e1ac28a5620 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 23:21:28 +0000 Subject: [PATCH] fix(media): flow_diagram pads to 16:9 at native size, never downscales The template was shrinking the diagram to fit a fixed 1200x675 canvas, costing resolution and making nodes smaller than needed. It now renders the diagram at full native size and pads the canvas out to a 16:9 aspect ratio around it (a 4-node flow becomes ~1700x957). A named preset applies its aspect ratio at native scale; explicit width/height grows to content instead of clipping. No content is ever downscaled. Smoke test updated to assert aspect ratio + native-size behavior. --- .../Media/Templates/FlowDiagramTemplate.php | 110 +++++++++++------- tests/flow-diagram-template-smoke.php | 24 +++- 2 files changed, 85 insertions(+), 49 deletions(-) diff --git a/inc/Abilities/Media/Templates/FlowDiagramTemplate.php b/inc/Abilities/Media/Templates/FlowDiagramTemplate.php index abf7442de..7b8d5992b 100644 --- a/inc/Abilities/Media/Templates/FlowDiagramTemplate.php +++ b/inc/Abilities/Media/Templates/FlowDiagramTemplate.php @@ -139,14 +139,8 @@ public function render( array $data, GDRenderer $renderer, array $options = arra } } - // 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. + // Grid metrics are always full size. The diagram is NEVER downscaled; + // instead the canvas is grown around the native-size content. $node_w = self::NODE_WIDTH; $node_h = self::NODE_HEIGHT; $gap = $has_edge_labels ? self::GAP + 90 : self::GAP; @@ -155,28 +149,17 @@ public function render( array $data, GDRenderer $renderer, array $options = arra $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 ); - } + // Resolve the canvas: + // - Explicit width/height or preset: use it, but grow (never shrink) + // so content is never clipped and never downscaled. + // - Default: render the content at native size, then pad the canvas + // out to a 16:9 aspect ratio around it. + $canvas = $this->resolve_canvas( $options, $content_w, $content_h ); + $width = $canvas['width']; + $height = $canvas['height']; - // 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 ); + $off_x = intdiv( $width - $content_w, 2 ); + $off_y = intdiv( $height - $content_h, 2 ); $renderer->create_canvas( $width, $height ); @@ -301,33 +284,72 @@ public function render( array $data, GDRenderer $renderer, array $options = arra } /** - * Resolve the target output canvas size. + * Resolve the output canvas size around native-size content. * - * Precedence: - * 1. Explicit options[width] + options[height] - * 2. options[preset] resolved via PlatformPresets - * 3. The template default preset (16:9 twitter_card, 1200x675) + * The diagram is never downscaled. The canvas is sized to at least the + * content, then: + * - Explicit width/height or a named preset: grow that target to the + * content if the content is larger (never clip, never shrink). + * - Default: pad the content out to a 16:9 aspect ratio, so a bare + * render produces a clean 16:9 card at the content's native resolution + * (e.g. a 4-node flow becomes ~1600x900, not a shrunk 1200x675). * - * @param array $options Render options (preset, width, height). + * @param array $options Render options (preset, width, height, aspect). + * @param int $content_w Native content width. + * @param int $content_h Native content height. * @return array{width: int, height: int} */ - private function resolve_canvas( array $options ): array { + private function resolve_canvas( array $options, int $content_w, int $content_h ): array { + // Explicit target dimensions (grow to content, never shrink). if ( ! empty( $options['width'] ) && ! empty( $options['height'] ) ) { return array( - 'width' => (int) $options['width'], - 'height' => (int) $options['height'], + 'width' => max( (int) $options['width'], $content_w ), + 'height' => max( (int) $options['height'], $content_h ), ); } - $preset = ! empty( $options['preset'] ) ? (string) $options['preset'] : self::DEFAULT_PRESET; - $dims = PlatformPresets::dimensions( $preset ); - if ( ! $dims ) { - $dims = PlatformPresets::dimensions( self::DEFAULT_PRESET ); + // Named preset: use its aspect ratio, applied at native content scale. + if ( ! empty( $options['preset'] ) ) { + $dims = PlatformPresets::dimensions( (string) $options['preset'] ); + if ( $dims && ! empty( $dims['width'] ) && ! empty( $dims['height'] ) ) { + return $this->pad_to_aspect( $content_w, $content_h, (int) $dims['width'], (int) $dims['height'] ); + } + } + + // Default: pad out to 16:9 around the native content. + return $this->pad_to_aspect( $content_w, $content_h, 16, 9 ); + } + + /** + * Grow a content box to a target aspect ratio without shrinking it. + * + * Whichever axis is short is padded up so width:height matches + * aspect_w:aspect_h. The content is never scaled, only surrounded. + * + * @param int $content_w Native content width. + * @param int $content_h Native content height. + * @param int $aspect_w Target aspect numerator. + * @param int $aspect_h Target aspect denominator. + * @return array{width: int, height: int} + */ + private function pad_to_aspect( int $content_w, int $content_h, int $aspect_w, int $aspect_h ): array { + $content_w = max( 1, $content_w ); + $content_h = max( 1, $content_h ); + + // Width needed to make this height match the aspect, and vice versa. + $needed_w = (int) ceil( $content_h * $aspect_w / $aspect_h ); + $needed_h = (int) ceil( $content_w * $aspect_h / $aspect_w ); + + if ( $needed_w >= $content_w ) { + return array( + 'width' => $needed_w, + 'height' => $content_h, + ); } return array( - 'width' => (int) ( $dims['width'] ?? 1200 ), - 'height' => (int) ( $dims['height'] ?? 675 ), + 'width' => $content_w, + 'height' => $needed_h, ); } diff --git a/tests/flow-diagram-template-smoke.php b/tests/flow-diagram-template-smoke.php index 1ed266179..f14082300 100644 --- a/tests/flow-diagram-template-smoke.php +++ b/tests/flow-diagram-template-smoke.php @@ -135,19 +135,33 @@ 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 ); + // Default: content rendered at native size, canvas padded to 16:9. + // The 4-node flow is wider than 1200, so the diagram is NOT downscaled + // into a fixed card; the canvas grows around it at a 16:9 ratio. + $ratio = is_array( $info ) ? $info[0] / $info[1] : 0; + $assert( 'default render is ~16:9 aspect', abs( $ratio - ( 16 / 9 ) ) < 0.02 ); + $assert( 'content rendered at native size (not downscaled to 1200 wide)', is_array( $info ) && $info[0] > 1200 ); wp_delete_file( $path ); } -// Explicit preset override is honored. +// Explicit preset uses that aspect ratio (still at native content scale). $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 ); + $oginfo = getimagesize( $og[0] ); + $ogratio = is_array( $oginfo ) ? $oginfo[0] / $oginfo[1] : 0; + $expected = 1200 / 630; + $assert( 'preset override applies its aspect ratio', abs( $ogratio - $expected ) < 0.03 ); wp_delete_file( $og[0] ); } +// Explicit width/height grows to content, never clips. +$fixed = $template->render( $spec, new GDRenderer(), array( 'width' => 400, 'height' => 300 ) ); +if ( ! empty( $fixed[0] ) && file_exists( $fixed[0] ) ) { + $finfo = getimagesize( $fixed[0] ); + $assert( 'explicit small canvas grows to content (no clip)', is_array( $finfo ) && $finfo[0] >= 400 && $finfo[1] >= 300 ); + wp_delete_file( $fixed[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 );