feat: deterministic image composer - OG + square variants, branding, baked caption (v0.17.0) (#152)

Composer slots between provider bytes and sideload: corner-marked featured,
branded 1200x630 OG + 1080x1080 square with baked clamped caption, capability
ladder (Imagick -> resize-only -> pass-through, never blocks publish).
Alt-text bug fix (caption, not model name). Vendored brand PNGs + OFL Poppins.

QA: APPROVE-WITH-P2-NOTES @ 6f76df1 (qa-reviews/prautoblogger/2026-06-11-6f76df1.md)
CEO greenlight: 2026-06-11 in chat.

Agent-Session: cto-image-composer-merge-20260611
This commit is contained in:
peptiderepo
2026-06-11 15:27:48 +07:00
committed by GitHub
parent d559fc58ab
commit 6de8cf2031
26 changed files with 2424 additions and 128 deletions

View File

@@ -0,0 +1,104 @@
<?php
/**
* Tests for PRAutoBlogger_Image_Composer_Imagick (real renders).
*
* These run only where ext-imagick is loaded (skipped on the standard CI
* matrix); the same renders are exercised by the standalone harness that
* produced the PR samples. Asserts the determinism contract — identical
* inputs yield byte-identical output in the same environment — plus the
* exact variant geometry.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class ImageComposerImagickTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
if ( ! extension_loaded( 'imagick' ) ) {
$this->markTestSkipped( 'ext-imagick not available — renderer covered by the server-side harness.' );
}
}
/** Helper: renderer wired to the real bundled assets. */
private function renderer(): \PRAutoBlogger_Image_Composer_Imagick {
$root = dirname( __DIR__, 3 );
return new \PRAutoBlogger_Image_Composer_Imagick(
\PRAutoBlogger_Image_Composer_Layout::defaults(),
$root . '/assets/brand/',
$root . '/assets/fonts/'
);
}
/** Helper: deterministic 1200×632 fixture base image (gradient + disc). */
private function fixture_bytes(): string {
$image = new \Imagick();
$image->newPseudoImage( 1200, 632, 'gradient:#1B8A92-#FAFAF7' );
$draw = new \ImagickDraw();
$draw->setFillColor( new \ImagickPixel( '#FF8A3D' ) );
$draw->circle( 600, 316, 600, 470 );
$image->drawImage( $draw );
$image->setImageFormat( 'png' );
return $image->getImageBlob();
}
/**
* Determinism: rendering the same input twice yields byte-identical
* variants (metadata stripped, PNG date/time chunks excluded).
*/
public function test_same_input_renders_byte_identical_output(): void {
$bytes = $this->fixture_bytes();
$caption = 'Collagen fragments may aid joint repair';
$first = $this->renderer()->compose_og( $bytes, $caption );
$second = $this->renderer()->compose_og( $bytes, $caption );
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'OG render must be byte-stable' );
$first = $this->renderer()->compose_square( $bytes, $caption );
$second = $this->renderer()->compose_square( $bytes, $caption );
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Square render must be byte-stable' );
$first = $this->renderer()->compose_featured( $bytes );
$second = $this->renderer()->compose_featured( $bytes );
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Featured render must be byte-stable' );
}
/**
* Variant geometry matches the spec: featured keeps base dimensions,
* OG is exactly 1200×630, square exactly 1080×1080, all PNG.
*/
public function test_variant_dimensions_and_mime(): void {
$bytes = $this->fixture_bytes();
$caption = 'Collagen fragments may aid joint repair';
$featured = $this->renderer()->compose_featured( $bytes );
$this->assertSame( [ 1200, 632, 'image/png', 'featured' ], [ $featured['width'], $featured['height'], $featured['mime_type'], $featured['role'] ] );
$og = $this->renderer()->compose_og( $bytes, $caption );
$this->assertSame( [ 1200, 630, 'image/png', 'og' ], [ $og['width'], $og['height'], $og['mime_type'], $og['role'] ] );
$square = $this->renderer()->compose_square( $bytes, $caption );
$this->assertSame( [ 1080, 1080, 'image/png', 'square' ], [ $square['width'], $square['height'], $square['mime_type'], $square['role'] ] );
}
/**
* Output PNG bytes embed no date text chunks or tIME chunk, so a render
* is stable across wall-clock time, not just within one process. (We
* scan the raw bytes: Imagick synthesizes `date:*` image properties at
* read time even for chunk-free PNGs, so getImageProperties() can't
* distinguish embedded dates from synthesized ones.)
*/
public function test_output_contains_no_date_chunks(): void {
$og = $this->renderer()->compose_og( $this->fixture_bytes(), 'Short caption' );
$this->assertFalse( strpos( $og['bytes'], 'date:create' ), 'PNG must not embed a date:create text chunk' );
$this->assertFalse( strpos( $og['bytes'], 'date:modify' ), 'PNG must not embed a date:modify text chunk' );
$this->assertFalse( strpos( $og['bytes'], 'tIME' ), 'PNG must not embed a tIME chunk' );
}
}