<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[53027] trunk: Media: Preserve attachment properties on cropping custom logo.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/53027">53027</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/53027","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>joedolson</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2022-03-29 21:46:09 +0000 (Tue, 29 Mar 2022)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>Media: Preserve attachment properties on cropping custom logo.

Migrate the alternative text, title, description, and caption of an image over to the cropped copy of the image after cropping. Ensure that characteristics added to an image prior to cropping are not lost.

Props flixos90, Clorith, afercia, antonvlasenko, ironprogrammer, hellofromTonya.
Fixes <a href="https://core.trac.wordpress.org/ticket/37750">#37750</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpadminincludesajaxactionsphp">trunk/src/wp-admin/includes/ajax-actions.php</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunktestsphpunittestsajaxwpAjaxCropImagephp">trunk/tests/phpunit/tests/ajax/wpAjaxCropImage.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpadminincludesajaxactionsphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-admin/includes/ajax-actions.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-admin/includes/ajax-actions.php      2022-03-29 20:16:36 UTC (rev 53026)
+++ trunk/src/wp-admin/includes/ajax-actions.php        2022-03-29 21:46:09 UTC (rev 53027)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -3970,20 +3970,47 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        /** This filter is documented in wp-admin/includes/class-custom-image-header.php */
</span><span class="cx" style="display: block; padding: 0 10px">                        $cropped = apply_filters( 'wp_create_file_in_uploads', $cropped, $attachment_id ); // For replication.
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        $parent_url = wp_get_attachment_url( $attachment_id );
-                       $url        = str_replace( wp_basename( $parent_url ), wp_basename( $cropped ), $parent_url );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 $parent_url      = wp_get_attachment_url( $attachment_id );
+                       $parent_basename = wp_basename( $parent_url );
+                       $url             = str_replace( $parent_basename, wp_basename( $cropped ), $parent_url );
</ins><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                        $size       = wp_getimagesize( $cropped );
</span><span class="cx" style="display: block; padding: 0 10px">                        $image_type = ( $size ) ? $size['mime'] : 'image/jpeg';
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                        // Get the original image's post to pre-populate the cropped image.
+                       $original_attachment      = get_post( $attachment_id );
+                       $sanitized_post_title     = sanitize_file_name( $original_attachment->post_title );
+                       $use_original_title       = (
+                               ( '' !== trim( $original_attachment->post_title ) ) &&
+                               /*
+                                * Check if the original image has a title other than the "filename" default,
+                                * meaning the image had a title when originally uploaded or its title was edited.
+                                */
+                               ( $parent_basename !== $sanitized_post_title ) &&
+                               ( pathinfo( $parent_basename, PATHINFO_FILENAME ) !== $sanitized_post_title )
+                       );
+                       $use_original_description = ( '' !== trim( $original_attachment->post_content ) );
+
</ins><span class="cx" style="display: block; padding: 0 10px">                         $object = array(
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                                'post_title'     => wp_basename( $cropped ),
-                               'post_content'   => $url,
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                         'post_title'     => $use_original_title ? $original_attachment->post_title : wp_basename( $cropped ),
+                               'post_content'   => $use_original_description ? $original_attachment->post_content : $url,
</ins><span class="cx" style="display: block; padding: 0 10px">                                 'post_mime_type' => $image_type,
</span><span class="cx" style="display: block; padding: 0 10px">                                'guid'           => $url,
</span><span class="cx" style="display: block; padding: 0 10px">                                'context'        => $context,
</span><span class="cx" style="display: block; padding: 0 10px">                        );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                        // Copy the image caption attribute (post_excerpt field) from the original image.
+                       if ( '' !== trim( $original_attachment->post_excerpt ) ) {
+                               $object['post_excerpt'] = $original_attachment->post_excerpt;
+                       }
+
+                       // Copy the image alt text attribute from the original image.
+                       if ( '' !== trim( $original_attachment->_wp_attachment_image_alt ) ) {
+                               $object['meta_input'] = array(
+                                       '_wp_attachment_image_alt' => wp_slash( $original_attachment->_wp_attachment_image_alt ),
+                               );
+                       }
+
</ins><span class="cx" style="display: block; padding: 0 10px">                         $attachment_id = wp_insert_attachment( $object, $cropped );
</span><span class="cx" style="display: block; padding: 0 10px">                        $metadata      = wp_generate_attachment_metadata( $attachment_id, $cropped );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span></span></pre></div>
<a id="trunktestsphpunittestsajaxwpAjaxCropImagephp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/ajax/wpAjaxCropImage.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/ajax/wpAjaxCropImage.php                                (rev 0)
+++ trunk/tests/phpunit/tests/ajax/wpAjaxCropImage.php  2022-03-29 21:46:09 UTC (rev 53027)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,222 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+
+/**
+ * Admin Ajax functions to be tested.
+ */
+require_once ABSPATH . 'wp-admin/includes/ajax-actions.php';
+require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
+require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
+
+/**
+ * Class for testing ajax crop image functionality.
+ *
+ * @group ajax
+ * @covers ::wp_ajax_crop_image
+ */
+class Tests_Ajax_WpAjaxCropImage extends WP_Ajax_UnitTestCase {
+
+       /**
+        * @var WP_Post|null
+        */
+       private $attachment;
+
+       /**
+        * @var WP_Post|null
+        */
+       private $cropped_attachment;
+
+       public function set_up() {
+               parent::set_up();
+
+               // Become an administrator.
+               $this->_setRole( 'administrator' );
+       }
+
+       public function tear_down() {
+               if ( $this->attachment instanceof WP_Post ) {
+                       wp_delete_attachment( $this->attachment->ID, true );
+               }
+
+               if ( $this->cropped_attachment instanceof WP_Post ) {
+                       wp_delete_attachment( $this->cropped_attachment->ID, true );
+               }
+               $this->attachment         = null;
+               $this->cropped_attachment = null;
+
+               parent::tear_down();
+       }
+
+       /**
+        * Tests that attachment properties are copied over to the cropped image.
+        *
+        * @ticket 37750
+        */
+       public function test_it_copies_metadata_from_original_image() {
+               $this->attachment = $this->make_attachment( true );
+               $this->prepare_post( $this->attachment );
+
+               // Make the request.
+               try {
+                       $this->_handleAjax( 'crop-image' );
+               } catch ( WPAjaxDieContinueException $e ) {
+               }
+
+               $response = json_decode( $this->_last_response, true );
+               $this->validate_response( $response );
+
+               $this->cropped_attachment = get_post( $response['data']['id'] );
+               $this->assertInstanceOf( WP_Post::class, $this->cropped_attachment, 'get_post function must return an instance of WP_Post class' );
+               $this->assertNotEmpty( $this->attachment->post_title, 'post_title value must not be empty for testing purposes' );
+               $this->assertNotEmpty( $this->cropped_attachment->post_title, 'post_title value must not be empty for testing purposes' );
+               $this->assertSame( $this->attachment->post_title, $this->cropped_attachment->post_title, 'post_title value should be copied over to the cropped attachment' );
+               $this->assertSame( $this->attachment->post_content, $this->cropped_attachment->post_content, 'post_content value should be copied over to the cropped attachment' );
+               $this->assertSame( $this->attachment->post_excerpt, $this->cropped_attachment->post_excerpt, 'post_excerpt value should be copied over to the cropped attachment' );
+               $this->assertSame( $this->attachment->_wp_attachment_image_alt, $this->cropped_attachment->_wp_attachment_image_alt, '_wp_attachment_image_alt value should be copied over to the cropped attachment' );
+       }
+
+       /**
+        * Tests that post_title gets populated if it wasn't modified.
+        *
+        * @ticket 37750
+        */
+       public function test_it_populates_title_if_title_was_not_modified() {
+
+               $this->attachment = $this->make_attachment( true );
+               $filename         = $this->get_attachment_filename( $this->attachment );
+               $this->attachment = get_post(
+                       wp_update_post(
+                               array(
+                                       'ID'         => $this->attachment->ID,
+                                       'post_title' => $filename,
+                               )
+                       )
+               );
+
+               $this->prepare_post( $this->attachment );
+
+               // Make the request.
+               try {
+                       $this->_handleAjax( 'crop-image' );
+               } catch ( WPAjaxDieContinueException $e ) {
+               }
+
+               $response = json_decode( $this->_last_response, true );
+               $this->validate_response( $response );
+
+               $this->cropped_attachment = get_post( $response['data']['id'] );
+               $this->assertInstanceOf( WP_Post::class, $this->cropped_attachment, 'get_post function must return an instance of WP_Post class' );
+               $this->assertStringStartsWith( 'cropped-', $this->cropped_attachment->post_title, 'post_title attribute should start with "cropped-" prefix, i.e. it has to be populated' );
+       }
+
+       /**
+        * Tests that attachment properties get populated if they are not defined (but specific logic depends on the actual property).
+        *
+        * @ticket 37750
+        */
+       public function test_it_doesnt_generate_new_metadata_if_metadata_is_empty() {
+               $this->attachment = $this->make_attachment( false );
+               $this->prepare_post( $this->attachment );
+
+               // Make the request.
+               try {
+                       $this->_handleAjax( 'crop-image' );
+               } catch ( WPAjaxDieContinueException $e ) {
+               }
+
+               $response = json_decode( $this->_last_response, true );
+               $this->validate_response( $response );
+
+               $this->cropped_attachment = get_post( $response['data']['id'] );
+               $this->assertInstanceOf( WP_Post::class, $this->cropped_attachment, 'get_post function must return an instance of WP_Post class' );
+               $this->assertEmpty( $this->attachment->post_title, 'post_title value must be empty for testing purposes' );
+               $this->assertNotEmpty( $this->cropped_attachment->post_title, 'post_title value must be auto-generated if it\'s empty in the original attachment' );
+               $this->assertSame( $this->get_attachment_filename( $this->cropped_attachment ), $this->cropped_attachment->post_title, 'post_title attribute should contain filename of the cropped image' );
+               $this->assertStringStartsWith( 'cropped-', $this->cropped_attachment->post_title, 'post_title attribute should start with "cropped-" prefix, i.e. it has to be populated' );
+               $this->assertStringStartsWith( 'http', $this->cropped_attachment->post_content, 'post_content value should contain an URL if it\'s empty in the original attachment' );
+               $this->assertEmpty( $this->cropped_attachment->post_excerpt, 'post_excerpt value must be empty if it\'s empty in the original attachment' );
+               $this->assertEmpty( $this->cropped_attachment->_wp_attachment_image_alt, '_wp_attachment_image_alt value must be empty if it\'s empty in the original attachment' );
+       }
+
+       /**
+        * Creates an attachment.
+        *
+        * @return WP_Post
+        */
+       private function make_attachment( $with_metadata = true ) {
+               $uniq_id = uniqid( 'crop-image-ajax-action-test-' );
+
+               $test_file        = DIR_TESTDATA . '/images/test-image.jpg';
+               $upload_directory = wp_upload_dir();
+               $uploaded_file    = $upload_directory['path'] . '/' . $uniq_id . '.jpg';
+               $filesystem       = new WP_Filesystem_Direct( true );
+               $filesystem->copy( $test_file, $uploaded_file );
+
+               $attachment_data = array(
+                       'file' => $uploaded_file,
+                       'type' => 'image/jpg',
+                       'url'  => 'http://localhost/foo.jpg',
+               );
+
+               $attachment_id = $this->_make_attachment( $attachment_data );
+               $post_data     = array(
+                       'ID'           => $attachment_id,
+                       'post_title'   => $with_metadata ? 'Title ' . $uniq_id : '',
+                       'post_content' => $with_metadata ? 'Description ' . $uniq_id : '',
+                       'context'      => 'custom-logo',
+                       'post_excerpt' => $with_metadata ? 'Caption ' . $uniq_id : '',
+               );
+
+               // Update the post because _make_attachment method doesn't support these arguments.
+               wp_update_post( $post_data );
+
+               if ( $with_metadata ) {
+                       update_post_meta( $attachment_id, '_wp_attachment_image_alt', wp_slash( 'Alt ' . $uniq_id ) );
+               }
+
+               return get_post( $attachment_id );
+       }
+
+       /**
+        * @param array $response Response to validate.
+        */
+       private function validate_response( $response ) {
+               $this->assertArrayHasKey( 'success', $response, 'Response array must contain "success" key.' );
+               $this->assertArrayHasKey( 'data', $response, 'Response array must contain "data" key.' );
+               $this->assertNotEmpty( $response['data']['id'], 'Response array must contain "ID" value of the post entity.' );
+       }
+
+       /**
+        * Prepares $_POST for crop-image ajax action.
+        *
+        * @param WP_Post $attachment
+        */
+       private function prepare_post( WP_Post $attachment ) {
+               $_POST = array(
+                       'wp_customize' => 'on',
+                       'nonce'        => wp_create_nonce( 'image_editor-' . $attachment->ID ),
+                       'id'           => $attachment->ID,
+                       'context'      => 'custom_logo',
+                       'cropDetails'  =>
+                               array(
+                                       'x1'         => '0',
+                                       'y1'         => '0',
+                                       'x2'         => '100',
+                                       'y2'         => '100',
+                                       'width'      => '100',
+                                       'height'     => '100',
+                                       'dst_width'  => '100',
+                                       'dst_height' => '100',
+                               ),
+                       'action'       => 'crop-image',
+               );
+       }
+
+       /**
+        * @param WP_Post $attachment
+        *
+        * @return string
+        */
+       private function get_attachment_filename( WP_Post $attachment ) {
+               return wp_basename( wp_get_attachment_url( $attachment->ID ) );
+       }
+}
</ins></span></pre>
</div>
</div>

</body>
</html>