diff --git a/php/class-delivery.php b/php/class-delivery.php
index a4eaa7452..f2ec5d184 100644
--- a/php/class-delivery.php
+++ b/php/class-delivery.php
@@ -1583,9 +1583,10 @@ public function parse_element( $element, $content = '' ) {
// Track back the found URL.
if ( $this->media->is_cloudinary_url( $raw_url ) ) {
$public_id = $this->media->get_public_id_from_url( $raw_url );
- if ( isset( $this->known[ $public_id ] ) ) {
-
- $url = $this->known[ $public_id ]['sized_url'];
+ // A URL with transformations of its own (e.g. typed into content by hand) is left as is, since rebuilding it would drop them.
+ if ( isset( $this->known[ $public_id ] ) && ! $this->has_custom_transformations( $raw_url, $this->known[ $public_id ]['post_id'] ) ) {
+ // The relation's sized_url is relative to the content dir, while the URL keys of $this->known are full URLs.
+ $url = $this->get_searchable_url( $this->known[ $public_id ] );
$attributes['src'] = $url;
}
}
@@ -1837,25 +1838,54 @@ public function validate_url( $url ) {
}
/**
- * Set url usability.
+ * Check if a Cloudinary URL has transformations other than the ones the plugin applies to the attachment itself:
+ * its size, and the attachment's own and global transformations.
*
- * @param array $item The item result.
- * @param null|bool $auto_sync If auto_sync is on.
+ * @param string $url The Cloudinary URL.
+ * @param int $attachment_id The attachment ID.
+ *
+ * @return bool
*/
- protected function set_usability( $item, $auto_sync = null ) {
+ protected function has_custom_transformations( $url, $attachment_id ) {
+ $type = $this->media->get_resource_type( $attachment_id );
+ $expected = $this->media->get_transformations_from_string( (string) $this->media->cloudinary_url( $attachment_id ), $type );
+
+ foreach ( $this->media->get_transformations_from_string( $url, $type ) as $transformation ) {
+ // Loose comparison, so the order of the parameters doesn't matter.
+ if ( ! $this->is_size_transformation( $transformation ) && ! in_array( $transformation, $expected ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
+ return true;
+ }
+ }
- /**
- * Filter the found item to allow usability to be altered.
- *
- * @hook cloudinary_set_usable_asset
- * @since 3.0.2
- *
- * @param array $item The found asset.
- *
- * @return array
- */
- $item = apply_filters( 'cloudinary_set_usable_asset', $item );
+ return false;
+ }
+ /**
+ * Check if a transformation is one the plugin uses to size an image, e.g. `w_300,h_200,c_scale` or `w_150,h_150,c_thumb,g_auto`.
+ *
+ * @param array $transformation The transformation, as parsed by Media::get_transformations_from_string().
+ *
+ * @return bool
+ */
+ protected function is_size_transformation( $transformation ) {
+ if ( array_diff_key( $transformation, array_flip( array( 'width', 'height', 'crop', 'gravity' ) ) ) ) {
+ return false;
+ }
+ if ( isset( $transformation['crop'] ) && ! in_array( $transformation['crop'], array( 'scale', 'fill', 'thumb' ), true ) ) {
+ return false;
+ }
+
+ return ! isset( $transformation['gravity'] ) || 'auto' === $transformation['gravity'];
+ }
+
+ /**
+ * Get the URL an item is known by, as used for the keys of $this->known.
+ *
+ * @param array $item The item result.
+ *
+ * @return string
+ */
+ protected function get_searchable_url( $item ) {
/**
* The URL to be searched for and prepared to be delivered by Cloudinary.
*
@@ -1887,7 +1917,30 @@ protected function set_usability( $item, $auto_sync = null ) {
*
* @return string
*/
- $url = apply_filters( 'cloudinary_delivery_searchable_url', $url, $item, $content_url );
+ return apply_filters( 'cloudinary_delivery_searchable_url', $url, $item, $content_url );
+ }
+
+ /**
+ * Set url usability.
+ *
+ * @param array $item The item result.
+ * @param null|bool $auto_sync If auto_sync is on.
+ */
+ protected function set_usability( $item, $auto_sync = null ) {
+
+ /**
+ * Filter the found item to allow usability to be altered.
+ *
+ * @hook cloudinary_set_usable_asset
+ * @since 3.0.2
+ *
+ * @param array $item The found asset.
+ *
+ * @return array
+ */
+ $item = apply_filters( 'cloudinary_set_usable_asset', $item );
+
+ $url = $this->get_searchable_url( $item );
$found = array();
diff --git a/tests/phpunit/tests/test-delivery-parse-element.php b/tests/phpunit/tests/test-delivery-parse-element.php
new file mode 100644
index 000000000..492f6e2a1
--- /dev/null
+++ b/tests/phpunit/tests/test-delivery-parse-element.php
@@ -0,0 +1,220 @@
+attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
+ }
+
+ /**
+ * Build a Delivery instance using a media component pointed at the test Cloudinary domain.
+ *
+ * @return Test_Delivery_Parse_Element_Delivery
+ */
+ protected function get_delivery() {
+ $plugin = \Cloudinary\get_plugin_instance();
+ $media = new Test_Delivery_Parse_Element_Media( $plugin );
+ $media->base_url = self::BASE_URL;
+ $media->filter = new \Cloudinary\Media\Filter( $media );
+ $media->stub_cloudinary_url = self::BASE_URL . '/images/f_auto,q_auto/v1/canola/canola.jpg';
+
+ $delivery = new Test_Delivery_Parse_Element_Delivery( $plugin );
+ $delivery->set_media( $media );
+
+ return $delivery;
+ }
+
+ /**
+ * Seed the known items the same way Delivery::set_usability() keys them: by public ID, and
+ * by the full URL built from the content URL and the relation's content-relative sized_url.
+ *
+ * @param Test_Delivery_Parse_Element_Delivery $delivery The delivery instance.
+ *
+ * @return string The full URL the item is known by.
+ */
+ protected function seed_known( $delivery ) {
+ $file = get_post_meta( self::$attachment_id, '_wp_attached_file', true );
+ $item = array(
+ 'post_id' => self::$attachment_id,
+ 'public_id' => 'canola',
+ 'sized_url' => '/uploads/' . $file,
+ 'sync_type' => 'media',
+ 'format' => 'jpg',
+ 'width' => 640,
+ 'height' => 480,
+ 'post_state' => 'enable',
+ );
+ $url = \Cloudinary\Utils::clean_url( content_url() ) . $item['sized_url'];
+
+ $delivery->known['canola'] = $item;
+ $delivery->known[ $url ] = $item;
+
+ return $url;
+ }
+
+ /**
+ * An img tag with a Cloudinary src for a known public ID is tracked back to its attachment.
+ *
+ * @return void
+ */
+ public function test_cloudinary_src_is_tracked_back_to_its_attachment() {
+ $delivery = $this->get_delivery();
+ $url = $this->seed_known( $delivery );
+
+ $element = $delivery->parse_element( '
' );
+
+ $this->assertSame( self::$attachment_id, $element['id'] );
+ $this->assertSame( $url, $element['atts']['src'] );
+ $this->assertSame( 'canola.jpg', $element['atts']['data-public-id'] );
+ }
+
+ /**
+ * A Cloudinary src with the plugin's own thumbnail crop is still tracked back.
+ *
+ * @return void
+ */
+ public function test_cloudinary_src_with_thumbnail_crop_is_tracked_back() {
+ $delivery = $this->get_delivery();
+ $this->seed_known( $delivery );
+
+ $element = $delivery->parse_element( '
' );
+
+ $this->assertSame( self::$attachment_id, $element['id'] );
+ }
+
+ /**
+ * A Cloudinary src with transformations of its own, e.g. typed into content by hand, is left
+ * alone, since rebuilding it from the attachment would drop them.
+ *
+ * @dataProvider data_custom_transformations
+ *
+ * @param string $transformations The transformations in the src.
+ *
+ * @return void
+ */
+ public function test_cloudinary_src_with_custom_transformations_is_not_tracked_back( $transformations ) {
+ $delivery = $this->get_delivery();
+ $this->seed_known( $delivery );
+
+ $src = self::BASE_URL . '/images/' . $transformations . '/v1/canola/canola.jpg';
+ $element = $delivery->parse_element( '
' );
+
+ $this->assertSame( 0, $element['id'] );
+ $this->assertSame( $src, $element['atts']['src'] );
+ }
+
+ /**
+ * Transformations the plugin doesn't apply itself.
+ *
+ * @return array
+ */
+ public function data_custom_transformations() {
+ return array(
+ 'effect' => array( 'w_300,h_225,c_scale/e_sepia/f_auto,q_auto' ),
+ 'manual crop' => array( 'c_crop,w_400,h_400,x_100,y_100/e_grayscale' ),
+ 'gravity' => array( 'w_300,h_300,c_fill,g_face' ),
+ );
+ }
+
+ /**
+ * A Cloudinary src for a public ID Delivery doesn't know is left alone.
+ *
+ * @return void
+ */
+ public function test_unknown_cloudinary_src_is_not_tracked_back() {
+ $delivery = $this->get_delivery();
+ $this->seed_known( $delivery );
+
+ $src = self::BASE_URL . '/images/w_300,h_225,c_scale/v1/other/other.jpg';
+ $element = $delivery->parse_element( '
' );
+
+ $this->assertSame( 0, $element['id'] );
+ $this->assertSame( $src, $element['atts']['src'] );
+ }
+}
+
+/**
+ * Media with cloudinary_url() stubbed, to avoid the real sync/signature pipeline.
+ */
+class Test_Delivery_Parse_Element_Media extends \Cloudinary\Media {
+
+ /**
+ * Canned cloudinary_url() return value.
+ *
+ * @var string|false
+ */
+ public $stub_cloudinary_url = false;
+
+ /**
+ * Stubbed to avoid the real sync/signature pipeline.
+ *
+ * @param int $attachment_id The attachment ID.
+ * @param array|string $size The requested size.
+ * @param array $transformations Transformations to apply.
+ * @param string|null $cloudinary_id A forced Cloudinary ID.
+ * @param bool $overwrite_transformations Whether to overwrite transformations.
+ *
+ * @return string|false
+ */
+ public function cloudinary_url( $attachment_id, $size = array(), $transformations = array(), $cloudinary_id = null, $overwrite_transformations = false ) {
+ return $this->stub_cloudinary_url;
+ }
+}
+
+/**
+ * Delivery with a settable media component, since init() only wires the real one once connected.
+ */
+class Test_Delivery_Parse_Element_Delivery extends \Cloudinary\Delivery {
+
+ /**
+ * Set the media component.
+ *
+ * @param \Cloudinary\Media $media The media component.
+ *
+ * @return void
+ */
+ public function set_media( $media ) {
+ $this->media = $media;
+ }
+}