get_blogs_php_image( 'foto777-titolo.jpg', array( 'w' => '300', 'crop' => '1' ) ); $this->assertImageDimensions( $data, 300, 225 ); } /** * Test the image is resized to the dimensions of the specified "box". */ public function testImageResizing() { $data = $this->get_blogs_php_image( 'foto777-titolo.jpg', array( 'resize' => '100,100' ) ); $this->assertImageDimensions( $data, 100, 100 ); } /** * Test the image is resized to fit into the specified "box". */ public function testImageFit() { $data = $this->get_blogs_php_image( 'kate-middleton-644.jpg', array( 'fit' => '80,80' ) ); $this->assertImageDimensions( $data, 55, 80 ); } /** * Test crop at offsets and dimensions specified in percentages. */ public function testImageCropOffset() { $data = $this->get_blogs_php_image( 'kate-middleton-644.jpg', array( 'crop' => '25,5,40,40' ) ); $this->assertImageDimensions( $data, 177, 258 ); } /** * If the requested width is out of bounds we return the original * image, but we must still process the grayscale filter. */ public function testImageOversizedWidthRequest() { $data = $this->get_blogs_php_image( 'pasta-scampi-468.jpg', array( 'w' => '5000', 'filter' => 'grayscale' ) ); $this->assertImageDimensions( $data, 468, 185 ); $this->assertImageGrayscale( $data ); } /** * Ensure we have the correct dimensions after a chain of * transformations and that the last filter is also applied. */ public function testImageArgumentChaining() { $data = $this->get_blogs_php_image( 'kate-middleton-644.jpg', array( 'h' => '400', 'fit' => '200,200', 'crop' => '20px,20px,60,40', 'filter' => 'emboss,grayscale' ) ); $this->assertImageDimensions( $data, 83, 80 ); $this->assertImageGrayscale( $data ); } public function dataGrayscaleTransparentPNGStaysTransparentAfterWrite() { /* Both images are 1140 pixels wide. `w=1000` forces them to be rewritten */ return array( array( 'recebeu.png', array( 'w' => '1000' ) ), array( 'oi.png', array( 'w' => '1000' ) ), ); } /** * Ensure transparent grayscale PNGs aren't silently demoted, losing transparency * * @dataProvider dataGrayscaleTransparentPNGStaysTransparentAfterWrite */ public function testGrayscaleTransparentPNGStaysTransparentAfterWrite( $image, $params ) { $data = $this->get_blogs_php_image( $image, $params ); $image = imagecreatefromstring( $data ); $first_pixel = imagecolorat( $image, 0, 0 ); $first_pixel = imagecolorsforindex( $image, $first_pixel ); $this->assertGreaterThan( 0, $first_pixel['alpha'] ); } /** * A jpeg's real quality is not only determined by it's quality value; * there is more than one way of inferring it, ours isn't perfect and * should not be used as an upper limit, as it sometimes introduces * a significant amount of artifacts. */ public function testUserIsAllowedToOverrideQualityPropagation() { $image = 'low-quality-image-that-is-made-worse.jpg'; $original_data = $this->get_blogs_php_image( $image ); $original_image = imagecreatefromstring( $original_data ); // The image is 600 pixels wide, we reduce w to force it to be processed $default_quality_data = $this->get_blogs_php_image( $image, array( 'w' => 599 ) ); $default_quality_image = imagecreatefromstring( $default_quality_data ); $max_quality_data = $this->get_blogs_php_image( $image, array( 'w' => 599, 'quality' => 100 ) ); $max_quality_image = imagecreatefromstring( $max_quality_data ); $error_from_default = $this->getSumOfSquarePixelDistances( $original_image, $default_quality_image, true ); $error_from_max = $this->getSumOfSquarePixelDistances( $original_image, $max_quality_image, true ); $this->assertLessThan( $error_from_default, $error_from_max ); } /** * Requesting a JPEG with `quality=20` and no other parameters must force * a re-encode at the lower quality, producing a smaller payload than the * untransformed request. */ public function testQualityOnlyParameterShrinksJpeg() { $image = 'foto777-titolo.jpg'; $baseline_data = $this->get_blogs_php_image( $image ); $low_quality_data = $this->get_blogs_php_image( $image, array( 'quality' => 20 ) ); $this->assertLessThan( strlen( $baseline_data ), strlen( $low_quality_data ) ); } public function dataBothImageLimitsAreEnforced() { /* Both images are above the 20000px limit in only one dimension */ return array( array( 'too-tall.jpg', array( 'resize' => '100,100' ) ), array( 'too-wide.jpg', array( 'resize' => '100,100' ) ), ); } /** * Ensure images that are large in any dimension are rejected * * @dataProvider dataBothImageLimitsAreEnforced */ public function testBothImageLimitsAreEnforced( $image, $params ) { $data = $this->get_blogs_php_image( $image, $params ); $this->assertRequestFailed( $data ); } public function testRotationIsNotLostForWebpWithStrip() { $data = $this->get_blogs_php_image( 'landscape-8-exif.jpg', array( 'w' => '500', 'strip' => 'all' ), array( 'ACCEPT' => 'image/webp' ) ); $this->assertImageFormat( $data, 'webp' ); $this->assertImageDimensions( $data, 500, 333 ); $this->assertRotation( $data, false ); } /** * Ensure webp rotation is always hardcoded, otherwise browsers don't support it */ public function testRotationRespectedForWebpWithoutStrip() { $data = $this->get_blogs_php_image( 'landscape-8-exif.jpg', array( 'w' => '500', 'strip' => 'none' ), array( 'ACCEPT' => 'image/webp' ) ); $this->assertImageFormat( $data, 'webp' ); $this->assertImageDimensions( $data, 500, 333 ); // Final image might contain the exif entry, which is ignored by browsers // $this->assertRotation( $data, false ); } public function dataPercentageSignInSizeIsIgnored() { return array( array( 'foto777-titolo.jpg', array( 'w' => '50%' ), 50, 37 ), array( 'foto777-titolo.jpg', array( 'h' => '50%' ), 66, 50 ), ); } /** * Ensures widths and heights such as 50% are intepreted as 50px * * @dataProvider dataPercentageSignInSizeIsIgnored */ public function testPercentageSignInSizeIsIgnored( $image, $params, $width, $height ) { $data = $this->get_blogs_php_image( $image, $params ); $this->assertImageDimensions( $data, $width, $height ); } public function dataBasicLetterboxing() { return array( array( 'foto777-titolo.jpg', array( 'lb' => '300,350' ), array( 62, 63, 0, 0 ) ), array( 'foto777-titolo.jpg', array( 'lb' => '300,349' ), array( 62, 62, 0, 0 ) ), array( 'foto777-titolo.jpg', array( 'lb' => '196,147' ), array( 0, 0, 0, 0 ) ), array( 'kate-middleton-644.jpg', array( 'lb' => '300,1200' ), array( 382, 382, 0, 0 ) ), array( 'kate-middleton-644.jpg', array( 'lb' => '600,350' ), array( 0, 0, 179, 180 ) ), ); } /** Tests the basic letterboxing functionality * * @dataProvider dataBasicLetterboxing */ public function testBasicLetterboxing( $image, $params, $expected_black_borders ) { $data = $this->get_blogs_php_image( $image, $params ); $black_borders = $this->calculateBlackBorders( $data ); $this->assertEquals( $expected_black_borders , $black_borders ); } public function dataBadExifDataCanBeStrippedFromPNG() { return array( array( 'broken-exif.png', array( 'strip' => 'all' ) ), array( 'broken-exif.png', array( 'strip' => 'all', 'w' => 100 ) ), array( 'broken-exif.png', array( 'strip' => 'info' ) ), array( 'broken-exif.png', array( 'strip' => 'info', 'w' => 100 ) ), array( 'broken-exif.png', array( 'strip' => 'color' ) ), array( 'broken-exif.png', array( 'strip' => 'color', 'w' => 100 ) ), ); } /** Tests that strip=all works with PNG files, including when they have * bad data. This only works with optipng * * @dataProvider dataBadExifDataCanBeStrippedFromPNG */ public function testBadExifDataCanBeStrippedFromPNG( $image, $params ) { $data = $this->get_blogs_php_image( $image, $params ); $chunks = $this->getPNGChunks( $data ); $this->assertNotContains( 'eXIf', $chunks ); } public function dataLargeGifsAreRejectedAccordingToMemoryLimit() { return array( array( 'small.gif', array( 'w' => 127 ), 50 * 2**20, '512M', true ), array( 'small.gif', array( 'w' => 127 ), 50 * 2**20, '128M', false ), array( 'small.gif', array( 'w' => 127 ), 50 * 2**20, '-1', true ), ); } /** * Ensures gifs that are likely to fatal during processing are rejected early * * @dataProvider dataLargeGifsAreRejectedAccordingToMemoryLimit */ public function testLargeGifsAreRejectedAccordingToMemoryLimit( $image, $params, $num_appended_bytes, $mem_limit, $should_be_processed ) { $data = $this->get_blogs_php_image( $image, $params, array(), array( "-dmemory_limit=$mem_limit" ), $num_appended_bytes ); if ( $should_be_processed ) { $this->assertStringStartsWith( 'GIF', $data ); } else { $this->assertStringStartsNotWith( 'GIF', $data ); } } public function dataWebpsArePreparsed() { // Three different types of WebP images, all are 128x64 return array( array( 'vp8.webp', 129, 65, false ), array( 'vp8.webp', 128, 65, true ), array( 'vp8.webp', 129, 64, true ), array( 'vp8l.webp', 129, 65, false ), array( 'vp8l.webp', 128, 65, true ), array( 'vp8l.webp', 129, 64, true ), array( 'vp8x.webp', 129, 65, false ), array( 'vp8x.webp', 128, 65, true ), array( 'vp8x.webp', 129, 64, true ), ); } /** * Ensures webps are correctly preparsed * * @dataProvider dataWebpsArePreparsed */ public function testWebpsArePreparsed( $image, $max_width, $max_height, $should_be_rejected ) { if ( false === phpversion( 'photon-opencv' ) ) { $this->markTestSkipped( 'WebP sources are only supported by Photon OpenCV' ); } $prepended_code = "define( 'IMAGE_MAX_WIDTH', $max_width );"; $prepended_code .= "define( 'IMAGE_MAX_HEIGHT', $max_height );"; $data = $this->get_blogs_php_image( $image, array( 'w' => 100 ), array(), array(), 0, $prepended_code ); if ( $should_be_rejected) { $this->assertGracefulFailure( $data ); } else { $this->assertImageDimensions( $data, 100, 50 ); } } /** * Ensures images that aren't really images return the appropriate error message */ public function testInvalidImageGracefullyFails() { $data = $this->get_blogs_php_image( 'html.jpg', array( 'w' => 100 ) ); $this->assertGracefulFailure( $data ); } /** * Ensures broken images that can be parsed by browsers can also be parsed by Photon */ public function testBrokenImagesCanBeProcessed() { $data = $this->get_blogs_php_image( 'missingbytes.jpg', array( 'w' => 100 ) ); $this->assertImageDimensions( $data, 100, 75 ); } public function dataWebpIsConvertedToAppropriateFormat() { return array( array( 'vp8.webp', array( 'w' => 100 ), 'image/jpeg' ), array( 'vp8x.webp', array( 'w' => 100 ), 'image/jpeg' ), array( 'vp8l.webp', array( 'w' => 100 ), 'image/png' ), array( 'vp8xl.webp', array( 'w' => 100 ), 'image/png' ), ); } /** * Checks that lossless webps are converted to PNGs and lossy ones to JPG * when other formats are not supported * * @dataProvider dataWebpIsConvertedToAppropriateFormat */ public function testWebpIsConvertedToAppropriateFormat( $image, $params, $expected_mime) { $data = $this->get_blogs_php_image( $image, $params ); $finfo = finfo_open(); $mime = finfo_buffer( $finfo, $data, FILEINFO_MIME_TYPE ); $this->assertEquals( $expected_mime, $mime ); } public function dataAvifIsConvertedToAppropriateFormat() { return array( // Converted to webp if client supports it array( 'opaque_avif.avif', array( 'w' => 100 ), array( 'ACCEPT' => 'image/webp,image/avif' ), 'image/webp' ), array( 'transparent_avif.avif', array( 'w' => 100 ), array( 'ACCEPT' => 'image/webp' ), 'image/webp' ), // Converted to jpg if webp is not supported array( 'opaque_avif.avif', array( 'w' => 100 ), array(), 'image/jpeg' ), // Transparent images are converted to png if webp is not supported array( 'transparent_avif.avif', array( 'w' => 100 ), array(), 'image/png' ), ); } /** * @dataProvider dataAvifIsConvertedToAppropriateFormat */ public function testAvifIsConvertedToAppropriateFormat( $image, $params, $headers, $expected_mime ) { $data = $this->get_blogs_php_image( $image, $params, $headers ); $finfo = finfo_open(); $mime = finfo_buffer( $finfo, $data, FILEINFO_MIME_TYPE ); $this->assertEquals( $expected_mime, $mime ); } public function dataAvifAcceptHeaderIsRespected() { return array( array( array( 'ACCEPT' => 'image/avif' ), true ), array( array( 'ACCEPT' => 'image/webp,image/avif,image/something' ), true ), array( array( 'ACCEPT' => 'dimage/avif' ), false ), array( array( 'ACCEPT' => 'image/avife' ), false ), array( array( 'ACCEPT' => 'image/webp,image/something' ), false ), ); } /** * Checks that AVIF images are not sent when they are not supported * * @dataProvider dataAvifAcceptHeaderIsRespected */ public function testAvifAcceptHeaderIsRespected( $headers, $should_be_avif ) { $data = $this->get_blogs_php_image( 'foto777-titolo.jpg', array( 'w' => 100 ), $headers, array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );"); $magic_bytes = substr( $data, 4, 8 ); $avif_signature = 'ftypavif'; if ( $should_be_avif ) { $this->assertEquals( $avif_signature, $magic_bytes ); } else { $this->assertNotEquals( $avif_signature, $magic_bytes ); } } /** * Confirms that lossless avifs are generated when the input is a lossless * image */ public function testAvifFromLosslessSourceIsLossless() { // TODO: expand to test with webp as well $data = $this->get_blogs_php_image( 'fine.png', array( 'quality' => 0 ), array( 'ACCEPT' => 'image/avif' ), array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );"); $magic_bytes = substr( $data, 4, 8 ); $this->assertEquals( 'ftypavif', $magic_bytes ); $this->markTestIncomplete( 'Need to find a solid way of comparing the ' . 'AVIF image with the PNG. Photon Opencv doesn\'t support it ' . 'well enough for now' ); } /** * Confirms avif quality can be controlled */ public function testAvifObservesQuality() { $data = $this->get_blogs_php_image( 'foto777-titolo.jpg' ); $original = imagecreatefromstring( $data ); $data = $this->get_blogs_php_image( 'foto777-titolo.jpg', array( 'quality' => 20 ), array( 'ACCEPT' => 'image/avif' ), array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );"); $img = new Photon_OpenCV(); $img->readimageblob( $data ); $img->setimageformat( 'jpeg' ); $bad = imagecreatefromstring( $img->getimageblob() ); $data = $this->get_blogs_php_image( 'foto777-titolo.jpg', array( 'quality' => 80 ), array( 'ACCEPT' => 'image/avif' ), array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );"); $img = new Photon_OpenCV(); $img->readimageblob( $data ); $img->setimageformat( 'jpeg' ); $good = imagecreatefromstring( $img->getimageblob() ); $good_error = $this->getSumOfSquarePixelDistances( $original, $good ); $bad_error = $this->getSumOfSquarePixelDistances( $original, $bad ); $this->assertLessThan( $bad_error, $good_error ); } /** * Confirms that images are not converted to lossy if `quality` or `allow_lossy` is not specified */ public function testLosslessImageIsReturnedWhenQualityOrAllowLossyIsNotSpecified() { $processed_png = $this->get_blogs_php_image( 'same-pixels.png', array( 'w' => 1 ) ); $this->assertImageFormat( $processed_png, 'png' ); } public function dataLossyImageIsReturned() { return array( array( 'fine.png', 'image/webp', 'webp' ), // Png should become lossless webp if webp is requested array( 'long-strip.png', '', 'jpeg' ), // Non-transparent png should become jpeg if 'image/webp' header is not provided array( 'vp8l.webp', 'image/webp', 'webp' ), // lossless webp becomes lossy webp array( 'vp8l.webp', '', 'jpeg' ), // lossless webp becomes jpeg if 'image/web' header is not provided array( 'vp8.webp', '', 'jpeg' ), // lossy webp becomes jpeg when other formats are not supported array( 'vp8xl.webp', '', 'jpeg' ), // lossless webp becomes jpeg when other formats are not supported array( 'fine.png', 'image/avif', 'avif' ), // png can be converted to lossy avif array( 'vp8l.webp', 'image/avif', 'avif' ), // Lossless webp can be converted to lossy avif array( 'vp8.webp', 'image/avif', 'avif' ), // Lossy webp can be converted to lossy avif array( 'foto777-titolo.jpg', 'image/avif', 'avif' ), // Lossy webp can be converted to lossy avif // The following tests are currently disabled because conversion to webp fails with error: `imagecreatefromstring(): gd-webp cannot allocate temporary buffer` // array('tiny-frame.gif', 'image/webp', 'webp'), // Gif can be converted to lossy webp // array('anim.webp', 'image/webp', 'webp'), // animated webp can be made lossy // array('anim.webp', '', 'gif'), // animated webp becomes gif ); } /** * Confirms that if quality is specified, lossy conversion is used. * So, lower `quality` value will result in an image that is more different from the original. * * @dataProvider dataLossyImageIsReturned */ public function testLossyImageIsReturnedWhenQualityIsSpecified( $image, $accept_header, $expected_format ) { $original_data = file_get_contents( __DIR__ . '/data/' . $image ); $original_image = imagecreatefromstring( $original_data ); $quality_80_data = $this->get_blogs_php_image( $image, array( 'quality' => 80 ), array( 'ACCEPT' => $accept_header ), array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );" ); $this->assertImageFormat( $quality_80_data, $expected_format ); $quality_80_image = imagecreatefromstring( $quality_80_data ); $quality_80_error = $this->getSumOfSquarePixelDistances( $original_image, $quality_80_image ); $quality_50_data = $this->get_blogs_php_image( $image, array( 'quality' => 50 ), array( 'ACCEPT' => $accept_header ), array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );" ); $this->assertImageFormat( $quality_50_data, $expected_format ); $quality_50_image = imagecreatefromstring( $quality_50_data ); $quality_50_error = $this->getSumOfSquarePixelDistances( $original_image, $quality_50_image ); $this->assertLessThan( $quality_50_error, $quality_80_error ); // $quality_80_error < $quality_50_error; } /** * @dataProvider dataLossyImageIsReturned */ public function testAllowLossyConvertsToLossyFormats( $image, $accept_header, $expected_format ) { $image_with_allow_lossy = $this->get_blogs_php_image( $image, array( 'allow_lossy' => 1 ), array( 'ACCEPT' => $accept_header ), array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );" ); $this->assertImageFormat( $image_with_allow_lossy, $expected_format ); } // Transparent PNGs should always be lossless, even if quality is specified public function testTransparentPngStaysTransparentPngEvenWithQuality() { $data = $this->get_blogs_php_image( 'recebeu.png', array( 'quality' => 50 ) ); $this->assertImageFormat( $data, 'png' ); $image = imagecreatefromstring( $data ); $first_pixel = imagecolorat( $image, 0, 0 ); $first_pixel = imagecolorsforindex( $image, $first_pixel ); $this->assertGreaterThan( 0, $first_pixel['alpha'] ); $this->assertNotFalse( $image, 'Image should be a valid PNG' ); } // Transparent PNGs should be lossless if other formats are not supported, even if allow_lossy is set public function testTransparentPngStaysTransparentPngEvenWithAllowLossy() { $data = $this->get_blogs_php_image( 'recebeu.png', array( 'allow_lossy' => '1' ) ); $this->assertImageFormat( $data, 'png' ); $image = imagecreatefromstring( $data ); $first_pixel = imagecolorat( $image, 0, 0 ); $first_pixel = imagecolorsforindex( $image, $first_pixel ); $this->assertGreaterThan( 0, $first_pixel['alpha'] ); $this->assertNotFalse( $image, 'Image should be a valid PNG' ); } public function dataAvifIsTreatedAsAnOptimmization() { return array( array( false ), array( true ) ); } /** * Checks that AVIF images are not sent when they are not supported * * @dataProvider dataAvifIsTreatedAsAnOptimmization() { */ public function testAvifIsTreatedAsAnOptimmization( $optimizations_disabled ) { $defines = "define( 'AVIF_ENCODING_ENABLED', true );"; if ( $optimizations_disabled ) { $defines .= "define( 'DISABLE_IMAGE_OPTIMIZATIONS', true );"; } $data = $this->get_blogs_php_image( 'foto777-titolo.jpg', array( 'w' => 100 ), array( 'ACCEPT' => 'image/avif' ), array(), 0, $defines ); $magic_bytes = substr( $data, 4, 8 ); $avif_signature = 'ftypavif'; if ( $optimizations_disabled ) { $this->assertNotEquals( $avif_signature, $magic_bytes ); } else { $this->assertEquals( $avif_signature, $magic_bytes ); } } /** * Ensure converted images have the right content type headers */ public function testContentTypeMatchesContent() { $this->markTestIncomplete( 'Device for retriving headers does not currently exist' ); // TODO: simulate a request that converts an image into each supported type, match header to content } public function testRotationIsNotLostForAvifWithStrip() { $this->markTestIncomplete( 'Ensure AVIF images can have their metadata stripped without affecting the final image. See equivalent webp test' ); } /** * Ensure avif rotation is always hardcoded, otherwise browsers don't support it */ public function testRotationIsStrippedForAvifWithoutStrip() { $this->markTestIncomplete( 'Ensure AVIF images always have their metadata stripped. See equivalent webp test' ); } /** * Trying to encode a webp image bigger than what the specs allow should not fail with errors */ public function testTooBigWebPFallsbackToTraditionalFormat() { $data = $this->get_blogs_php_image( 'long-strip.png', array( 'w' => '10' ), array( 'ACCEPT' => 'image/webp' ) ); $this->assertImageFormat( $data, 'png' ); $this->assertImageDimensions( $data, 10, 17000 ); } public function dataAnimatedImageRequestsRespectClientCapabilities() { return array( array( 'anim.webp', 'image/webp', 'webp' ), array( 'anim.webp', '', 'gif' ), array( 'anim.gif', 'image/webp', 'webp' ), array( 'anim.gif', '', 'gif' ), ); } /** * Animated gifs should be promoted to WebP when possible. Animated WebP should be converted to gifs when necessary * * @dataProvider dataAnimatedImageRequestsRespectClientCapabilities */ public function testAnimatedImageRequestsRespectClientCapabilities( $image, $accept_header, $expected_format ) { $data = $this->get_blogs_php_image( $image, array( 'w' => '64' ), array( 'ACCEPT' => $accept_header ), ); $this->assertImageFormat( $data, $expected_format ); } /** * Animation incompatible with WebP should be returned as gif */ public function testAnimationIncompatibleWithWebPFallsBackToGif() { $data = $this->get_blogs_php_image( 'long-anim.gif', array( 'w' => '16500' ), array( 'ACCEPT' => 'image/webp' ), ); $this->assertImageFormat( $data, 'gif' ); } /** * Ensure lossy transparent images are not losing their alpha when fallback formats are necessary */ public function testLossyTransparentWebpFallsbackToPNG() { $data = $this->get_blogs_php_image( 'lossy-transp.webp', array( 'w' => '200' ), array( 'allow_lossy' => 1 ) ); $this->assertImageFormat( $data, 'png' ); } /** * Tests that the resize and zoom conbination won't fatal for bad values */ public function testInvalidZoomAndResizeDoesntFatal() { // Implicit assertion in method $data = $this->get_blogs_php_image( 'foto777-titolo.jpg', array( 'resize' => 'onehundred,notanumber', 'zoom' => '2' )); } public function dataInvertedAxisAreRespected() { return array( array( 'inverted-axis.jpg', array( 'w' => '100' ), array( 100, 133 ) ), array( 'inverted-axis.jpg', array( 'h' => '100' ), array( 75, 100 ) ), array( 'inverted-axis.jpg', array( 'resize' => '100,50' ), array( 100, 50 ) ), array( 'inverted-axis.jpg', array( 'fit' => '100,80' ), array( 60, 80 ) ), array( 'inverted-axis.jpg', array( 'lb' => '100,200' ), array( 100, 200 ) ), array( 'inverted-axis.jpg', array( 'crop' => '10,20,32px,48px' ), array( 32, 48 ) ), array( 'inverted-axis.jpg', array( 'crop' => '1', 'w' => '32', 'h' => '48' ), array( 32, 48 ) ), array( 'inverted-axis-with-border.jpg', array( 'ulb' => 'true' ), array( 100, 133 ) ), ); } /** * Ensures changes to width and height are properly mapped for images * with defined exif orientation * @dataProvider dataInvertedAxisAreRespected */ public function testInvertedAxisAreRespected( $image, $params, $expected_size ) { // Strip ensures dimensions match browser rendering $params = array_merge( $params, array( 'strip' => 'all' ) ); $data = $this->get_blogs_php_image( $image, $params ); $this->assertImageDimensions( $data, $expected_size[0], $expected_size[1] ); } public function dataCropRespectsOrientation() { // Exif is not stripped, sizes should look inverted return array( array( 'crop-test-exif-1.jpg' ), array( 'crop-test-exif-2.jpg' ), array( 'crop-test-exif-3.jpg' ), array( 'crop-test-exif-4.jpg' ), array( 'crop-test-exif-5.jpg' ), array( 'crop-test-exif-6.jpg' ), array( 'crop-test-exif-7.jpg' ), array( 'crop-test-exif-8.jpg' ), ); } /** * Ensures crops take into account orientation when calculating the * location of the new region * @dataProvider dataCropRespectsOrientation */ public function testCropRespectsOrientation( $image ) { // Cropped area should be 100% white, rest of the image is black $all_white = imagecreatetruecolor( 16, 16 ); $white_color = imagecolorallocate( $all_white, 255, 255, 255 ); imagefilledrectangle( $all_white, 0, 0, 16, 16, $white_color ); $data = $this->get_blogs_php_image( $image, array( 'crop' => '32px,16px,8px,16px' ) ); $cropped = imagecreatefromstring( $data ); $error = $this->getSumOfSquarePixelDistances( $all_white, $cropped, true ); // Low threshold to account for possible jpeg degradation $this->assertLessThan( 16, $error ); } /** * Ensures unletterboxing doesn't fail if there have been any previous * operations, and that it doesn't ignore exif orientation */ public function testULBRespectsPreviousOperationsAndExif() { $data = $this->get_blogs_php_image( 'inverted-axis-with-border.jpg', array( 'w' => 64, 'ulb' => 'true', 'strip' => 'all' ) ); $this->assertImageDimensions( $data, 64, 86 ); } /** * Ensures filters don't fail if there have been any previous * operations, and that they don't ignore exif orientation */ public function testFilterRespectsPreviousOperationsAndExif() { $data = $this->get_blogs_php_image( 'inverted-axis-with-border.jpg', array( 'w' => 64, 'filter' => 'negate', 'strip' => 'all' ) ); $this->assertImageDimensions( $data, 64, 128 ); // Border should have turned white $image = imagecreatefromstring( $data ); $first_pixel = imagecolorat( $image, 0, 0 ); $first_pixel = imagecolorsforindex( $image, $first_pixel ); $intensity = ( $first_pixel['red'] + $first_pixel['green'] + $first_pixel['blue'] ) / 3; $this->assertGreaterThanOrEqual( 250, $intensity ); } /** * Ensures cropping works with gifs */ public function testCropWorksWithGifs() { $data = $this->get_blogs_php_image( 'small.gif', array( 'crop' => '25,25,50,50' ) ); $this->assertImageDimensions( $data, 64, 64 ); } /** * Ensures gifs with width/height of zero are processed */ public function testGifWithInvalidSizeIsProcessed() { $data = $this->get_blogs_php_image( 'w0h0.gif', array( 'w' => '32' ) ); $this->assertImageDimensions( $data, 32, 32 ); } /** * Ensures operations that create empty frames in WebPs are handled gracefully */ public function testGifToWebPWithEmptyFrameIsEncodedCorrectly() { $data = $this->get_blogs_php_image( 'tiny-frame.gif', array( 'w' => '5' ), array( 'ACCEPT' => 'image/webp' ), ); $this->assertImageFormat( $data, 'webp' ); } public function dataStripIsRespected() { $params_opts = array( array( 'w' => '8' ), array( 'strip' => 'all', 'w' => '8' ), array( 'strip' => 'none', 'w' => '8' ), array( 'strip' => 'info', 'w' => '8' ), array( 'strip' => 'color', 'w' => '8' ), array( 'strip' => 'invalid', 'w' => '8' ), ); $headers_opts = array( array(), array( 'ACCEPT' => 'image/webp' ), array( 'ACCEPT' => 'image/avif' ), ); $cases = array(); foreach ( $params_opts as $params ) { foreach ( $headers_opts as $headers ) { $case = array( $params, $headers, isset( $params['strip'] ) && 'none' === $params['strip'], ); $cases[] = $case; } } return $cases; } /** * Ensures the strip parameter is respected across different scenarios and conversions * @dataProvider dataStripIsRespected */ public function testStripIsRespected( $params, $headers, $exif_should_be_present ) { $expected_string = 'METADATA IS PRESENT'; if ( isset( $headers['ACCEPT'] ) && 'image/avif' === $headers['ACCEPT'] ) { $this->markTestSkipped( 'Photon_OpenCV can\'t add metadata to avif files' ); } $data = $this->get_blogs_php_image( 'metadata.jpg', $params, $headers, array(), 0, "define( 'AVIF_ENCODING_ENABLED', true );" ); if ( $exif_should_be_present ) { $this->assertStringContainsString( $expected_string, $data ); } else { $this->assertStringNotContainsString( $expected_string, $data ); } } public function dataDigitalSourceTypeSurvivesStrip() { return array( array( 'ai-generated.jpg', array() ), array( 'ai-generated.jpg', array( 'ACCEPT' => 'image/webp' ) ), array( 'ai-generated.png', array() ), array( 'ai-generated.png', array( 'ACCEPT' => 'image/webp' ) ), ); } /** * Ensures the IPTC digitalSourceType marker, which flags an image as AI generated, * is retained when the rest of the metadata is stripped. The fixtures also carry an * unrelated XMP property, which must not survive. * * @dataProvider dataDigitalSourceTypeSurvivesStrip */ public function testDigitalSourceTypeSurvivesStrip( $image, $headers ) { $digital_source_type = 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia'; $strippable_xmp = 'XMP THAT SHOULD BE STRIPPED'; $data = $this->get_blogs_php_image( $image, array( 'w' => '8', 'strip' => 'all' ), $headers ); $this->assertStringContainsString( $digital_source_type, $data ); $this->assertStringNotContainsString( $strippable_xmp, $data ); } public function dataC2paImageIsResignedWithActions() { return array( 'jpeg' => array( 'c2pa-test-input.jpg' ), 'png' => array( 'c2pa-test-input.png' ), 'webp' => array( 'c2pa-test-input.webp' ), 'gif' => array( 'c2pa-test-input.gif' ), 'avif' => array( 'c2pa-test-input.avif' ), 'webp output' => array( 'c2pa-test-input.jpg', array( 'ACCEPT' => 'image/webp' ) ), 'avif output' => array( 'c2pa-test-input.jpg', array( 'ACCEPT' => 'image/avif' ), 'define( "AVIF_ENCODING_ENABLED", true );' ), ); } /** * An input carrying a c2pa manifest must be re-signed after processing, * with the applied transformations recorded as actions. * * @dataProvider dataC2paImageIsResignedWithActions */ public function testC2paImageIsResignedWithActions( $image, $headers = array(), $prepended_code = '' ) { $this->skip_unless_c2patool_available(); $data = $this->get_blogs_php_image( $image, array( 'w' => '32' ), $headers, array(), 0, $prepended_code ); $manifest_store = $this->read_manifest( $data ); $this->assertNotNull( $manifest_store ); $manifest = $manifest_store['manifests'][ $manifest_store['active_manifest'] ]; $this->assertEquals( 'Photon', $manifest['claim_generator_info'][0]['name'] ); $actions = array(); foreach ( $manifest['assertions'] as $assertion ) { if ( 0 === strpos( $assertion['label'], 'c2pa.actions' ) ) { $actions = array_column( $assertion['data']['actions'], 'action' ); } } $this->assertContains( 'c2pa.resized.proportional', $actions ); $this->assertContains( 'c2pa.transcoded', $actions ); $relationships = array_column( $manifest['ingredients'] ?? array(), 'relationship' ); $this->assertContains( 'parentOf', $relationships ); } /** * Re-signed manifests must not embed thumbnails of their own. An ingredient * may still reference a thumbnail already present in the parent manifest. */ public function testC2paManifestHasNoThumbnails() { $this->skip_unless_c2patool_available(); $data = $this->get_blogs_php_image( 'c2pa-test-input.jpg', array( 'w' => '32' ) ); $manifest_store = $this->read_manifest( $data ); $this->assertNotNull( $manifest_store ); $active = $manifest_store['active_manifest']; $manifest = $manifest_store['manifests'][ $active ]; $this->assertArrayNotHasKey( 'thumbnail', $manifest ); foreach ( $manifest['ingredients'] ?? array() as $ingredient ) { $this->assertStringNotContainsString( $active, $ingredient['thumbnail']['identifier'] ?? '' ); } } public function dataNonC2paImageIsNotSigned() { return array( 'jpeg' => array( 'ai-generated.jpg' ), 'png' => array( 'ai-generated.png' ), 'webp' => array( 'c2paless.webp' ), 'gif' => array( 'anim.gif' ), 'avif' => array( 'c2paless.avif' ), ); } /** * An input without a c2pa manifest must not gain one during processing. * * @dataProvider dataNonC2paImageIsNotSigned */ public function testNonC2paImageIsNotSigned( $image ) { $this->skip_unless_c2patool_available(); $data = $this->get_blogs_php_image( $image, array( 'w' => '32' ) ); $this->assertNull( $this->read_manifest( $data ) ); } public function dataC2paOutputConformsToRubric() { $operations = array( 'resized.proportional' => array( array( 'w' => '32' ), array( 'c2pa.opened' => false, 'c2pa.resized.proportional' => false, 'c2pa.transcoded' => false ), ), 'cropped' => array( array( 'crop' => '10,10,50,50' ), array( 'c2pa.opened' => false, 'c2pa.cropped' => true, 'c2pa.transcoded' => false ), ), 'resized' => array( array( 'resize' => '32,32' ), array( 'c2pa.opened' => false, 'c2pa.resized' => true, 'c2pa.transcoded' => false ), ), 'adjustedColor' => array( array( 'brightness' => '20' ), array( 'c2pa.opened' => false, 'c2pa.adjustedColor' => true, 'c2pa.transcoded' => false ), ), 'filtered' => array( array( 'filter' => 'grayscale' ), array( 'c2pa.opened' => false, 'c2pa.filtered' => true, 'c2pa.transcoded' => false ), ), 'transcoded only' => array( array( 'quality' => '50' ), array( 'c2pa.opened' => false, 'c2pa.transcoded' => false ), ), 'combined' => array( array( 'crop' => '10,10,50,50', 'w' => '16', 'brightness' => '20' ), array( 'c2pa.opened' => false, 'c2pa.cropped' => true, 'c2pa.resized.proportional' => false, 'c2pa.adjustedColor' => true, 'c2pa.transcoded' => false, ), ), ); $cases = array(); foreach ( $operations as $op_name => list( $params, $expected_actions ) ) { $cases[ $op_name ] = array( 'c2pa-test-input.jpg', $params, $expected_actions ); } return $cases; } /** * Limited checks to ensure generated manifests adhere to C2PA spec 2.4 rubric * * @dataProvider dataC2paOutputConformsToRubric */ public function testC2paOutputConformsToRubric( $image, $params, $expected_actions ) { $this->skip_unless_c2patool_available(); $data = $this->get_blogs_php_image( $image, $params ); $crjson = $this->read_crjson( $data ); $this->assertNotNull( $crjson, 'Output carries no C2PA manifest' ); $manifest = $crjson['manifests'][0]; $claim = $manifest['claim.v2']; $this->assertNotEmpty( $claim['claim_generator_info']['specVersion'] ?? null ); $created_urls = array_column( $claim['created_assertions'] ?? array(), 'url' ); $this->assertContains( 'self#jumbf=c2pa.assertions/c2pa.actions.v2', $created_urls ); $actions = $manifest['assertions']['c2pa.actions.v2'] ?? array(); // Opened first $this->assertSame( 'c2pa.opened', $actions['actions'][0]['action'] ?? null ); $this->assertTrue( $actions['allActionsIncluded'] ?? null ); // Ensure digitalSourceType is present when expected $has_digital_source = array(); foreach ( $actions['actions'] as $action ) { $has_digital_source[ $action['action'] ] = isset( $action['digitalSourceType'] ); } $this->assertSame( $expected_actions, $has_digital_source ); } }