3737namespace DataMachine \Abilities \Media \Templates ;
3838
3939use DataMachine \Abilities \Media \GDRenderer ;
40+ use DataMachine \Abilities \Media \PlatformPresets ;
4041use DataMachine \Abilities \Media \TemplateInterface ;
4142
4243if ( ! defined ( 'ABSPATH ' ) ) {
@@ -96,8 +97,14 @@ public function get_fields(): array {
9697 );
9798 }
9899
100+ /**
101+ * Default canvas: twitter_card is 1200x675, a true 16:9 card that works well
102+ * as a blog featured image / social share. The diagram is centered within it.
103+ */
104+ private const DEFAULT_PRESET = 'twitter_card ' ;
105+
99106 public function get_default_preset (): string {
100- return ' open_graph ' ;
107+ return self :: DEFAULT_PRESET ;
101108 }
102109
103110 /**
@@ -131,17 +138,46 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
131138 break ;
132139 }
133140 }
134- $ gap = $ has_edge_labels ? self ::GAP + 90 : self ::GAP ;
135141
136- // Compute canvas from the node grid (ignore preset; diagrams size to content).
137- if ( 'vertical ' === $ direction ) {
138- $ width = self ::MARGIN * 2 + self ::NODE_WIDTH ;
139- $ height = self ::MARGIN * 2 + $ title_band + $ count * self ::NODE_HEIGHT + ( $ count - 1 ) * $ gap ;
140- } else {
141- $ width = self ::MARGIN * 2 + $ count * self ::NODE_WIDTH + ( $ count - 1 ) * $ gap ;
142- $ height = self ::MARGIN * 2 + $ title_band + self ::NODE_HEIGHT ;
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.
150+ $ node_w = self ::NODE_WIDTH ;
151+ $ node_h = self ::NODE_HEIGHT ;
152+ $ gap = $ has_edge_labels ? self ::GAP + 90 : self ::GAP ;
153+ $ margin = self ::MARGIN ;
154+
155+ $ content_w = $ this ->grid_width ( $ direction , $ count , $ node_w , $ gap , $ margin );
156+ $ content_h = $ this ->grid_height ( $ direction , $ count , $ node_h , $ gap , $ margin , $ title_band );
157+
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 );
143172 }
144173
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 );
180+
145181 $ renderer ->create_canvas ( $ width , $ height );
146182
147183 // 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
159195 $ renderer ->fill ( $ bg );
160196
161197 if ( '' !== $ title ) {
162- $ renderer ->filled_rect ( 0 , 0 , $ width , $ title_band , $ surface );
163- $ renderer ->draw_text_centered ( $ title , 30 , self ::MARGIN + 6 , $ title_c , 'title ' );
198+ $ title_fs = max ( 16 , (int ) round ( 30 * ( $ title_band / self ::TITLE_BAND ) ) );
199+ $ renderer ->filled_rect ( 0 , $ off_y , $ width , $ off_y + $ title_band , $ surface );
200+ $ renderer ->draw_text_centered ( $ title , $ title_fs , $ off_y + $ margin + 6 , $ title_c , 'title ' );
164201 }
165202
166203 // Position nodes on a single row/column and index by id for edge routing.
204+ // Offsets center the content grid within the (possibly larger) canvas.
167205 $ positions = array ();
168- $ top0 = self :: MARGIN + $ title_band ;
206+ $ top0 = $ off_y + $ margin + $ title_band ;
169207
170208 foreach ( $ nodes as $ i => $ node ) {
171209 if ( 'vertical ' === $ direction ) {
172- $ x = self :: MARGIN ;
173- $ y = $ top0 + $ i * ( self :: NODE_HEIGHT + $ gap );
210+ $ x = $ off_x + $ margin ;
211+ $ y = $ top0 + $ i * ( $ node_h + $ gap );
174212 } else {
175- $ x = self :: MARGIN + $ i * ( self :: NODE_WIDTH + $ gap );
213+ $ x = $ off_x + $ margin + $ i * ( $ node_w + $ gap );
176214 $ y = $ top0 ;
177215 }
178216
@@ -181,10 +219,10 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
181219 $ positions [ $ id ] = array (
182220 'x ' => $ x ,
183221 'y ' => $ y ,
184- 'cx ' => $ x + intdiv ( self :: NODE_WIDTH , 2 ),
185- 'cy ' => $ y + intdiv ( self :: NODE_HEIGHT , 2 ),
186- 'right ' => $ x + self :: NODE_WIDTH ,
187- 'bottom ' => $ y + self :: NODE_HEIGHT ,
222+ 'cx ' => $ x + intdiv ( $ node_w , 2 ),
223+ 'cy ' => $ y + intdiv ( $ node_h , 2 ),
224+ 'right ' => $ x + $ node_w ,
225+ 'bottom ' => $ y + $ node_h ,
188226 );
189227 }
190228
@@ -240,45 +278,99 @@ public function render( array $data, GDRenderer $renderer, array $options = arra
240278 $ label = isset ( $ node ['label ' ] ) ? (string ) $ node ['label ' ] : '' ;
241279 $ label = str_replace ( '\n ' , "\n" , $ label );
242280
281+ $ radius = max ( 6 , (int ) round ( 16 * ( $ node_h / self ::NODE_HEIGHT ) ) );
282+
243283 switch ( $ shape ) {
244284 case 'diamond ' :
245- $ renderer ->draw_diamond ( $ pos ['cx ' ], $ pos ['cy ' ], self :: NODE_WIDTH , self :: NODE_HEIGHT , $ fill , true );
285+ $ renderer ->draw_diamond ( $ pos ['cx ' ], $ pos ['cy ' ], $ node_w , $ node_h , $ fill , true );
246286 break ;
247287 case 'oval ' :
248- $ renderer ->draw_oval ( $ pos ['cx ' ], $ pos ['cy ' ], self :: NODE_WIDTH , self :: NODE_HEIGHT , $ fill , true );
288+ $ renderer ->draw_oval ( $ pos ['cx ' ], $ pos ['cy ' ], $ node_w , $ node_h , $ fill , true );
249289 break ;
250290 default :
251- $ renderer ->draw_rounded_rect ( $ pos ['x ' ], $ pos ['y ' ], self :: NODE_WIDTH , self :: NODE_HEIGHT , $ fill , 16 );
291+ $ renderer ->draw_rounded_rect ( $ pos ['x ' ], $ pos ['y ' ], $ node_w , $ node_h , $ fill , $ radius );
252292 break ;
253293 }
254294
255- $ this ->draw_node_label ( $ renderer , $ label , $ pos , $ text_c );
295+ $ this ->draw_node_label ( $ renderer , $ label , $ pos , $ text_c, $ node_w );
256296 }
257297
258298 $ path = $ renderer ->save_temp ( $ format );
259299
260300 return $ path ? array ( $ path ) : array ();
261301 }
262302
303+ /**
304+ * Resolve the target output canvas size.
305+ *
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)
310+ *
311+ * @param array $options Render options (preset, width, height).
312+ * @return array{width: int, height: int}
313+ */
314+ private function resolve_canvas ( array $ options ): array {
315+ if ( ! empty ( $ options ['width ' ] ) && ! empty ( $ options ['height ' ] ) ) {
316+ return array (
317+ 'width ' => (int ) $ options ['width ' ],
318+ 'height ' => (int ) $ options ['height ' ],
319+ );
320+ }
321+
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 );
326+ }
327+
328+ return array (
329+ 'width ' => (int ) ( $ dims ['width ' ] ?? 1200 ),
330+ 'height ' => (int ) ( $ dims ['height ' ] ?? 675 ),
331+ );
332+ }
333+
334+ /**
335+ * Total grid width for the given metrics.
336+ */
337+ private function grid_width ( string $ direction , int $ count , int $ node_w , int $ gap , int $ margin ): int {
338+ if ( 'vertical ' === $ direction ) {
339+ return $ margin * 2 + $ node_w ;
340+ }
341+ return $ margin * 2 + $ count * $ node_w + ( $ count - 1 ) * $ gap ;
342+ }
343+
344+ /**
345+ * Total grid height for the given metrics.
346+ */
347+ private function grid_height ( string $ direction , int $ count , int $ node_h , int $ gap , int $ margin , int $ title_band ): int {
348+ if ( 'vertical ' === $ direction ) {
349+ return $ margin * 2 + $ title_band + $ count * $ node_h + ( $ count - 1 ) * $ gap ;
350+ }
351+ return $ margin * 2 + $ title_band + $ node_h ;
352+ }
353+
263354 /**
264355 * Draw a node's (possibly multi-line, possibly wrapped) label centered in the node box.
265356 *
266357 * @param GDRenderer $renderer Renderer.
267358 * @param string $label Label text (may contain "\n").
268359 * @param array<string, int> $pos Node position record.
269360 * @param int $color Text color id.
361+ * @param int $node_w Node width (scaled).
270362 */
271- private function draw_node_label ( GDRenderer $ renderer , string $ label , array $ pos , int $ color ): void {
363+ private function draw_node_label ( GDRenderer $ renderer , string $ label , array $ pos , int $ color, int $ node_w ): void {
272364 if ( '' === $ label ) {
273365 return ;
274366 }
275367
276- $ inner = self :: NODE_WIDTH - 40 ;
368+ $ inner = $ node_w - 40 ;
277369
278- // Auto-shrink the font until the longest single token fits the node
279- // width. Handles long unbroken strings (e.g. "ai-provider-for-claude-code")
280- // that word-wrapping alone can't break.
281- $ font_size = 20 ;
370+ // Base font scales with the node, then auto-shrinks until the longest
371+ // single token fits the inner width. Handles long unbroken strings
372+ // (e.g. "ai-provider-for-claude-code") that word-wrapping can't break.
373+ $ font_size = max ( 11 , ( int ) round ( 20 * ( $ node_w / self :: NODE_WIDTH ) ) ) ;
282374 $ longest = '' ;
283375 foreach ( preg_split ( '/\s+/ ' , str_replace ( "\n" , ' ' , $ label ) ) as $ token ) {
284376 if ( strlen ( $ token ) > strlen ( $ longest ) ) {
0 commit comments