Step-by-Step Guide to building a custom bulk image watermarker block for Gutenberg using REST API custom routes
Leveraging WordPress REST API for Custom Gutenberg Blocks: A Bulk Image Watermarking Solution
This guide details the construction of a custom Gutenberg block for WordPress that facilitates bulk image watermarking. We will architect this solution using the WordPress REST API, specifically by defining custom routes to handle the image processing logic server-side. This approach ensures efficient processing, maintains security, and provides a robust user experience within the WordPress admin interface.
I. Plugin Structure and REST API Endpoint Registration
We begin by establishing the foundational plugin structure and registering our custom REST API endpoint. This endpoint will serve as the communication channel between our Gutenberg block (JavaScript) and our PHP processing logic.
Create a new directory for your plugin, e.g., wp-content/plugins/bulk-watermarker. Inside this directory, create the main plugin file, bulk-watermarker.php.
A. Plugin Header and Initialization
The plugin header provides essential metadata for WordPress. We’ll also hook into WordPress actions to register our REST API routes.
<?php
/**
* Plugin Name: Bulk Image Watermarker
* Description: Adds a Gutenberg block for bulk watermarking images via REST API.
* Version: 1.0.0
* Author: Your Name
* Author URI: https://yourwebsite.com
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: bulk-watermarker
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Register REST API routes.
*/
function bw_register_rest_routes() {
// Register the route for watermarking images.
register_rest_route( 'bulk-watermarker/v1', '/watermark', array(
'methods' => WP_REST_Server::CREATABLE, // Use CREATABLE for POST requests.
'callback' => 'bw_handle_watermark_request',
'permission_callback' => function() {
// Ensure only authenticated users with sufficient capabilities can access.
return current_user_can( 'upload_files' );
},
'args' => array(
'image_ids' => array(
'required' => true,
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'description' => __( 'Array of attachment IDs to watermark.', 'bulk-watermarker' ),
),
'watermark_image_id' => array(
'required' => true,
'type' => 'integer',
'description' => __( 'Attachment ID of the watermark image.', 'bulk-watermarker' ),
),
'position' => array(
'required' => false,
'type' => 'string',
'enum' => array( 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center' ),
'default' => 'bottom-right',
'description' => __( 'Position of the watermark.', 'bulk-watermarker' ),
),
'opacity' => array(
'required' => false,
'type' => 'number',
'min' => 0,
'max' => 1,
'default' => 0.7,
'description' => __( 'Opacity of the watermark (0.0 to 1.0).', 'bulk-watermarker' ),
),
'scale' => array(
'required' => false,
'type' => 'number',
'min' => 0.1,
'max' => 2.0,
'default' => 0.5,
'description' => __( 'Scale of the watermark relative to the base image.', 'bulk-watermarker' ),
),
),
) );
}
add_action( 'rest_api_init', 'bw_register_rest_routes' );
/**
* Callback function to handle the watermarking request.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
function bw_handle_watermark_request( WP_REST_Request $request ) {
// Implementation will be detailed in the next section.
return new WP_Error( 'bw_not_implemented', __( 'Watermarking logic not yet implemented.', 'bulk-watermarker' ), array( 'status' => 501 ) );
}
/**
* Enqueue scripts for the Gutenberg editor.
*/
function bw_enqueue_editor_assets() {
wp_enqueue_script(
'bulk-watermarker-editor-script',
plugins_url( 'build/index.js', __FILE__ ), // Assuming your JS is compiled to build/index.js
array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n' ),
filemtime( plugin_dir_path( __FILE__ ) . 'build/index.js' )
);
// Localize script for passing data to JavaScript.
wp_localize_script( 'bulk-watermarker-editor-script', 'bw_ajax_object', array(
'ajax_url' => rest_url( 'bulk-watermarker/v1/watermark' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
}
add_action( 'enqueue_block_editor_assets', 'bw_enqueue_editor_assets' );
?>
II. Server-Side Image Processing Logic
The core of our watermarking functionality resides in the bw_handle_watermark_request callback. This function will receive the image IDs, watermark details, and processing parameters, then use WordPress’s image manipulation capabilities to apply the watermark.
A. Image Manipulation with GD Library
We’ll utilize PHP’s GD library for image processing. This requires the GD extension to be enabled on your server. The process involves:
- Retrieving the full path to the original images and the watermark image.
- Loading both images into GD image resources.
- Resizing the watermark if necessary based on the ‘scale’ parameter.
- Applying the watermark with specified opacity and position.
- Saving the watermarked image, overwriting the original (or creating a new version, depending on desired behavior).
B. Implementing the Callback Function
Replace the placeholder in bulk-watermarker.php with the following implementation:
/**
* Callback function to handle the watermarking request.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
function bw_handle_watermark_request( WP_REST_Request $request ) {
if ( ! extension_loaded( 'gd' ) ) {
return new WP_Error( 'bw_gd_missing', __( 'GD library is not enabled on the server.', 'bulk-watermarker' ), array( 'status' => 500 ) );
}
$image_ids = $request->get_param( 'image_ids' );
$watermark_image_id = $request->get_param( 'watermark_image_id' );
$position = $request->get_param( 'position' );
$opacity = $request->get_param( 'opacity' );
$scale = $request->get_param( 'scale' );
$processed_count = 0;
$errors = array();
// Get watermark image path and dimensions.
$watermark_path = get_attached_file( $watermark_image_id );
if ( ! $watermark_path || ! file_exists( $watermark_path ) ) {
return new WP_Error( 'bw_watermark_not_found', __( 'Watermark image not found.', 'bulk-watermarker' ), array( 'status' => 404 ) );
}
$watermark_info = getimagesize( $watermark_path );
if ( ! $watermark_info ) {
return new WP_Error( 'bw_watermark_invalid', __( 'Watermark image is invalid.', 'bulk-watermarker' ), array( 'status' => 400 ) );
}
$watermark_mime = $watermark_info['mime'];
$watermark_width = $watermark_info[0];
$watermark_height = $watermark_info[1];
// Create watermark image resource.
$watermark_resource = null;
switch ( $watermark_mime ) {
case 'image/jpeg':
$watermark_resource = imagecreatefromjpeg( $watermark_path );
break;
case 'image/png':
$watermark_resource = imagecreatefrompng( $watermark_path );
break;
case 'image/gif':
$watermark_resource = imagecreatefromgif( $watermark_path );
break;
default:
return new WP_Error( 'bw_watermark_unsupported', __( 'Unsupported watermark image format.', 'bulk-watermarker' ), array( 'status' => 400 ) );
}
if ( ! $watermark_resource ) {
return new WP_Error( 'bw_watermark_load_failed', __( 'Failed to load watermark image.', 'bulk-watermarker' ), array( 'status' => 500 ) );
}
// Adjust watermark opacity.
if ( $opacity < 1.0 ) {
imagealphablending( $watermark_resource, true );
imagesavealpha( $watermark_resource, true );
$alpha = intval( ( 1 - $opacity ) * 127 );
imagefilter( $watermark_resource, IMG_FILTER_ALPHA, $alpha );
}
foreach ( $image_ids as $image_id ) {
$image_path = get_attached_file( $image_id );
if ( ! $image_path || ! file_exists( $image_path ) ) {
$errors[] = sprintf( __( 'Image ID %d not found.', 'bulk-watermarker' ), $image_id );
continue;
}
$image_info = getimagesize( $image_path );
if ( ! $image_info ) {
$errors[] = sprintf( __( 'Image ID %d is invalid.', 'bulk-watermarker' ), $image_id );
continue;
}
$image_mime = $image_info['mime'];
$image_width = $image_info[0];
$image_height = $image_info[1];
// Create image resource.
$image_resource = null;
switch ( $image_mime ) {
case 'image/jpeg':
$image_resource = imagecreatefromjpeg( $image_path );
break;
case 'image/png':
$image_resource = imagecreatefrompng( $image_path );
break;
case 'image/gif':
$image_resource = imagecreatefromgif( $image_path );
break;
default:
$errors[] = sprintf( __( 'Image ID %d has an unsupported format (%s).', 'bulk-watermarker' ), $image_id, $image_mime );
continue;
}
if ( ! $image_resource ) {
$errors[] = sprintf( __( 'Failed to load image ID %d.', 'bulk-watermarker' ), $image_id );
continue;
}
// Calculate watermark dimensions based on scale.
$target_watermark_width = intval( $image_width * $scale );
$target_watermark_height = intval( $image_height * $scale );
// Resize watermark if necessary.
if ( $watermark_width != $target_watermark_width || $watermark_height != $target_watermark_height ) {
$resized_watermark = imagecreatetruecolor( $target_watermark_width, $target_watermark_height );
if ( $resized_watermark === false ) {
$errors[] = sprintf( __( 'Failed to create resized watermark canvas for image ID %d.', 'bulk-watermarker' ), $image_id );
imagedestroy($image_resource);
continue;
}
// Preserve transparency for PNGs
if ( $watermark_mime === 'image/png' ) {
imagealphablending( $resized_watermark, false );
imagesavealpha( $resized_watermark, true );
$transparent = imagecolorallocatealpha( $resized_watermark, 0, 0, 0, 127 );
imagefill( $resized_watermark, 0, 0, $transparent );
}
if ( ! imagecopyresampled( $resized_watermark, $watermark_resource, 0, 0, 0, 0, $target_watermark_width, $target_watermark_height, $watermark_width, $watermark_height ) ) {
$errors[] = sprintf( __( 'Failed to resample watermark for image ID %d.', 'bulk-watermarker' ), $image_id );
imagedestroy($image_resource);
imagedestroy($resized_watermark);
continue;
}
imagedestroy($watermark_resource); // Free up original watermark resource
$watermark_resource = $resized_watermark; // Use the resized one
$watermark_width = $target_watermark_width;
$watermark_height = $target_watermark_height;
}
// Calculate watermark position.
$dest_x = 0;
$dest_y = 0;
switch ( $position ) {
case 'top-left':
$dest_x = 10; // Add some padding
$dest_y = 10;
break;
case 'top-right':
$dest_x = $image_width - $watermark_width - 10;
$dest_y = 10;
break;
case 'bottom-left':
$dest_x = 10;
$dest_y = $image_height - $watermark_height - 10;
break;
case 'center':
$dest_x = ( $image_width - $watermark_width ) / 2;
$dest_y = ( $image_height - $watermark_height ) / 2;
break;
case 'bottom-right':
default: // Default to bottom-right
$dest_x = $image_width - $watermark_width - 10;
$dest_y = $image_height - $watermark_height - 10;
break;
}
// Ensure watermark doesn't go out of bounds.
$dest_x = max( 0, $dest_x );
$dest_y = max( 0, $dest_y );
// Merge the watermark with the image.
// Preserve transparency for PNGs
if ( $image_mime === 'image/png' ) {
imagealphablending( $image_resource, true );
imagesavealpha( $image_resource, true );
}
if ( ! imagecopy( $image_resource, $watermark_resource, $dest_x, $dest_y, 0, 0, $watermark_width, $watermark_height ) ) {
$errors[] = sprintf( __( 'Failed to copy watermark onto image ID %d.', 'bulk-watermarker' ), $image_id );
imagedestroy($image_resource);
continue;
}
// Save the watermarked image.
$saved = false;
switch ( $image_mime ) {
case 'image/jpeg':
$saved = imagejpeg( $image_resource, $image_path, 90 ); // Save with 90% quality
break;
case 'image/png':
$saved = imagepng( $image_resource, $image_path, 9 ); // Save with compression level 9
break;
case 'image/gif':
$saved = imagegif( $image_resource, $image_path );
break;
}
if ( $saved ) {
$processed_count++;
// Update the attachment metadata to reflect changes.
// This is crucial for WordPress to recognize the modified file.
wp_update_attachment_metadata( $image_id, wp_generate_attachment_metadata( $image_id, $image_path ) );
} else {
$errors[] = sprintf( __( 'Failed to save watermarked image for ID %d.', 'bulk-watermarker' ), $image_id );
}
// Free up memory.
imagedestroy( $image_resource );
}
// Free up watermark resource.
imagedestroy( $watermark_resource );
$response_data = array(
'success' => true,
'message' => sprintf( __( '%d images watermarked successfully.', 'bulk-watermarker' ), $processed_count ),
'errors' => $errors,
);
if ( ! empty( $errors ) ) {
$response_data['message'] .= sprintf( __( ' %d errors occurred.', 'bulk-watermarker' ), count( $errors ) );
}
return new WP_REST_Response( $response_data, 200 );
}
III. Gutenberg Block Development
Now, we’ll develop the frontend Gutenberg block. This involves creating a JavaScript file that registers the block and provides the UI for users to select images, choose a watermark, and trigger the watermarking process.
A. Project Setup (Node.js/npm/Webpack)
For modern WordPress development, using a build tool like Webpack is standard. This allows us to write modern JavaScript (ESNext) and bundle it for browser compatibility.
Navigate to your plugin directory (wp-content/plugins/bulk-watermarker) in your terminal and run:
npm init -y npm install @wordpress/scripts --save-dev
This installs the WordPress scripts package, which provides a pre-configured Webpack setup. Add the following scripts to your package.json:
{
"name": "bulk-watermarker",
"version": "1.0.0",
"description": "",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@wordpress/scripts": "^26.10.0"
}
}
Create a src directory in your plugin folder. Inside src, create index.js and block.json.
B. Block Metadata (block.json)
This file defines the block’s properties, including its name, category, and attributes.
{
"apiVersion": 2,
"name": "bulk-watermarker/block",
"version": "0.1.0",
"title": "Bulk Image Watermarker",
"category": "media",
"icon": "format-image",
"description": "Apply watermarks to multiple images in bulk.",
"attributes": {
"selectedImageIds": {
"type": "array",
"default": []
},
"watermarkImageId": {
"type": "number",
"default": 0
},
"watermarkPosition": {
"type": "string",
"default": "bottom-right"
},
"watermarkOpacity": {
"type": "number",
"default": 0.7
},
"watermarkScale": {
"type": "number",
"default": 0.5
}
},
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"supports": {
"html": false
}
}
C. Block Registration and UI (src/index.js)
This is the main JavaScript file for our block. It registers the block, defines its editor interface, and handles interactions.
/**
* WordPress dependencies
*/
const { registerBlockType } = wp.blocks;
const {
Button,
PanelBody,
SelectControl,
RangeControl,
Spinner,
Notice,
MediaUpload,
MediaUploadCheck,
} = wp.components;
const { Fragment } = wp.element;
const { __ } = wp.i18n;
const { InspectorControls, useBlockProps } = wp.blockEditor;
/**
* Internal dependencies
*/
import metadata from '../block.json';
const { name } = metadata;
registerBlockType( name, {
edit: ( { attributes, setAttributes } ) => {
const {
selectedImageIds,
watermarkImageId,
watermarkPosition,
watermarkOpacity,
watermarkScale,
} = attributes;
const blockProps = useBlockProps();
const [ isLoading, setIsLoading ] = wp.useState( false );
const [ notices, setNotices ] = wp.useState( [] );
const [ mediaLibraryOpen, setMediaLibraryOpen ] = wp.useState( false );
const [ watermarkLibraryOpen, setWatermarkLibraryOpen ] = wp.useState( false );
// Fetch media library for image selection.
const onSelectImages = ( media ) => {
const newImageIds = media.map( ( img ) => img.id );
setAttributes( {
selectedImageIds: [ ...selectedImageIds, ...newImageIds ],
} );
};
// Fetch media library for watermark selection.
const onSelectWatermark = ( media ) => {
setAttributes( { watermarkImageId: media.id } );
};
// Remove selected image.
const removeImage = ( idToRemove ) => {
setAttributes( {
selectedImageIds: selectedImageIds.filter( ( id ) => id !== idToRemove ),
} );
};
// Handle watermarking process.
const handleWatermark = async () => {
if ( selectedImageIds.length === 0 || watermarkImageId === 0 ) {
setNotices( [
{
type: 'error',
message: __( 'Please select images and a watermark image.', 'bulk-watermarker' ),
},
] );
return;
}
setIsLoading( true );
setNotices( [] ); // Clear previous notices
try {
const response = await fetch( bw_ajax_object.ajax_url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': bw_ajax_object.nonce,
},
body: JSON.stringify( {
image_ids: selectedImageIds,
watermark_image_id: watermarkImageId,
position: watermarkPosition,
opacity: watermarkOpacity,
scale: watermarkScale,
} ),
} );
const result = await response.json();
if ( response.ok ) {
setNotices( [
{
type: 'success',
message: result.message,
},
] );
// Optionally clear selected images after successful watermarking
// setAttributes( { selectedImageIds: [] } );
} else {
const errorMessage = result.message || __( 'An unknown error occurred.', 'bulk-watermarker' );
setNotices( [
{
type: 'error',
message: `${errorMessage} ${result.errors ? result.errors.join(', ') : ''}`,
},
] );
}
} catch ( error ) {
console.error( 'Watermarking error:', error );
setNotices( [
{
type: 'error',
message: __( 'Network error or server issue. Please try again.', 'bulk-watermarker' ),
},
] );
} finally {
setIsLoading( false );
}
};
const positionOptions = [
{ label: __( 'Top Left', 'bulk-watermarker' ), value: 'top-left' },
{ label: __( 'Top Right', 'bulk-watermarker' ), value: 'top-right' },
{ label: __( 'Bottom Left', 'bulk-watermarker' ), value: 'bottom-left' },
{ label: __( 'Bottom Right', 'bulk-watermarker' ), value: 'bottom-right' },
{ label: __( 'Center', 'bulk-watermarker' ), value: 'center' },
];
return (
<Fragment>
<InspectorControls>
<PanelBody title={ __( 'Watermark Settings', 'bulk-watermarker' ) } initialOpen={ true }>
<MediaUploadCheck>
<MediaUpload
title={ __( 'Select Watermark Image', 'bulk-watermarker' ) }
onSelect={ onSelectWatermark }
allowedTypes={ [ 'image' ] }
value={ watermarkImageId }
render={ ( { open } ) => (
<Button
isPrimary
onClick={ open }
icon="format-image"
label={ __( 'Select Watermark', 'bulk-watermarker' ) }
>
{ watermarkImageId ? __( 'Change Watermark', 'bulk-watermarker' ) : __( 'Select Watermark', 'bulk-watermarker' ) }
</Button>
) }
/>
</MediaUploadCheck>
{ watermarkImageId !== 0 && (
<RangeControl
label={ __( 'Opacity', 'bulk-watermarker' ) }
value={ watermarkOpacity }
onChange={ ( value ) => setAttributes( { watermarkOpacity: value } ) }
min={ 0.1 }
max={ 1.0 }
step={ 0.1 }
/>
) }
{ watermarkImageId !== 0 && (
<RangeControl
label={ __( 'Scale', 'bulk-watermarker' ) }
value={ watermarkScale }
onChange={ ( value ) => setAttributes( { watermarkScale: value } ) }
min={ 0.1 }
max={ 2.0 }
step={ 0.1 }
/>
) }
{ watermarkImageId !== 0 && (
<SelectControl
label={ __( 'Position', 'bulk-watermarker' ) }
value={ watermarkPosition }
options={ positionOptions }
onChange={ ( value ) => setAttributes( { watermarkPosition: value } ) }
/>
) }
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
{ notices.map( ( notice, index ) => (
<Notice key={ index } status={ notice.type } isDismissible={ false }>
{ notice.message }
</Notice>
) ) }
<h3>{ __( 'Bulk Image Watermarker', 'bulk-watermarker' ) }</h3>
<MediaUploadCheck>
<MediaUpload
title={ __( 'Select Images to Watermark', 'bulk-watermarker' ) }
onSelect={ onSelectImages }
allowedTypes={ [ 'image' ] }
multiple
gallery
value={ selectedImageIds }
render={ ( { open } ) => (
<Button
isPrimary
onClick={ open }
icon="upload"
label={ __( 'Add Images', 'bulk-watermarker' ) }
>
{ __( 'Add Images', 'bulk-watermarker' ) }
</Button>
) }
/>
</MediaUploadCheck>
{ selectedImageIds.length > 0 && (
<div style={ { marginTop: '20px' } }>
<h4>{ __( 'Selected Images:', 'bulk-watermarker' ) }</h4>
<ul style={ { listStyle: 'none', padding: 0 } }>
{ selectedImageIds.map( ( id ) => (
<li key={ id } style={ { marginBottom: '10px', display: 'flex', alignItems: 'center' } }>
<img
src={ wp.media.attachment( id ).attributes.url }
alt=""
width="50"
height="50"
style={ { marginRight: '10px', objectFit: 'cover' } }
/>
#{ id }
<Button
isDestructive
isSmall
onClick={ () => removeImage( id ) }
style={ { marginLeft: 'auto' } }
>
{ __( 'Remove', 'bulk-watermarker' ) }
</Button>
</li>
) ) }
</ul>
</div>
) }
{ watermarkImageId !== 0 && selectedImageIds.length > 0 && (
<Button
isPrimary
onClick={ handleWatermark }
disabled={ isLoading || selectedImageIds.length === 0 || watermarkImageId === 0 }
style={ { marginTop: '20px' } }
>
{ isLoading ? <Spinner /> : __( 'Apply Watermark', 'bulk-watermarker' ) }
</Button>
) }
</div>
</Fragment>
);
},
save: () => {
// The block doesn't render anything in the frontend,
// so we return null.
return null;
},
} );
D. Building the JavaScript
After saving your src/index.js and block.json, run the build command in your terminal from the plugin’s root directory:
npm run build
This will compile your JavaScript and CSS into the build directory, making it available to WordPress.
IV. Integration and Usage
With the plugin files in place and the JavaScript built, you can now activate the “Bulk Image Watermarker” plugin in your WordPress admin. Once activated, navigate to the post or page editor. You should be able to add the “Bulk Image Watermarker” block from the block inserter.