Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions inc/Abilities/Media/GDRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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',
Expand All @@ -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 );
Expand All @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 13 additions & 1 deletion inc/Abilities/Media/ImageTemplateAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'] ?? '';
Expand Down Expand Up @@ -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,
);

Expand Down
9 changes: 6 additions & 3 deletions inc/Abilities/Media/PlatformPresets.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class PlatformPresets {
/**
* Default platform dimension presets.
*
* @var array<string, array{width: int, height: int, label: string}>
* @var array<string, array{width: int, height: int, label: string, dpi?: int}>
*/
private const PRESETS = array(
'instagram_feed_portrait' => array(
Expand Down Expand Up @@ -67,24 +67,27 @@ 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,
),
);

/**
* 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();
Expand All @@ -94,7 +97,7 @@ public static function get( string $name ): ?array {
/**
* Get all presets.
*
* @return array<string, array{width: int, height: int, label: string}>
* @return array<string, array{width: int, height: int, label: string, dpi?: int}>
*/
public static function all(): array {
/**
Expand Down
12 changes: 12 additions & 0 deletions inc/Cli/Commands/ImageCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ public function generate( array $args, array $assoc_args ): void {
* [--format=<format>]
* : Output image format: png or jpeg (default: png). Note: this controls the image format, not the CLI output format.
*
* [--dpi=<n>]
* : Positive integer output DPI; overrides the platform preset resolution.
*
* [--pipeline_id=<id>]
* : Pipeline ID for file repository storage context.
*
Expand Down Expand Up @@ -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 ) ) {
Expand Down
128 changes: 128 additions & 0 deletions tests/Unit/Abilities/Media/ImageRenderDpiTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php
/**
* Standalone output-resolution coverage for image rendering.
*
* @package DataMachine\Tests
*/
namespace {
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', '/tmp/' );
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( $tag, $value, ...$args ) {
return $value;
}
}
if ( ! function_exists( 'do_action' ) ) {
function do_action( ...$args ) {}
}
if ( ! function_exists( 'wp_delete_file' ) ) {
function wp_delete_file( $path ) {
if ( is_file( $path ) ) {
unlink( $path );
}
}
}
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public function __construct( private string $code, private string $message = '', private array $data = array() ) {}
public function get_error_code(): string {
return $this->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() );
}
}
}
Loading