@@ -139,14 +139,8 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
139139 }
140140 }
141141
142- // Resolve the target canvas first (default is a 16:9 card). The diagram
143- // grid is then scaled to fit inside it and centered, so a bare render
144- // always produces a clean, correctly-proportioned image.
145- $ canvas = $ this ->resolve_canvas ( $ options );
146- $ width = $ canvas ['width ' ];
147- $ height = $ canvas ['height ' ];
148-
149- // Base (unscaled) grid metrics.
142+ // Grid metrics are always full size. The diagram is NEVER downscaled;
143+ // instead the canvas is grown around the native-size content.
150144 $ node_w = self ::NODE_WIDTH ;
151145 $ node_h = self ::NODE_HEIGHT ;
152146 $ gap = $ has_edge_labels ? self ::GAP + 90 : self ::GAP ;
@@ -155,28 +149,17 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
155149 $ content_w = $ this ->grid_width ( $ direction , $ count , $ node_w , $ gap , $ margin );
156150 $ content_h = $ this ->grid_height ( $ direction , $ count , $ node_h , $ gap , $ margin , $ title_band );
157151
158- // Scale the whole grid down uniformly if it overflows the canvas in
159- // either axis. Never scale up past the base size (keeps small diagrams
160- // from ballooning on a large canvas).
161- $ scale = min ( 1.0 , $ width / max ( 1 , $ content_w ), $ height / max ( 1 , $ content_h ) );
162- if ( $ scale < 1.0 ) {
163- // floor() (not round()) guarantees the scaled grid never exceeds the
164- // target canvas by a rounding pixel, so preset dimensions stay exact.
165- $ node_w = (int ) floor ( $ node_w * $ scale );
166- $ node_h = (int ) floor ( $ node_h * $ scale );
167- $ gap = (int ) floor ( $ gap * $ scale );
168- $ margin = (int ) floor ( $ margin * $ scale );
169- $ title_band = (int ) floor ( $ title_band * $ scale );
170- $ content_w = $ this ->grid_width ( $ direction , $ count , $ node_w , $ gap , $ margin );
171- $ content_h = $ this ->grid_height ( $ direction , $ count , $ node_h , $ gap , $ margin , $ title_band );
172- }
152+ // Resolve the canvas:
153+ // - Explicit width/height or preset: use it, but grow (never shrink)
154+ // so content is never clipped and never downscaled.
155+ // - Default: render the content at native size, then pad the canvas
156+ // out to a 16:9 aspect ratio around it.
157+ $ canvas = $ this ->resolve_canvas ( $ options , $ content_w , $ content_h );
158+ $ width = $ canvas ['width ' ];
159+ $ height = $ canvas ['height ' ];
173160
174- // If content is still larger than the canvas (single very wide node),
175- // grow the canvas so nothing clips.
176- $ width = max ( $ width , $ content_w );
177- $ height = max ( $ height , $ content_h );
178- $ off_x = intdiv ( $ width - $ content_w , 2 );
179- $ off_y = intdiv ( $ height - $ content_h , 2 );
161+ $ off_x = intdiv ( $ width - $ content_w , 2 );
162+ $ off_y = intdiv ( $ height - $ content_h , 2 );
180163
181164 $ renderer ->create_canvas ( $ width , $ height );
182165
@@ -301,33 +284,72 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
301284 }
302285
303286 /**
304- * Resolve the target output canvas size.
287+ * Resolve the output canvas size around native-size content .
305288 *
306- * Precedence:
307- * 1. Explicit options[width] + options[height]
308- * 2. options[preset] resolved via PlatformPresets
309- * 3. The template default preset (16:9 twitter_card, 1200x675)
289+ * The diagram is never downscaled. The canvas is sized to at least the
290+ * content, then:
291+ * - Explicit width/height or a named preset: grow that target to the
292+ * content if the content is larger (never clip, never shrink).
293+ * - Default: pad the content out to a 16:9 aspect ratio, so a bare
294+ * render produces a clean 16:9 card at the content's native resolution
295+ * (e.g. a 4-node flow becomes ~1600x900, not a shrunk 1200x675).
310296 *
311- * @param array $options Render options (preset, width, height).
297+ * @param array $options Render options (preset, width, height, aspect).
298+ * @param int $content_w Native content width.
299+ * @param int $content_h Native content height.
312300 * @return array{width: int, height: int}
313301 */
314- private function resolve_canvas ( array $ options ): array {
302+ private function resolve_canvas ( array $ options , int $ content_w , int $ content_h ): array {
303+ // Explicit target dimensions (grow to content, never shrink).
315304 if ( ! empty ( $ options ['width ' ] ) && ! empty ( $ options ['height ' ] ) ) {
316305 return array (
317- 'width ' => ( int ) $ options ['width ' ],
318- 'height ' => ( int ) $ options ['height ' ],
306+ 'width ' => max ( ( int ) $ options ['width ' ], $ content_w ) ,
307+ 'height ' => max ( ( int ) $ options ['height ' ], $ content_h ) ,
319308 );
320309 }
321310
322- $ preset = ! empty ( $ options ['preset ' ] ) ? (string ) $ options ['preset ' ] : self ::DEFAULT_PRESET ;
323- $ dims = PlatformPresets::dimensions ( $ preset );
324- if ( ! $ dims ) {
325- $ dims = PlatformPresets::dimensions ( self ::DEFAULT_PRESET );
311+ // Named preset: use its aspect ratio, applied at native content scale.
312+ if ( ! empty ( $ options ['preset ' ] ) ) {
313+ $ dims = PlatformPresets::dimensions ( (string ) $ options ['preset ' ] );
314+ if ( $ dims && ! empty ( $ dims ['width ' ] ) && ! empty ( $ dims ['height ' ] ) ) {
315+ return $ this ->pad_to_aspect ( $ content_w , $ content_h , (int ) $ dims ['width ' ], (int ) $ dims ['height ' ] );
316+ }
317+ }
318+
319+ // Default: pad out to 16:9 around the native content.
320+ return $ this ->pad_to_aspect ( $ content_w , $ content_h , 16 , 9 );
321+ }
322+
323+ /**
324+ * Grow a content box to a target aspect ratio without shrinking it.
325+ *
326+ * Whichever axis is short is padded up so width:height matches
327+ * aspect_w:aspect_h. The content is never scaled, only surrounded.
328+ *
329+ * @param int $content_w Native content width.
330+ * @param int $content_h Native content height.
331+ * @param int $aspect_w Target aspect numerator.
332+ * @param int $aspect_h Target aspect denominator.
333+ * @return array{width: int, height: int}
334+ */
335+ private function pad_to_aspect ( int $ content_w , int $ content_h , int $ aspect_w , int $ aspect_h ): array {
336+ $ content_w = max ( 1 , $ content_w );
337+ $ content_h = max ( 1 , $ content_h );
338+
339+ // Width needed to make this height match the aspect, and vice versa.
340+ $ needed_w = (int ) ceil ( $ content_h * $ aspect_w / $ aspect_h );
341+ $ needed_h = (int ) ceil ( $ content_w * $ aspect_h / $ aspect_w );
342+
343+ if ( $ needed_w >= $ content_w ) {
344+ return array (
345+ 'width ' => $ needed_w ,
346+ 'height ' => $ content_h ,
347+ );
326348 }
327349
328350 return array (
329- 'width ' => ( int ) ( $ dims [ ' width ' ] ?? 1200 ) ,
330- 'height ' => ( int ) ( $ dims [ ' height ' ] ?? 675 ) ,
351+ 'width ' => $ content_w ,
352+ 'height ' => $ needed_h ,
331353 );
332354 }
333355
0 commit comments