diff --git a/inc/Abilities/Media/GDRenderer.php b/inc/Abilities/Media/GDRenderer.php index b31f319d5..255548200 100644 --- a/inc/Abilities/Media/GDRenderer.php +++ b/inc/Abilities/Media/GDRenderer.php @@ -59,6 +59,12 @@ class GDRenderer { */ private int $height = 0; + /** Preset resolution for the current canvas, if specified. */ + private ?int $preset_dpi = null; + + /** Explicit resolution override, retained across canvases in batch renders. */ + private ?int $dpi_override = null; + /** * Resolved font paths cache. * @@ -97,7 +103,7 @@ class GDRenderer { */ public function create_canvas( int|string $width_or_preset, int $height = 0 ): self { if ( is_string( $width_or_preset ) ) { - $dims = PlatformPresets::dimensions( $width_or_preset ); + $dims = PlatformPresets::get( $width_or_preset ); if ( ! $dims ) { do_action( 'datamachine_log', @@ -106,11 +112,13 @@ public function create_canvas( int|string $width_or_preset, int $height = 0 ): s ); return $this; } - $this->width = $dims['width']; - $this->height = $dims['height']; + $this->width = $dims['width']; + $this->height = $dims['height']; + $this->preset_dpi = $dims['dpi'] ?? null; } else { - $this->width = $width_or_preset; - $this->height = $height; + $this->width = $width_or_preset; + $this->height = $height; + $this->preset_dpi = null; } $this->image = imagecreatetruecolor( $this->width, $this->height ); @@ -123,6 +131,21 @@ public function create_canvas( int|string $width_or_preset, int $height = 0 ): s return $this; } + /** + * Override output resolution without changing pixel dimensions. + * + * @param int $dpi Positive dots per inch. + * @return self + * @throws \InvalidArgumentException If DPI is not positive. + */ + public function set_dpi( int $dpi ): self { + if ( $dpi < 1 ) { + throw new \InvalidArgumentException( 'dpi must be a positive integer.' ); + } + $this->dpi_override = $dpi; + return $this; + } + /** * Get the current GD image resource. * @@ -1301,6 +1324,11 @@ public function save_temp( string $format = 'png', int $quality = -1 ): ?string return null; } + $dpi = $this->dpi_override ?? $this->preset_dpi; + if ( null !== $dpi ) { + imageresolution( $this->image, $dpi, $dpi ); + } + $temp_file = tempnam( sys_get_temp_dir(), 'datamachine_img_' ); $ext = 'jpeg' === $format ? '.jpg' : '.png'; $path = $temp_file . $ext; diff --git a/inc/Abilities/Media/ImageTemplateAbilities.php b/inc/Abilities/Media/ImageTemplateAbilities.php index 68b7b1b4e..fec438116 100644 --- a/inc/Abilities/Media/ImageTemplateAbilities.php +++ b/inc/Abilities/Media/ImageTemplateAbilities.php @@ -118,6 +118,11 @@ private static function render_image_template_definition(): array { 'type' => 'string', 'description' => 'Platform preset override (e.g. instagram_feed_portrait)', ), + 'dpi' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Output resolution in dots per inch; overrides the preset without resizing pixels.', + ), 'format' => array( 'type' => 'string', 'description' => 'Output format: png or jpeg', @@ -230,6 +235,10 @@ private static function list_image_templates_definition(): array { * @return array|\WP_Error Result with local paths and public URLs, or an error. */ public static function renderTemplate( array $input ): array|\WP_Error { + if ( array_key_exists( 'dpi', $input ) && ( ! is_int( $input['dpi'] ) || $input['dpi'] < 1 ) ) { + return new \WP_Error( 'invalid_dpi', 'dpi must be a positive integer.', array( 'status' => 400 ) ); + } + $template_id = $input['template_id'] ?? ''; $data = $input['data'] ?? array(); $preset = $input['preset'] ?? ''; @@ -281,7 +290,10 @@ public static function renderTemplate( array $input ): array|\WP_Error { } $renderer = new GDRenderer(); - $options = array( + if ( isset( $input['dpi'] ) ) { + $renderer->set_dpi( $input['dpi'] ); + } + $options = array( 'format' => $format, ); diff --git a/inc/Abilities/Media/PlatformPresets.php b/inc/Abilities/Media/PlatformPresets.php index df814fd54..0026bc1be 100644 --- a/inc/Abilities/Media/PlatformPresets.php +++ b/inc/Abilities/Media/PlatformPresets.php @@ -20,7 +20,7 @@ class PlatformPresets { /** * Default platform dimension presets. * - * @var array + * @var array */ private const PRESETS = array( 'instagram_feed_portrait' => array( @@ -67,16 +67,19 @@ class PlatformPresets { 'width' => 1650, 'height' => 2550, 'label' => 'Print Tabloid (11x17 @ 150dpi)', + 'dpi' => 150, ), 'print_tabloid_300' => array( 'width' => 3300, 'height' => 5100, 'label' => 'Print Tabloid (11x17 @ 300dpi)', + 'dpi' => 300, ), 'print_pocket_flyer' => array( 'width' => 1275, 'height' => 1650, 'label' => 'Print Pocket Flyer (4.25x5.5 @ 300dpi, 8-up on 11x17)', + 'dpi' => 300, ), ); @@ -84,7 +87,7 @@ class PlatformPresets { * Get a preset by name. * * @param string $name Preset name. - * @return array{width: int, height: int, label: string}|null Preset data or null if not found. + * @return array{width: int, height: int, label: string, dpi?: int}|null Preset data or null if not found. */ public static function get( string $name ): ?array { $presets = self::all(); @@ -94,7 +97,7 @@ public static function get( string $name ): ?array { /** * Get all presets. * - * @return array + * @return array */ public static function all(): array { /** diff --git a/inc/Cli/Commands/ImageCommand.php b/inc/Cli/Commands/ImageCommand.php index 482f5e216..9f0255fd4 100644 --- a/inc/Cli/Commands/ImageCommand.php +++ b/inc/Cli/Commands/ImageCommand.php @@ -175,6 +175,9 @@ public function generate( array $args, array $assoc_args ): void { * [--format=] * : Output image format: png or jpeg (default: png). Note: this controls the image format, not the CLI output format. * + * [--dpi=] + * : Positive integer output DPI; overrides the platform preset resolution. + * * [--pipeline_id=] * : Pipeline ID for file repository storage context. * @@ -219,6 +222,15 @@ public function render( array $args, array $assoc_args ): void { 'data' => $data, ); + if ( array_key_exists( 'dpi', $assoc_args ) ) { + $dpi = filter_var( $assoc_args['dpi'], FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); + if ( false === $dpi || is_bool( $assoc_args['dpi'] ) ) { + WP_CLI::error( 'Invalid --dpi: expected a positive integer.' ); + return; + } + $input['dpi'] = $dpi; + } + if ( isset( $assoc_args['brand'] ) ) { $brand = json_decode( $assoc_args['brand'] ); if ( json_last_error() !== JSON_ERROR_NONE || ! is_object( $brand ) ) { diff --git a/tests/Unit/Abilities/Media/ImageRenderDpiTest.php b/tests/Unit/Abilities/Media/ImageRenderDpiTest.php new file mode 100644 index 000000000..c37fdbddf --- /dev/null +++ b/tests/Unit/Abilities/Media/ImageRenderDpiTest.php @@ -0,0 +1,128 @@ +code; + } + } + } +} + +namespace DataMachine\Tests\Unit\Abilities\Media { + use DataMachine\Abilities\Media\GDRenderer; + use DataMachine\Abilities\Media\ImageTemplateAbilities; + use DataMachine\Abilities\Media\PlatformPresets; + use DataMachine\Abilities\Media\TemplateInterface; + use DataMachine\Abilities\Media\TemplateRegistry; + use PHPUnit\Framework\TestCase; + + class ImageRenderDpiTest extends TestCase { + private array $files = array(); + + protected function setUp(): void { + if ( ! extension_loaded( 'gd' ) ) { + $this->markTestSkipped( 'GD required.' ); + } + } + + protected function tearDown(): void { + foreach ( $this->files as $path ) { + wp_delete_file( $path ); + } + TemplateRegistry::clear_cache(); + } + + private function reload_resolution( GDRenderer $renderer, string $format ): array { + $path = $renderer->save_temp( $format ); + $this->assertNotNull( $path ); + $this->files[] = $path; + $image = 'jpeg' === $format ? imagecreatefromjpeg( $path ) : imagecreatefrompng( $path ); + $this->assertSame( $renderer->get_width(), imagesx( $image ) ); + $this->assertSame( $renderer->get_height(), imagesy( $image ) ); + return imageresolution( $image ); + } + + public function test_print_presets_embed_resolution_in_png_and_jpeg(): void { + foreach ( array( 'print_tabloid' => 150, 'print_tabloid_300' => 300, 'print_pocket_flyer' => 300 ) as $preset => $dpi ) { + $this->assertSame( $dpi, PlatformPresets::get( $preset )['dpi'] ); + $renderer = ( new GDRenderer() )->create_canvas( $preset ); + foreach ( array( 'png', 'jpeg' ) as $format ) { + $this->assertSame( array( $dpi, $dpi ), $this->reload_resolution( $renderer, $format ) ); + } + } + } + + public function test_non_print_and_explicit_dimensions_keep_gd_default_after_canvas_reuse(): void { + $renderer = ( new GDRenderer() )->create_canvas( 'print_tabloid' ); + $this->reload_resolution( $renderer, 'png' ); + $renderer->create_canvas( 'instagram_feed_portrait' ); + $this->assertArrayNotHasKey( 'dpi', PlatformPresets::get( 'instagram_feed_portrait' ) ); + $default = imageresolution( $renderer->get_image() ); + foreach ( array( 'png', 'jpeg' ) as $format ) { + $this->assertSame( $default, $this->reload_resolution( $renderer, $format ) ); + } + $renderer->create_canvas( 20, 30 ); + $this->assertSame( $default, $this->reload_resolution( $renderer, 'png' ) ); + } + + public function test_override_wins_and_survives_batch_canvas_creation(): void { + $renderer = ( new GDRenderer() )->set_dpi( 240 ); + foreach ( array( 'print_tabloid', 'print_pocket_flyer', 'instagram_feed_portrait' ) as $preset ) { + $renderer->create_canvas( $preset ); + foreach ( array( 'png', 'jpeg' ) as $format ) { + $this->assertSame( array( 240, 240 ), $this->reload_resolution( $renderer, $format ) ); + } + } + } + + public function test_ability_rejects_invalid_dpi_before_rendering(): void { + foreach ( array( 0, -1, 1.5, '150', 'bad', true, null, array() ) as $dpi ) { + $result = ImageTemplateAbilities::renderTemplate( array( 'dpi' => $dpi ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'invalid_dpi', $result->get_error_code() ); + } + } + + public function test_ability_applies_override_before_template_creates_canvas(): void { + $template = $this->createMock( TemplateInterface::class ); + $template->method( 'get_fields' )->willReturn( array() ); + $template->expects( $this->once() )->method( 'render' )->willReturnCallback( + function ( $data, GDRenderer $renderer, $options ) { + $renderer->create_canvas( 'print_tabloid' ); + $this->assertSame( array( 200, 200 ), $this->reload_resolution( $renderer, 'png' ) ); + // Avoid storage: the callback's render is sufficient to check propagation. + return array(); + } + ); + $property = new \ReflectionProperty( TemplateRegistry::class, 'templates' ); + $property->setValue( null, array( 'dpi_test' => $template ) ); + $result = ImageTemplateAbilities::renderTemplate( array( 'template_id' => 'dpi_test', 'data' => array(), 'dpi' => 200 ) ); + $this->assertSame( 'template_render_empty', $result->get_error_code() ); + } + } +}