-
Notifications
You must be signed in to change notification settings - Fork 87
Expand file tree
/
Copy pathAlt_Text_Generation.php
More file actions
602 lines (514 loc) · 15.8 KB
/
Alt_Text_Generation.php
File metadata and controls
602 lines (514 loc) · 15.8 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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
<?php
/**
* Alt text generation WordPress Ability implementation.
*
* @package WordPress\AI
*/
declare( strict_types=1 );
namespace WordPress\AI\Abilities\Image;
use WP_Error;
use WordPress\AI\Abstracts\Abstract_Ability;
use function WordPress\AI\get_preferred_vision_models;
use function WordPress\AI\normalize_content;
/**
* Alt text generation WordPress Ability.
*
* Uses AI vision models to propose alt text aligned with WCAG-oriented practice.
*
* @since 0.3.0
*/
class Alt_Text_Generation extends Abstract_Ability {
/**
* The maximum character length for generated alt text.
*
* @since 0.3.0
*
* @var int
*/
protected const MAX_ALT_TEXT_LENGTH = 125;
/**
* Model output token that means the correct alternative text is empty (alt="").
*
* @since 0.7.0
*
* @var string
*/
private const DECORATIVE_ALT_TOKEN = '[[DECORATIVE_ALT]]';
/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function guideline_categories(): array {
return array( 'site', 'images' );
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
protected function input_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'attachment_id' => array(
'type' => 'integer',
'sanitize_callback' => 'absint',
'description' => esc_html__( 'The attachment ID of the image to generate alt text for.', 'ai' ),
),
'image_url' => array(
'type' => 'string',
'sanitize_callback' => array( $this, 'sanitize_image_reference_input' ),
'description' => esc_html__( 'URL or data URI of the image to generate alt text for. Used if attachment_id is not provided.', 'ai' ),
),
'context' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
'description' => esc_html__( 'Optional context about the image or surrounding content to improve alt text relevance.', 'ai' ),
),
'image_meta' => array(
'type' => 'string',
'description' => esc_html__( 'Structured metadata about how the image block is used, such as whether it is linked.', 'ai' ),
),
),
);
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
protected function output_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'alt_text' => array(
'type' => 'string',
'description' => esc_html__( 'Generated alternative text for the image; may be empty when alt="" is correct.', 'ai' ),
),
'is_decorative' => array(
'type' => 'boolean',
'description' => esc_html__( 'Whether the image was determined to be decorative.', 'ai' ),
),
),
);
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
protected function execute_callback( $input ) {
// Default arguments.
$args = wp_parse_args(
$input,
array(
'attachment_id' => null,
'image_url' => null,
'context' => '',
'image_meta' => '',
),
);
// Get the image reference.
$image_reference = $this->get_image_reference( $args );
if ( is_wp_error( $image_reference ) ) {
return $image_reference;
}
// Generate the alt text.
$result = $this->generate_alt_text(
$image_reference,
normalize_content( $args['context'] ),
sanitize_textarea_field( $args['image_meta'] )
);
if ( is_wp_error( $result ) ) {
return $result;
}
// Detect the decorative token from the AI response.
if ( 0 === strcasecmp( trim( $result ), self::DECORATIVE_ALT_TOKEN ) ) {
return array(
'alt_text' => '',
'is_decorative' => true,
);
}
// Return the alt text in the format the Ability expects.
return array(
'alt_text' => sanitize_text_field( $result ),
);
}
/**
* Gets the image as a data URI from the input arguments.
*
* @since 0.3.0
*
* @param array<string, mixed> $args The input arguments.
* @return array{reference: string}|\WP_Error The prepared reference payload or WP_Error on failure.
*/
protected function get_image_reference( array $args ) {
// If an attachment ID is provided, get the attachment from the database.
if ( ! empty( $args['attachment_id'] ) ) {
return $this->get_attachment_reference( absint( $args['attachment_id'] ) );
}
// If an image URL is provided, get the image from the URL.
if ( ! empty( $args['image_url'] ) ) {
// Preserve data URIs as-is so the AI client can read the inline bytes.
if ( 0 === strpos( $args['image_url'], 'data:' ) ) {
return $this->prepare_reference_result( $args['image_url'] );
}
// Try to map the URL to a local path.
$path = $this->maybe_map_url_to_local_path( $args['image_url'] );
if ( $path ) {
$data_uri = $this->file_to_data_uri( $path );
if ( $data_uri ) {
return $this->prepare_reference_result( $data_uri );
}
}
// Download the remote image to a temporary file.
$downloaded = $this->download_remote_image_to_temp_file( $args['image_url'] );
if ( is_wp_error( $downloaded ) ) {
return $downloaded;
}
$data_uri = $this->file_to_data_uri( $downloaded );
$this->cleanup_temporary_file( $downloaded );
if ( ! $data_uri ) {
return new WP_Error(
'file_read_error',
esc_html__( 'Could not read the downloaded image file.', 'ai' )
);
}
return $this->prepare_reference_result( $data_uri );
}
return new WP_Error(
'no_image_provided',
esc_html__( 'Either attachment_id or image_url must be provided.', 'ai' )
);
}
/**
* Generates alt text for the given image reference.
*
* @since 0.3.0
*
* @param array{reference: string} $image_reference Prepared image reference containing a data URI.
* @param string $context Optional context to improve alt text relevance.
* @param string $image_meta Optional metadata about how the image block is used.
* @return string|\WP_Error The generated alt text or WP_Error on failure.
*/
protected function generate_alt_text( array $image_reference, string $context = '', string $image_meta = '' ) {
$prompt_builder = $this->get_prompt_builder( $this->build_prompt( $context, $image_meta ), $image_reference['reference'] );
if ( is_wp_error( $prompt_builder ) ) {
return $prompt_builder;
}
$result = $prompt_builder->generate_text();
if ( is_wp_error( $result ) ) {
return $result;
}
// Clean up the result.
$alt_text = trim( $result );
$alt_text = trim( $alt_text, '"\'.' );
return $alt_text;
}
/**
* Converts an attachment to a data URI.
*
* @since 0.3.0
*
* @param int $attachment_id Attachment ID.
* @return array{reference: string}|\WP_Error Data URI reference array or WP_Error on failure.
*/
protected function get_attachment_reference( int $attachment_id ) {
$attachment = get_post( $attachment_id );
// Ensure the attachment is valid.
if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
return new WP_Error(
'invalid_attachment',
/* translators: %d: Attachment ID. */
sprintf( esc_html__( 'Attachment with ID %d not found.', 'ai' ), $attachment_id )
);
}
// Ensure the attachment is an image.
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return new WP_Error(
'not_an_image',
esc_html__( 'The specified attachment is not an image.', 'ai' )
);
}
// Try and get the data URI from the file path.
$file_path = get_attached_file( $attachment_id );
if ( $file_path && file_exists( $file_path ) ) {
$data_uri = $this->file_to_data_uri( $file_path );
if ( $data_uri ) {
return $this->prepare_reference_result( $data_uri );
}
}
// If the file path doesn't exist, try and use the image src.
$image_src = wp_get_attachment_image_src( $attachment_id, 'large' );
if ( ! $image_src ) {
$image_src = wp_get_attachment_image_src( $attachment_id, 'full' );
}
if ( ! $image_src || empty( $image_src[0] ) ) {
return new WP_Error(
'image_url_not_found',
esc_html__( 'Could not retrieve image URL from attachment.', 'ai' )
);
}
// Download remote URL and convert to data URI.
$downloaded = $this->download_remote_image_to_temp_file( $image_src[0] );
if ( is_wp_error( $downloaded ) ) {
return $downloaded;
}
$data_uri = $this->file_to_data_uri( $downloaded );
$this->cleanup_temporary_file( $downloaded );
if ( ! $data_uri ) {
return new WP_Error(
'file_read_error',
esc_html__( 'Could not read the downloaded image file.', 'ai' )
);
}
return $this->prepare_reference_result( $data_uri );
}
/**
* Attempts to map an uploads URL to a local filesystem path.
*
* @since 0.3.0
*
* @param string $url The URL to convert.
* @return string|null The local path if found, otherwise null.
*/
protected function maybe_map_url_to_local_path( string $url ): ?string {
$uploads = wp_get_upload_dir();
if (
empty( $uploads['baseurl'] ) ||
empty( $uploads['basedir'] )
) {
return null;
}
$normalized_url = $this->normalize_upload_url( $url );
$normalized_baseurl = $this->normalize_upload_url( $uploads['baseurl'] );
if ( false === strpos( $normalized_url, $normalized_baseurl ) ) {
return null;
}
$relative_path = ltrim(
substr( $normalized_url, strlen( $normalized_baseurl ) ),
'/'
);
if ( '' === $relative_path ) {
return null;
}
// Reject path traversal attempts in the relative path.
if (
'..' === $relative_path ||
0 === strpos( $relative_path, '../' ) ||
false !== strpos( $relative_path, '/..' )
) {
return null;
}
$base_dir = wp_normalize_path(
trailingslashit( $uploads['basedir'] )
);
$full_path = $base_dir . $relative_path;
$real_full_path = realpath( $full_path );
if ( false === $real_full_path ) {
return null;
}
$real_full_path = wp_normalize_path( $real_full_path );
// Ensure the resolved path is strictly within the uploads base directory.
if ( 0 !== strpos( $real_full_path, $base_dir ) ) {
return null;
}
if ( file_exists( $real_full_path ) && is_file( $real_full_path ) ) {
return $real_full_path;
}
return null;
}
/**
* Normalizes an uploads URL for comparison.
*
* @since 0.3.0
*
* @param string $url URL to normalize.
* @return string Normalized URL without scheme and trailing slashes.
*/
protected function normalize_upload_url( string $url ): string {
$without_scheme = preg_replace( '#^https?://#i', '', $url );
return rtrim( $without_scheme ?? $url, '/' );
}
/**
* Gets a prompt builder for generating alt text.
*
* @since 0.7.0
*
* @param string $prompt The prompt to generate alt text from.
* @param string $reference The reference image.
* @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a WP_Error on failure.
*/
private function get_prompt_builder( string $prompt, string $reference ) {
$prompt_builder = wp_ai_client_prompt( $prompt )
->with_file( $reference )
->using_system_instruction( $this->get_system_instruction( 'alt-text-system-instruction.php' ) )
->using_temperature( 0.3 )
->using_model_preference( ...get_preferred_vision_models() );
return $this->ensure_text_generation_supported(
$prompt_builder,
esc_html__( 'Alt text generation failed. Please ensure you have a connected provider that supports both text generation and vision capabilities.', 'ai' )
);
}
/**
* Builds the prompt for alt text generation.
*
* @since 0.3.0
*
* @param string $context Optional context about the image.
* @param string $image_meta Optional metadata about how the image block is used.
* @return string The prompt for the AI.
*/
protected function build_prompt( string $context = '', string $image_meta = '' ): string {
$prompt = __( 'Generate alt text for this image.', 'ai' );
// If we have additional context, add it to the prompt.
if ( ! empty( $context ) ) {
$prompt .= ' ' . __( 'Ensure the alt text you return matches the language of the content in the <additional-context> tag.', 'ai' );
$prompt .= "\n\n<additional-context>" . $context . '</additional-context>';
} else {
$prompt .= ' ' . sprintf(
/* translators: %s: locale code, e.g. pl_PL */
__( 'Ensure the alt text you return matches the language of this locale: %s.', 'ai' ),
get_locale()
);
}
// If we have image block usage metadata, add it to the prompt.
if ( ! empty( $image_meta ) ) {
$prompt .= "\n\n<image-meta>" . $image_meta . '</image-meta>';
}
return $prompt;
}
/**
* Sanitizes incoming image references while allowing data URIs.
*
* @since 0.3.0
*
* @param mixed $value Raw user input.
* @return string Sanitized value.
*/
protected function sanitize_image_reference_input( $value ): string {
if ( ! is_string( $value ) ) {
return '';
}
$value = trim( $value );
if ( '' === $value ) {
return '';
}
if ( 0 === strpos( $value, 'data:' ) ) {
return $value;
}
return esc_url_raw( $value );
}
/**
* Downloads a remote image to a temporary file for processing.
*
* @since 0.3.0
*
* @param string $url Remote image URL.
* @return string|\WP_Error Path to the temporary file or WP_Error on failure.
*/
protected function download_remote_image_to_temp_file( string $url ) {
if ( ! function_exists( 'download_url' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$temp_file = download_url( $url );
if ( is_wp_error( $temp_file ) ) {
return $temp_file;
}
return $temp_file;
}
/**
* Cleans up a temporary file if it exists.
*
* @since 0.3.0
*
* @param string $file_path The file path to clean up.
* @return void
*/
protected function cleanup_temporary_file( string $file_path ): void {
if ( ! file_exists( $file_path ) ) {
return;
}
wp_delete_file( $file_path );
}
/**
* Helper to standardize the reference result.
*
* @since 0.3.0
*
* @param string $reference The data URI reference.
* @return array{reference: string} Standardized reference array.
*/
protected function prepare_reference_result( string $reference ): array {
return array(
'reference' => $reference,
);
}
/**
* Converts a file to a data URI.
*
* @since 0.3.0
*
* @param string $file_path Path to the file.
* @return string|null Data URI or null on failure.
*/
protected function file_to_data_uri( string $file_path ): ?string {
$mime_type = wp_check_filetype( $file_path )['type'];
if ( ! $mime_type ) {
return null;
}
$contents = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
if ( false === $contents ) {
return null;
}
return 'data:' . $mime_type . ';base64,' . base64_encode( $contents );
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
protected function permission_callback( $args ) {
$attachment_id = isset( $args['attachment_id'] ) ? absint( $args['attachment_id'] ) : null;
if ( $attachment_id ) {
$attachment = get_post( $attachment_id );
if ( ! $attachment ) {
return new WP_Error(
'attachment_not_found',
/* translators: %d: Attachment ID. */
sprintf( esc_html__( 'Attachment with ID %d not found.', 'ai' ), $attachment_id )
);
}
// Check if user can edit this attachment.
if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to edit this attachment.', 'ai' )
);
}
} elseif ( ! current_user_can( 'upload_files' ) ) {
// For URL-based generation, require upload_files capability.
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to generate alt text.', 'ai' )
);
}
return true;
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
protected function meta(): array {
return array(
'show_in_rest' => true,
'mcp' => array(
'public' => true,
'type' => 'tool',
'category' => 'media',
),
);
}
}