-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathAltTextGenerator.php
More file actions
321 lines (273 loc) · 9.25 KB
/
Copy pathAltTextGenerator.php
File metadata and controls
321 lines (273 loc) · 9.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
<?php
/**
* AI Alt Text Generator.
*
* @package EqualizeDigital\AccessibilityChecker
*/
namespace EqualizeDigital\AccessibilityChecker\AI;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Generates alt text suggestions for images using the WordPress AI connector
* (WordPress 7.0+ wp_ai_client_prompt) or the AI Services plugin.
*/
class AltTextGenerator {
/**
* Rule slugs that support AI-assisted alt text generation.
*/
const SUPPORTED_RULES = [ 'img_alt_missing', 'img_alt_empty', 'img_alt_invalid' ];
/**
* Number of alt text suggestions to request by default.
*/
const DEFAULT_NUM_SUGGESTIONS = 3;
/**
* Check whether any supported AI integration is available.
*
* @return bool
*/
public static function is_available(): bool {
return function_exists( 'wp_ai_client_prompt' ) || function_exists( 'ai_services' );
}
/**
* Generate alt text suggestions for a media attachment.
*
* @param int $attachment_id WordPress attachment post ID.
* @param int $num_suggestions Number of suggestions to generate (1–5).
* @return array|\WP_Error Array of suggestion objects or WP_Error on failure.
*/
public static function generate( int $attachment_id, int $num_suggestions = self::DEFAULT_NUM_SUGGESTIONS ) {
if ( ! self::is_available() ) {
return new \WP_Error(
'no_ai_service',
__( 'No AI service is available. Please configure the WordPress AI connector under Settings > Connectors, or install and configure the AI Services plugin.', 'accessibility-checker' )
);
}
if ( 'attachment' !== get_post_type( $attachment_id ) ) {
return new \WP_Error( 'invalid_attachment', __( 'The provided ID is not a valid media attachment.', 'accessibility-checker' ) );
}
$image_url = wp_get_attachment_image_url( $attachment_id, 'full' );
if ( ! $image_url ) {
return new \WP_Error( 'no_image_url', __( 'Could not retrieve an image URL for this attachment.', 'accessibility-checker' ) );
}
$num_suggestions = max( 1, min( 5, $num_suggestions ) );
$prompt = self::build_prompt( $num_suggestions );
if ( function_exists( 'wp_ai_client_prompt' ) ) {
return self::generate_with_wp_ai_client( $image_url, $prompt );
}
return self::generate_with_ai_services( $image_url, $prompt );
}
/**
* Build the structured prompt for alt text generation.
*
* @param int $num Number of suggestions to request.
* @return string
*/
private static function build_prompt( int $num ): string {
return sprintf(
'Analyze this image and generate %1$d distinct, accessibility-focused alt text suggestions.
Each suggestion must:
- Be under 125 characters (optimal for screen readers)
- Describe exactly what is visually visible in the image
- Focus on a different visual element or aspect across the %1$d suggestions (e.g., main subject, setting, action, or composition)
- Avoid starting with "Image of", "Photo of", "Picture of", or similar redundant phrases
- Use plain language, present tense, and active voice
Return ONLY a valid JSON array with exactly %1$d objects. Each object must have these keys:
- "alt": the alt text string (required, under 125 characters)
- "focus": a short noun phrase identifying the focal element of this suggestion (required)
- "explanation": one sentence explaining what aspect of the image this alt text emphasizes (required)
Example format:
[{"alt": "Worker harvesting ripe coffee cherries by hand", "focus": "harvester", "explanation": "Emphasizes the human labor and manual process."}]
Return only the JSON array with no markdown fencing or other text.',
$num
);
}
/**
* Generate using the WordPress AI Client (WordPress 7.0+).
*
* @param string $image_url Full URL of the image.
* @param string $prompt The generation prompt.
* @return array|\WP_Error
*/
private static function generate_with_wp_ai_client( string $image_url, string $prompt ) {
try {
$builder = wp_ai_client_prompt( 'accessibility-checker-alt-text' );
if ( is_wp_error( $builder ) ) {
return $builder;
}
if ( method_exists( $builder, 'with_image' ) ) {
$builder = $builder->with_image( [ 'url' => $image_url ] );
}
if ( method_exists( $builder, 'with_message' ) ) {
$builder = $builder->with_message( $prompt );
}
$response = $builder->send();
} catch ( \Throwable $e ) {
return new \WP_Error( 'wp_ai_client_error', $e->getMessage() );
}
if ( is_wp_error( $response ) ) {
return $response;
}
$text = self::extract_text_from_response( $response );
if ( is_wp_error( $text ) ) {
return $text;
}
return self::parse_suggestions( $text );
}
/**
* Extract the generated text string from an AI response object.
*
* Different providers and SDK versions expose the result through different
* method names. Try each known variant in order before giving up.
*
* @param mixed $response Response object returned by the AI SDK.
* @return string|\WP_Error The response text or a WP_Error.
*/
private static function extract_text_from_response( $response ) {
if ( is_string( $response ) ) {
return $response;
}
// WordPress AI Client / ai-services candidates object.
$methods = [
'get_text',
'get_first_candidate_text',
'get_content',
'getText',
'getFirstCandidateText',
];
foreach ( $methods as $method ) {
if ( method_exists( $response, $method ) ) {
$result = $response->$method();
if ( is_string( $result ) ) {
return $result;
}
}
}
// Candidates object: try to get first candidate then text from it.
if ( method_exists( $response, 'get_candidates' ) ) {
$candidates = $response->get_candidates();
if ( is_array( $candidates ) && ! empty( $candidates ) ) {
$first = reset( $candidates );
if ( method_exists( $first, 'get_text' ) ) {
return (string) $first->get_text();
}
if ( method_exists( $first, 'get_content' ) ) {
return (string) $first->get_content();
}
}
}
return new \WP_Error(
'unexpected_response',
sprintf(
/* translators: %s: PHP class name of the unexpected response object */
__( 'Unexpected AI response type (%s). Please report this to the plugin author.', 'accessibility-checker' ),
is_object( $response ) ? get_class( $response ) : gettype( $response )
)
);
}
/**
* Generate using the AI Services plugin (Felix Arntz / felixarntz/ai-services).
*
* @param string $image_url Full URL of the image.
* @param string $prompt The generation prompt.
* @return array|\WP_Error
*/
private static function generate_with_ai_services( string $image_url, string $prompt ) {
try {
$ai = ai_services();
// Request a service that supports both image understanding and text generation.
$service = $ai->get_available_service(
[
'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ],
]
);
if ( is_wp_error( $service ) ) {
// Fallback: any text-generation capable service.
$service = $ai->get_available_service(
[
'capabilities' => [ 'TEXT_GENERATION' ],
]
);
}
if ( is_wp_error( $service ) ) {
return new \WP_Error(
'no_configured_service',
__( 'No AI service is configured. Please add an API key under Settings > AI Services.', 'accessibility-checker' )
);
}
$model = $service->get_model(
[
'feature' => 'accessibility-checker-alt-text',
'capabilities' => [ 'MULTIMODAL_INPUT', 'TEXT_GENERATION' ],
]
);
if ( is_wp_error( $model ) ) {
return $model;
}
// Build content: image URL part + text prompt part.
$content_args = [
[
'role' => 'user',
'parts' => [
[
'type' => 'image_url',
'url' => $image_url,
],
[
'type' => 'text',
'text' => $prompt,
],
],
],
];
$candidates = $model->generate_text( $content_args );
if ( is_wp_error( $candidates ) ) {
return $candidates;
}
$text = self::extract_text_from_response( $candidates );
if ( is_wp_error( $text ) ) {
return $text;
}
return self::parse_suggestions( $text );
} catch ( \Throwable $e ) {
return new \WP_Error( 'ai_services_error', $e->getMessage() );
}
}
/**
* Parse JSON suggestion objects from the AI response text.
*
* @param string $text Raw text from the AI response.
* @return array|\WP_Error
*/
private static function parse_suggestions( string $text ) {
// Strip markdown code fences if the AI wrapped the JSON.
$text = preg_replace( '/^```(?:json)?\s*/m', '', $text );
$text = preg_replace( '/\s*```\s*$/m', '', $text );
$text = trim( $text );
$data = json_decode( $text, true, 3 );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
return new \WP_Error(
'parse_error',
__( 'Could not parse the AI response. Please try again.', 'accessibility-checker' )
);
}
$suggestions = [];
foreach ( $data as $item ) {
if ( ! is_array( $item ) || empty( $item['alt'] ) ) {
continue;
}
$suggestions[] = [
'alt' => sanitize_text_field( $item['alt'] ),
'focus' => sanitize_text_field( $item['focus'] ?? '' ),
'explanation' => sanitize_text_field( $item['explanation'] ?? '' ),
];
}
if ( empty( $suggestions ) ) {
return new \WP_Error(
'no_suggestions',
__( 'No valid alt text suggestions were generated. Please try again.', 'accessibility-checker' )
);
}
return $suggestions;
}
}