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 ); } }