plugin = $plugin;
// Auto-detect on upload.
add_action( 'add_attachment', array( $this, 'auto_detect_on_upload' ) );
// Attachment edit UI: manual flag.
add_filter( 'attachment_fields_to_edit', array( $this, 'attachment_field' ), 10, 2 );
add_filter( 'attachment_fields_to_save', array( $this, 'save_attachment_field' ), 10, 2 );
// Front-end: visible badge + machine-readable attributes on image blocks.
add_filter( 'render_block', array( $this, 'filter_image_block' ), 10, 2 );
add_filter( 'wp_get_attachment_image_attributes', array( $this, 'image_attributes' ), 10, 2 );
// Machine-readable page-level declaration.
add_action( 'wp_footer', array( $this, 'output_jsonld' ) );
}
/**
* Whether labeling is enabled.
*
* @return bool
*/
private function enabled() {
return (bool) $this->plugin->get_setting( 'media_labels_enabled' );
}
/**
* Is an attachment flagged as AI-generated?
*
* @param int $attachment_id Attachment ID.
* @return bool
*/
public function is_ai( $attachment_id ) {
return 'yes' === get_post_meta( $attachment_id, self::META_KEY, true );
}
/**
* Badge text (custom or default).
*
* @return string
*/
public function badge_text() {
$text = trim( (string) $this->plugin->get_setting( 'media_badge_text' ) );
if ( '' === $text ) {
$text = $this->plugin->default_media_text();
}
/** Documented in class-adfc-chatbot-disclosure.php pattern. */
return apply_filters( 'adfc_media_badge_text', $text );
}
/**
* On upload: inspect file metadata and flag when AI provenance found.
*
* @param int $attachment_id New attachment ID.
* @return void
*/
public function auto_detect_on_upload( $attachment_id ) {
if ( ! $this->enabled() || ! $this->plugin->get_setting( 'media_auto_detect' ) ) {
return;
}
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return;
}
$file = get_attached_file( $attachment_id );
if ( ! $file ) {
return;
}
$reader = new ADFC_Metadata_Reader();
$result = $reader->inspect( $file );
if ( $result['detected'] ) {
update_post_meta( $attachment_id, self::META_KEY, 'yes' );
update_post_meta( $attachment_id, self::META_METHOD_KEY, sanitize_key( $result['method'] ) );
}
}
/**
* Add an "AI-generated" checkbox to the attachment edit screen.
*
* @param array $form_fields Existing fields.
* @param WP_Post $post Attachment post.
* @return array
*/
public function attachment_field( $form_fields, $post ) {
$checked = $this->is_ai( $post->ID ) ? 'checked="checked"' : '';
$method = get_post_meta( $post->ID, self::META_METHOD_KEY, true );
$hint = '';
if ( $method ) {
$hint = '
' . esc_html(
sprintf(
/* translators: %s: detection method id. */
__( 'Auto-detected from file metadata (%s).', 'ai-disclosure-for-chatbots' ),
$method
)
) . '
';
}
$form_fields['adfc_ai_generated'] = array(
'label' => __( 'AI-generated', 'ai-disclosure-for-chatbots' ),
'input' => 'html',
'html' => ' '
. esc_html__( 'Label this image as AI-generated (EU AI Act, Art. 50)', 'ai-disclosure-for-chatbots' )
. $hint,
);
return $form_fields;
}
/**
* Save the attachment checkbox.
*
* @param array $post Attachment data.
* @param array $attachment Submitted fields.
* @return array
*/
public function save_attachment_field( $post, $attachment ) {
if ( isset( $attachment['adfc_ai_generated'] ) && 'yes' === $attachment['adfc_ai_generated'] ) {
update_post_meta( $post['ID'], self::META_KEY, 'yes' );
} else {
delete_post_meta( $post['ID'], self::META_KEY );
}
return $post;
}
/**
* Add badge wrapper class + data attributes to core image/gallery blocks
* whose attachment is flagged.
*
* @param string $block_content Rendered block HTML.
* @param array $block Block data.
* @return string
*/
public function filter_image_block( $block_content, $block ) {
if ( ! $this->enabled() || is_admin() ) {
return $block_content;
}
if ( empty( $block['blockName'] ) || ! in_array( $block['blockName'], array( 'core/image', 'core/cover', 'core/media-text' ), true ) ) {
return $block_content;
}
$attachment_id = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : ( isset( $block['attrs']['mediaId'] ) ? (int) $block['attrs']['mediaId'] : 0 );
if ( ! $attachment_id || ! $this->is_ai( $attachment_id ) ) {
return $block_content;
}
$block_content = (string) $block_content;
if ( '' === trim( $block_content ) ) {
return $block_content;
}
$style = (string) $this->plugin->get_setting( 'media_badge_style' );
$badge = ''
. $this->badge_svg()
. '' . esc_html( $this->badge_text() ) . '';
// The badge markup (including the filterable SVG icon) is rendered by
// the render_block filter, so it must be sanitized as a whole before
// it is returned for output.
$badge = wp_kses( $badge, self::badge_allowed_html() );
// core/media-text keeps its image in a dedicated inner and can
// visually swap media/text sides (e.g. "media on the right"), so
// wrapping the whole block would attach the absolutely-positioned
// badge to the wrong side. Target that inner media figure directly.
if ( 'core/media-text' === $block['blockName'] ) {
return $this->badge_media_text_block( $block_content, $badge );
}
// Wrap: add positioning class on the outer element and append badge inside it.
$block_content = preg_replace(
'/^(\s*<(?:figure|div)\b[^>]*class=")/',
'$1adfc-ai-media ',
$block_content,
1,
$replaced
);
if ( ! $replaced ) {
return '' . $block_content . $badge . '';
}
// Insert badge before the closing tag of the wrapper. Uses a callback
// (rather than embedding $badge in a replacement string) because the
// badge text is admin-controlled and a literal "$1" or "\1" in it
// would otherwise be misread by preg_replace() as a backreference.
$block_content = preg_replace_callback(
'/(<\/(?:figure|div)>\s*)$/',
function ( $matches ) use ( $badge ) {
return $badge . $matches[1];
},
$block_content,
1
);
return $block_content;
}
/**
* Attach the badge to the inner media of a core/media-text
* block instead of its outer wrapper (see filter_image_block()).
*
* @param string $block_content Rendered block HTML.
* @param string $badge Badge markup to insert.
* @return string
*/
private function badge_media_text_block( $block_content, $badge ) {
$with_class = preg_replace(
'/(]*\bclass="[^"]*\bwp-block-media-text__media\b)[^>]*\bclass=")/',
'$1adfc-ai-media ',
$block_content,
1,
$replaced
);
if ( ! $replaced ) {
return '' . $block_content . $badge . '';
}
// See the callback note in filter_image_block(): $badge (admin-
// controlled text) must not be embedded in a preg_replace()
// replacement string.
$with_badge = preg_replace_callback(
'/(]*\bclass="[^"]*\badfc-ai-media\b[^"]*"[^>]*>.*?)(<\/figure>)/s',
function ( $matches ) use ( $badge ) {
return $matches[1] . $badge . $matches[2];
},
$with_class,
1,
$badge_replaced
);
return $badge_replaced ? $with_badge : $with_class;
}
/**
* Machine-readable per-image attribute for flagged attachments.
*
* @param array $attr Image attributes.
* @param WP_Post $attachment Attachment post.
* @return array
*/
public function image_attributes( $attr, $attachment ) {
if ( ! $this->enabled() || ! $this->plugin->get_setting( 'media_machine_readable' ) ) {
return $attr;
}
if ( $attachment && $this->is_ai( $attachment->ID ) ) {
$attr['data-adfc-ai-generated'] = 'true';
}
return $attr;
}
/**
* Output schema.org JSON-LD declaring flagged images on singular pages
* as ImageObject with digitalSourceType trainedAlgorithmicMedia.
*
* @return void
*/
public function output_jsonld() {
if ( ! $this->enabled() || ! $this->plugin->get_setting( 'media_machine_readable' ) || ! is_singular() ) {
return;
}
$post = get_post();
if ( ! $post ) {
return;
}
$ids = array();
// Featured image.
$thumb_id = get_post_thumbnail_id( $post );
if ( $thumb_id && $this->is_ai( $thumb_id ) ) {
$ids[] = $thumb_id;
}
// Images referenced by blocks in content.
if ( function_exists( 'parse_blocks' ) && has_blocks( $post ) ) {
$blocks = parse_blocks( $post->post_content );
$ids = array_merge( $ids, $this->collect_flagged_ids( $blocks ) );
}
$ids = array_values( array_unique( array_filter( $ids ) ) );
if ( empty( $ids ) ) {
return;
}
$graph = array();
foreach ( $ids as $id ) {
$src = wp_get_attachment_image_url( $id, 'full' );
if ( ! $src ) {
continue;
}
$graph[] = array(
'@type' => 'ImageObject',
'contentUrl' => $src,
'digitalSourceType' => 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia',
'creditText' => $this->badge_text(),
);
}
if ( empty( $graph ) ) {
return;
}
$payload = array(
'@context' => 'https://schema.org',
'@graph' => $graph,
);
echo '' . "\n";
}
/**
* Recursively collect flagged attachment IDs from parsed blocks.
*
* @param array $blocks Parsed blocks.
* @return array
*/
private function collect_flagged_ids( $blocks ) {
$ids = array();
foreach ( $blocks as $block ) {
$id = 0;
if ( isset( $block['attrs']['id'] ) ) {
$id = (int) $block['attrs']['id'];
} elseif ( isset( $block['attrs']['mediaId'] ) ) {
$id = (int) $block['attrs']['mediaId'];
}
if ( $id && $this->is_ai( $id ) ) {
$ids[] = $id;
}
if ( isset( $block['attrs']['ids'] ) && is_array( $block['attrs']['ids'] ) ) {
foreach ( $block['attrs']['ids'] as $gallery_id ) {
if ( $this->is_ai( (int) $gallery_id ) ) {
$ids[] = (int) $gallery_id;
}
}
}
if ( ! empty( $block['innerBlocks'] ) ) {
$ids = array_merge( $ids, $this->collect_flagged_ids( $block['innerBlocks'] ) );
}
}
return $ids;
}
/**
* Small inline "AI" glyph used in the badge (neutral styling inspired by
* the EU transparency icon direction; the official EU icon set can be
* dropped in via the adfc_media_badge_svg filter once licensing of the
* asset files is confirmed for redistribution).
*
* @return string
*/
/**
* Allowed HTML for the badge markup passed through wp_kses().
*
* @return array
*/
private static function badge_allowed_html() {
return array(
'span' => array(
'class' => true,
'role' => true,
'aria-label' => true,
),
'svg' => array(
'class' => true,
'viewbox' => true,
'width' => true,
'height' => true,
'aria-hidden' => true,
'focusable' => true,
'xmlns' => true,
),
'rect' => array(
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'rx' => true,
'fill' => true,
'opacity' => true,
),
'path' => array(
'd' => true,
'fill' => true,
),
'g' => array(
'fill' => true,
'transform' => true,
),
);
}
private function badge_svg() {
$svg = '';
/**
* Filter the badge icon SVG.
*
* @param string $svg Inline SVG markup.
*/
return apply_filters( 'adfc_media_badge_svg', $svg );
}
}