Skip to content

Commit 3b4a21c

Browse files
Add comprehensive EXIF metadata tests for client-side media upload
Expands test coverage for issue #74356 to ensure client-side media uploads preserve the same EXIF data as traditional server-side processing. New tests verify: - exif_orientation field is returned in REST API response - EXIF orientation is preserved when generate_sub_sizes=false - Full EXIF metadata (camera, aperture, ISO, focal length) is extracted - IPTC metadata (credit, caption, copyright, title) is extracted - Sideloading sub-sizes preserves original image_meta - Sideloaded sub-sizes have complete metadata (file, dimensions, mime, filesize) - exif_orientation is properly defined in REST API schema - Metadata consistency between server-side and client-side upload flows Also fixes a bug where EXIF orientation 0 (undefined/no EXIF data) was being returned instead of 1 (no rotation needed). The controller now treats orientation <= 0 as "no rotation needed". Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 8383b9c commit 3b4a21c

File tree

2 files changed

+327
-1
lines changed

2 files changed

+327
-1
lines changed

lib/experimental/media/class-gutenberg-rest-attachments-controller.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,15 @@ public function prepare_item_for_response( $item, $request ): WP_REST_Response {
152152

153153
// Get the EXIF orientation from the image metadata.
154154
// This is stored by wp_read_image_metadata() during upload.
155+
// Values:
156+
// 0 = undefined (no EXIF data), treat as no rotation needed
157+
// 1 = normal (no rotation needed)
158+
// 2-8 = various rotations/flips needed
155159
$orientation = 1; // Default: no rotation needed.
156160
if (
157161
is_array( $metadata ) &&
158-
isset( $metadata['image_meta']['orientation'] )
162+
isset( $metadata['image_meta']['orientation'] ) &&
163+
(int) $metadata['image_meta']['orientation'] > 0
159164
) {
160165
$orientation = (int) $metadata['image_meta']['orientation'];
161166
}

phpunit/experimental/media/class-gutenberg-rest-attachments-controller-test.php

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,325 @@ public function test_sideload_item_year_month_based_folders_page_post_type() {
311311
$this->assertStringNotContainsString( '2017/02', $data['source_url'] );
312312
$this->assertStringContainsString( $subdir, $data['source_url'] );
313313
}
314+
315+
/**
316+
* Verifies that exif_orientation field is returned in REST API response.
317+
*
318+
* @covers ::prepare_item_for_response
319+
* @covers ::get_item_schema
320+
*/
321+
public function test_exif_orientation_field_returned_in_response() {
322+
wp_set_current_user( self::$admin_id );
323+
324+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
325+
$request->set_header( 'Content-Type', 'image/jpeg' );
326+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
327+
$request->set_param( 'generate_sub_sizes', false );
328+
329+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) );
330+
$response = rest_get_server()->dispatch( $request );
331+
$data = $response->get_data();
332+
333+
$this->assertSame( 201, $response->get_status() );
334+
$this->assertArrayHasKey( 'exif_orientation', $data );
335+
// canola.jpg has no EXIF orientation, so it should default to 1.
336+
$this->assertSame( 1, $data['exif_orientation'] );
337+
}
338+
339+
/**
340+
* Verifies that exif_orientation field is returned for image with non-1 orientation.
341+
*
342+
* Uses test-image-upside-down.jpg which has EXIF orientation value 3 (180° rotation).
343+
*
344+
* @covers ::prepare_item_for_response
345+
* @covers ::create_item
346+
* @requires extension exif
347+
*/
348+
public function test_exif_orientation_returned_for_rotated_image() {
349+
wp_set_current_user( self::$admin_id );
350+
351+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
352+
$request->set_header( 'Content-Type', 'image/jpeg' );
353+
$request->set_header( 'Content-Disposition', 'attachment; filename=test-image-upside-down.jpg' );
354+
$request->set_param( 'generate_sub_sizes', false );
355+
356+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image-upside-down.jpg' ) );
357+
$response = rest_get_server()->dispatch( $request );
358+
$data = $response->get_data();
359+
360+
$this->assertSame( 201, $response->get_status() );
361+
$this->assertArrayHasKey( 'exif_orientation', $data );
362+
// test-image-upside-down.jpg has EXIF orientation 3 (180° rotation).
363+
$this->assertSame( 3, $data['exif_orientation'] );
364+
}
365+
366+
/**
367+
* Verifies that server-side EXIF rotation is disabled when generate_sub_sizes is false.
368+
*
369+
* When client-side processing is enabled (generate_sub_sizes=false), the server should
370+
* NOT rotate the image based on EXIF orientation. The original orientation value should
371+
* be preserved in metadata so the client can handle rotation.
372+
*
373+
* @covers ::create_item
374+
* @requires extension exif
375+
*/
376+
public function test_server_side_exif_rotation_disabled_for_client_side_processing() {
377+
wp_set_current_user( self::$admin_id );
378+
379+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
380+
$request->set_header( 'Content-Type', 'image/jpeg' );
381+
$request->set_header( 'Content-Disposition', 'attachment; filename=test-image-upside-down.jpg' );
382+
$request->set_param( 'generate_sub_sizes', false );
383+
384+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image-upside-down.jpg' ) );
385+
$response = rest_get_server()->dispatch( $request );
386+
$data = $response->get_data();
387+
388+
$this->assertSame( 201, $response->get_status() );
389+
390+
// Get the attachment metadata directly from the database.
391+
$metadata = wp_get_attachment_metadata( $data['id'], true );
392+
393+
// The orientation should still be 3 (not reset to 1) because server-side rotation was disabled.
394+
$this->assertArrayHasKey( 'image_meta', $metadata );
395+
$this->assertArrayHasKey( 'orientation', $metadata['image_meta'] );
396+
$this->assertSame( '3', $metadata['image_meta']['orientation'] );
397+
398+
// The exif_orientation in the REST response should also be 3.
399+
$this->assertSame( 3, $data['exif_orientation'] );
400+
}
401+
402+
/**
403+
* Verifies that full EXIF metadata is extracted and stored during client-side upload flow.
404+
*
405+
* Uses 2004-07-22-DSC_0008.jpg which has rich EXIF data from a Nikon D70 camera.
406+
*
407+
* @covers ::create_item
408+
* @covers ::prepare_item_for_response
409+
* @requires extension exif
410+
*/
411+
public function test_full_exif_metadata_extracted_for_client_side_upload() {
412+
wp_set_current_user( self::$admin_id );
413+
414+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
415+
$request->set_header( 'Content-Type', 'image/jpeg' );
416+
$request->set_header( 'Content-Disposition', 'attachment; filename=2004-07-22-DSC_0008.jpg' );
417+
$request->set_param( 'generate_sub_sizes', false );
418+
419+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg' ) );
420+
$response = rest_get_server()->dispatch( $request );
421+
$data = $response->get_data();
422+
423+
$this->assertSame( 201, $response->get_status() );
424+
$this->assertArrayHasKey( 'media_details', $data );
425+
$this->assertArrayHasKey( 'image_meta', $data['media_details'] );
426+
427+
$image_meta = $data['media_details']['image_meta'];
428+
429+
// Verify the full EXIF data is extracted (same data as server-side upload).
430+
$this->assertSame( '6.3', $image_meta['aperture'] );
431+
$this->assertSame( 'NIKON D70', $image_meta['camera'] );
432+
$this->assertSame( '27', $image_meta['focal_length'] );
433+
$this->assertSame( '400', $image_meta['iso'] );
434+
// Verify timestamp is set (Nikon D70 image has created_timestamp).
435+
$this->assertNotEmpty( $image_meta['created_timestamp'] );
436+
}
437+
438+
/**
439+
* Verifies that EXIF metadata with IPTC data is extracted correctly.
440+
*
441+
* Uses 2004-07-22-DSC_0007.jpg which has both EXIF and IPTC data.
442+
*
443+
* @covers ::create_item
444+
* @covers ::prepare_item_for_response
445+
* @requires extension exif
446+
*/
447+
public function test_exif_and_iptc_metadata_extracted() {
448+
wp_set_current_user( self::$admin_id );
449+
450+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
451+
$request->set_header( 'Content-Type', 'image/jpeg' );
452+
$request->set_header( 'Content-Disposition', 'attachment; filename=2004-07-22-DSC_0007.jpg' );
453+
$request->set_param( 'generate_sub_sizes', false );
454+
455+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/2004-07-22-DSC_0007.jpg' ) );
456+
$response = rest_get_server()->dispatch( $request );
457+
$data = $response->get_data();
458+
459+
$this->assertSame( 201, $response->get_status() );
460+
461+
$image_meta = $data['media_details']['image_meta'];
462+
463+
// Verify EXIF data from camera.
464+
$this->assertSame( '6.3', $image_meta['aperture'] );
465+
$this->assertSame( 'NIKON D70', $image_meta['camera'] );
466+
$this->assertSame( '18', $image_meta['focal_length'] );
467+
$this->assertSame( '200', $image_meta['iso'] );
468+
469+
// Verify IPTC data.
470+
$this->assertSame( 'IPTC Creator', $image_meta['credit'] );
471+
$this->assertSame( 'IPTC Caption', $image_meta['caption'] );
472+
$this->assertSame( 'IPTC Copyright', $image_meta['copyright'] );
473+
$this->assertSame( 'IPTC Headline', $image_meta['title'] );
474+
}
475+
476+
/**
477+
* Verifies that sideloading sub-sizes preserves the original image_meta.
478+
*
479+
* @covers ::sideload_item
480+
* @requires extension exif
481+
*/
482+
public function test_sideload_preserves_image_meta() {
483+
wp_set_current_user( self::$admin_id );
484+
485+
// First, upload an image with EXIF data using client-side upload flow.
486+
$request = new WP_REST_Request( 'POST', '/wp/v2/media' );
487+
$request->set_header( 'Content-Type', 'image/jpeg' );
488+
$request->set_header( 'Content-Disposition', 'attachment; filename=2004-07-22-DSC_0008.jpg' );
489+
$request->set_param( 'generate_sub_sizes', false );
490+
491+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg' ) );
492+
$response = rest_get_server()->dispatch( $request );
493+
$data = $response->get_data();
494+
$attachment_id = $data['id'];
495+
496+
// Record the original image_meta.
497+
$original_image_meta = $data['media_details']['image_meta'];
498+
499+
// Now sideload a sub-size.
500+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" );
501+
$request->set_header( 'Content-Type', 'image/jpeg' );
502+
$request->set_header( 'Content-Disposition', 'attachment; filename=2004-07-22-DSC_0008-150x150.jpg' );
503+
$request->set_param( 'image_size', 'thumbnail' );
504+
505+
// Use a smaller image for the sub-size (dimensions don't matter for this test).
506+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) );
507+
$response = rest_get_server()->dispatch( $request );
508+
$data = $response->get_data();
509+
510+
$this->assertSame( 200, $response->get_status() );
511+
512+
// Verify the image_meta is preserved after sideloading.
513+
$this->assertArrayHasKey( 'image_meta', $data['media_details'] );
514+
$sideloaded_image_meta = $data['media_details']['image_meta'];
515+
516+
// The EXIF data should be unchanged.
517+
$this->assertSame( $original_image_meta['aperture'], $sideloaded_image_meta['aperture'] );
518+
$this->assertSame( $original_image_meta['camera'], $sideloaded_image_meta['camera'] );
519+
$this->assertSame( $original_image_meta['focal_length'], $sideloaded_image_meta['focal_length'] );
520+
$this->assertSame( $original_image_meta['iso'], $sideloaded_image_meta['iso'] );
521+
}
522+
523+
/**
524+
* Verifies that sideloaded sub-sizes include expected metadata fields.
525+
*
526+
* Sub-sizes should have file, width, height, mime-type, and filesize in their metadata.
527+
*
528+
* @covers ::sideload_item
529+
*/
530+
public function test_sideloaded_subsize_has_complete_metadata() {
531+
wp_set_current_user( self::$admin_id );
532+
533+
$attachment_id = self::factory()->attachment->create_object(
534+
DIR_TESTDATA . '/images/canola.jpg',
535+
0,
536+
array(
537+
'post_mime_type' => 'image/jpeg',
538+
)
539+
);
540+
541+
wp_update_attachment_metadata(
542+
$attachment_id,
543+
wp_generate_attachment_metadata( $attachment_id, DIR_TESTDATA . '/images/canola.jpg' )
544+
);
545+
546+
$request = new WP_REST_Request( 'POST', "/wp/v2/media/$attachment_id/sideload" );
547+
$request->set_header( 'Content-Type', 'image/jpeg' );
548+
$request->set_header( 'Content-Disposition', 'attachment; filename=canola-300x200.jpg' );
549+
$request->set_param( 'image_size', 'medium' );
550+
551+
$request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) );
552+
$response = rest_get_server()->dispatch( $request );
553+
$data = $response->get_data();
554+
555+
$this->assertSame( 200, $response->get_status() );
556+
$this->assertArrayHasKey( 'sizes', $data['media_details'] );
557+
$this->assertArrayHasKey( 'medium', $data['media_details']['sizes'] );
558+
559+
$medium_size = $data['media_details']['sizes']['medium'];
560+
561+
// Verify all expected metadata fields are present for the sub-size.
562+
$this->assertArrayHasKey( 'file', $medium_size );
563+
$this->assertArrayHasKey( 'width', $medium_size );
564+
$this->assertArrayHasKey( 'height', $medium_size );
565+
$this->assertArrayHasKey( 'mime_type', $medium_size );
566+
$this->assertArrayHasKey( 'filesize', $medium_size );
567+
568+
$this->assertSame( 'canola-300x200.jpg', $medium_size['file'] );
569+
$this->assertSame( 'image/jpeg', $medium_size['mime_type'] );
570+
$this->assertGreaterThan( 0, $medium_size['filesize'] );
571+
}
572+
573+
/**
574+
* Verifies that exif_orientation is in the schema for images.
575+
*
576+
* @covers ::get_item_schema
577+
*/
578+
public function test_exif_orientation_in_schema() {
579+
$controller = new Gutenberg_REST_Attachments_Controller( 'attachment' );
580+
$schema = $controller->get_item_schema();
581+
582+
$this->assertArrayHasKey( 'exif_orientation', $schema['properties'] );
583+
$this->assertSame( 'integer', $schema['properties']['exif_orientation']['type'] );
584+
$this->assertContains( 'edit', $schema['properties']['exif_orientation']['context'] );
585+
$this->assertTrue( $schema['properties']['exif_orientation']['readonly'] );
586+
}
587+
588+
/**
589+
* Verifies metadata consistency between server-side and client-side upload flows.
590+
*
591+
* The same image uploaded with server-side processing (generate_sub_sizes=true)
592+
* should have the same image_meta as when uploaded with client-side processing
593+
* (generate_sub_sizes=false).
594+
*
595+
* @covers ::create_item
596+
* @requires extension exif
597+
*/
598+
public function test_metadata_consistency_between_upload_flows() {
599+
wp_set_current_user( self::$admin_id );
600+
601+
// Upload with server-side processing (default).
602+
$request_server = new WP_REST_Request( 'POST', '/wp/v2/media' );
603+
$request_server->set_header( 'Content-Type', 'image/jpeg' );
604+
$request_server->set_header( 'Content-Disposition', 'attachment; filename=server-side-upload.jpg' );
605+
$request_server->set_param( 'generate_sub_sizes', true );
606+
607+
$request_server->set_body( file_get_contents( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg' ) );
608+
$response_server = rest_get_server()->dispatch( $request_server );
609+
$data_server = $response_server->get_data();
610+
611+
// Upload with client-side processing.
612+
$request_client = new WP_REST_Request( 'POST', '/wp/v2/media' );
613+
$request_client->set_header( 'Content-Type', 'image/jpeg' );
614+
$request_client->set_header( 'Content-Disposition', 'attachment; filename=client-side-upload.jpg' );
615+
$request_client->set_param( 'generate_sub_sizes', false );
616+
617+
$request_client->set_body( file_get_contents( DIR_TESTDATA . '/images/2004-07-22-DSC_0008.jpg' ) );
618+
$response_client = rest_get_server()->dispatch( $request_client );
619+
$data_client = $response_client->get_data();
620+
621+
$this->assertSame( 201, $response_server->get_status() );
622+
$this->assertSame( 201, $response_client->get_status() );
623+
624+
$meta_server = $data_server['media_details']['image_meta'];
625+
$meta_client = $data_client['media_details']['image_meta'];
626+
627+
// The core EXIF fields should be identical.
628+
$this->assertSame( $meta_server['aperture'], $meta_client['aperture'] );
629+
$this->assertSame( $meta_server['camera'], $meta_client['camera'] );
630+
$this->assertSame( $meta_server['focal_length'], $meta_client['focal_length'] );
631+
$this->assertSame( $meta_server['iso'], $meta_client['iso'] );
632+
$this->assertSame( $meta_server['shutter_speed'], $meta_client['shutter_speed'] );
633+
$this->assertSame( $meta_server['created_timestamp'], $meta_client['created_timestamp'] );
634+
}
314635
}

0 commit comments

Comments
 (0)