, content images, * and masonry-fixed CSS background-image containers. * * @package AIM_Transparency * @since 1.0.0 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Renders front-end AI-disclosure badges and JSON-LD metadata for flagged images. * * @since 1.0.0 */ class AICL_Frontend { /** @var int[] Flagged attachment IDs seen this request, for JSON-LD. */ protected $seen = array(); /** @var array URL => attachment-id resolution cache (per request). */ protected $url_cache = array(); /** * Registers the front-end image, content, asset, and JSON-LD hooks. * * @since 1.0.0 * * @return void */ public function hooks() { if ( is_admin() ) { return; } if ( ! AICL_Plugin::opt( 'badge_enabled' ) && ! AICL_Plugin::opt( 'jsonld_enabled' ) ) { return; } add_filter( 'wp_get_attachment_image_attributes', array( $this, 'img_attributes' ), 100, 3 ); add_filter( 'post_thumbnail_html', array( $this, 'thumbnail_html' ), 100, 5 ); add_filter( 'wp_content_img_tag', array( $this, 'content_img_tag' ), 100, 3 ); add_filter( 'the_content', array( $this, 'content' ), 999 ); add_filter( 'render_block', array( $this, 'render_block' ), 100, 2 ); add_action( 'wp_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'wp_footer', array( $this, 'json_ld' ), 20 ); } /* ---------------------------------------------------------------------- */ /* Image filters */ /* ---------------------------------------------------------------------- */ /** * Adds AI-disclosure data attributes to a flagged attachment's image markup. * * @since 1.0.0 * * @param array $attr Computed HTML attributes for the image element. * @param WP_Post $attachment Attachment post object for the image. * @param string|int[] $size Requested image size name or width/height array. * @return array Filtered image attributes, with AICL markers added when flagged. */ public function img_attributes( $attr, $attachment, $size ) { $id = is_object( $attachment ) ? (int) $attachment->ID : 0; $this->track( $id ); $token = AICL_Plugin::display_type( $id ); if ( '' === $token ) { return $attr; } $attr['class'] = trim( ( isset( $attr['class'] ) ? $attr['class'] : '' ) . ' aicl-img' ); $attr['data-aicl'] = '1'; $attr['data-aicl-type'] = $token; $attr['data-aicl-label'] = AICL_Plugin::badge_text_for( $token ); return $attr; } /** * Injects AICL markers into a flagged post thumbnail's image tag. * * @since 1.0.0 * * @param string $html The post thumbnail HTML. * @param int $post_id The post ID the thumbnail belongs to. * @param int $thumbnail_id The thumbnail attachment ID. * @param string|int[] $size Requested image size name or width/height array. * @param string|array $attr Query string or array of attributes for the image. * @return string Thumbnail HTML, with AICL markers added when flagged. */ public function thumbnail_html( $html, $post_id, $thumbnail_id, $size, $attr ) { $this->track( (int) $thumbnail_id ); $token = AICL_Plugin::display_type( $thumbnail_id ); if ( '' === $token || false === strpos( $html, ']*>/i', function ( $m ) use ( $token ) { return $this->inject_img( $m[0], $token ); }, $html, 1 ); } /* ---------------------------------------------------------------------- */ /* Content pass */ /* ---------------------------------------------------------------------- */ /** * Marks flagged images within the post content output. * * @since 1.0.0 * * @param string $html The post content HTML. * @return string Content HTML, with AICL markers added when flagged images exist. */ public function content( $html ) { if ( '' === $html || AICL_Plugin::flagged_count() < 1 ) { return $html; } return $this->mark_html( $html ); } /** * Marks flagged images in rendered block output for block/FSE themes that render images outside the_content. * * @since 1.0.0 * * @param string $html The rendered block HTML. * @param array $block The parsed block array. * @return string Block HTML, with AICL markers added when flagged images exist. */ public function render_block( $html, $block ) { if ( '' === $html || AICL_Plugin::flagged_count() < 1 ) { return $html; } return $this->mark_html( $html ); } /** * Marks a single content image via the per-image filter, where core resolves the attachment ID for us (WP 6.0+). * * @since 1.0.0 * * @param string $html The image tag HTML. * @param string $context Additional context, such as the calling function name. * @param int $attachment_id The attachment ID resolved by core. * @return string Image HTML, with AICL markers added when flagged. */ public function content_img_tag( $html, $context, $attachment_id ) { $this->track( (int) $attachment_id ); $token = AICL_Plugin::display_type( $attachment_id ); if ( '' === $token ) { return $html; } return $this->inject_img( $html, $token ); } /** * Marks flagged elements (by wp-image-{ID}) and CSS-background elements in any HTML fragment. * * @since 1.0.0 * * @param string $html The HTML fragment to scan and mark. * @return string The HTML fragment, with AICL markers added to flagged elements. */ protected function mark_html( $html ) { if ( false === strpos( $html, 'wp-image-' ) && false === strpos( $html, 'background-image' ) ) { return $html; } $html = preg_replace_callback( '/]*\bclass=("|\')[^"\']*wp-image-(\d+)[^"\']*\1[^>]*>/i', function ( $m ) { $this->track( (int) $m[2] ); $token = AICL_Plugin::display_type( (int) $m[2] ); if ( '' === $token ) { return $m[0]; } return $this->inject_img( $m[0], $token ); }, $html ); $html = preg_replace_callback( '/<[a-z][a-z0-9]*\b[^>]*\bstyle=("|\')[^"\']*background-image\s*:\s*url\([^)]*\)[^"\']*\1[^>]*>/i', array( $this, 'mark_bg_element' ), $html ); return $html; } /** * Marks a single CSS background-image element matched by mark_html(). * * @since 1.0.0 * * @param string[] $m Regex match array; index 0 is the full element opening tag. * @return string The element tag, with AICL markers added when the background image is flagged. */ public function mark_bg_element( $m ) { $tag = $m[0]; if ( false !== strpos( $tag, 'data-aicl' ) ) { return $tag; } if ( ! preg_match( '/background-image\s*:\s*url\(\s*([\'"]?)([^)\'"]+)\1\s*\)/i', $tag, $u ) ) { return $tag; } $id = $this->resolve_url( trim( $u[2] ) ); if ( ! $id ) { return $tag; } $this->track( $id ); $token = AICL_Plugin::display_type( $id ); if ( '' === $token ) { return $tag; } $attrs = sprintf( ' data-aicl="1" data-aicl-type="%s" data-aicl-label="%s"', esc_attr( $token ), esc_attr( AICL_Plugin::badge_text_for( $token ) ) ); $tag = preg_replace( '/\bclass=("|\')(.*?)\1/i', 'class=$1$2 aicl-bg$1', $tag, 1, $had_class ); if ( ! $had_class ) { $tag = substr( $tag, 0, -1 ) . ' class="aicl-bg">'; } return substr( $tag, 0, -1 ) . $attrs . '>'; } /* ---------------------------------------------------------------------- */ /* Helpers */ /* ---------------------------------------------------------------------- */ /** * Adds AICL data attributes to an tag string in an idempotent way. * * @since 1.0.0 * * @param string $tag The image tag HTML to modify. * @param string $token The source-type token for the flagged image. * @return string The image tag, with AICL markers added unless already present. */ protected function inject_img( $tag, $token ) { if ( false !== strpos( $tag, 'data-aicl' ) ) { return $tag; } $attrs = sprintf( ' data-aicl="1" data-aicl-type="%s" data-aicl-label="%s"', esc_attr( $token ), esc_attr( AICL_Plugin::badge_text_for( $token ) ) ); if ( preg_match( '/\bclass=("|\')(.*?)\1/i', $tag, $cm ) ) { $tag = str_replace( $cm[0], 'class=' . $cm[1] . trim( $cm[2] . ' aicl-img' ) . $cm[1], $tag ); } else { $tag = preg_replace( '/url_cache[ $url ] ) ) { return $this->url_cache[ $url ]; } $key = 'url_' . md5( $url ); $cached = wp_cache_get( $key, 'aicl' ); if ( false !== $cached ) { $this->url_cache[ $url ] = (int) $cached; return (int) $cached; } $base = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $url ); $id = attachment_url_to_postid( $base ); if ( ! $id && $base !== $url ) { $id = attachment_url_to_postid( $url ); } wp_cache_set( $key, (int) $id, 'aicl', HOUR_IN_SECONDS ); $this->url_cache[ $url ] = $id; return $id; } /* ---------------------------------------------------------------------- */ /* Assets */ /* ---------------------------------------------------------------------- */ /** * Enqueues the badge stylesheet and script and outputs the inline badge configuration. * * @since 1.0.0 * * @return void */ public function assets() { if ( ! AICL_Plugin::opt( 'badge_enabled' ) ) { return; } $css_path = AICL_DIR . 'assets/css/badge.css'; $js_path = AICL_DIR . 'assets/js/badge.js'; $css_ver = file_exists( $css_path ) ? filemtime( $css_path ) : AICL_VERSION; $js_ver = file_exists( $js_path ) ? filemtime( $js_path ) : AICL_VERSION; wp_enqueue_style( 'aicl-badge', AICL_URL . 'assets/css/badge.css', array(), $css_ver ); wp_enqueue_script( 'aicl-badge', AICL_URL . 'assets/js/badge.js', array(), $js_ver, true ); $pos = AICL_Plugin::opt( 'badge_position' ); $defaults = AICL_Plugin::defaults(); $bg = sanitize_hex_color( AICL_Plugin::opt( 'badge_bg' ) ); $color = sanitize_hex_color( AICL_Plugin::opt( 'badge_color' ) ); if ( empty( $bg ) ) { $bg = $defaults['badge_bg']; } if ( empty( $color ) ) { $color = $defaults['badge_color']; } $radius = (int) AICL_Plugin::opt( 'badge_radius' ); $rcss = 0 === $radius ? '0' : ( 2 === $radius ? '999px' : '8px' ); $bwidth = (int) AICL_Plugin::opt( 'badge_border' ); $bcolor = sanitize_hex_color( AICL_Plugin::opt( 'badge_border_color' ) ); if ( empty( $bcolor ) ) { $bcolor = '#ffffff'; } $shadow = (int) AICL_Plugin::opt( 'badge_shadow' ); $scss = 0 === $shadow ? 'none' : ( 2 === $shadow ? '0 6px 18px rgba(0,0,0,0.5)' : '0 2px 8px rgba(0,0,0,0.35)' ); $css = sprintf( ':root{--aicl-bg:%s;--aicl-color:%s;--aicl-radius:%s;--aicl-border-width:%dpx;--aicl-border-color:%s;--aicl-shadow:%s}', $bg, $color, $rcss, $bwidth, $bcolor, $scss ); wp_add_inline_style( 'aicl-badge', $css ); $eu_variant = ( 'white' === AICL_Plugin::opt( 'badge_eu_variant' ) ) ? 'white' : 'black'; // The custom logo is for the built-in badge only; never ship it for the official EU icon. $badge_style = AICL_Plugin::opt( 'badge_style' ); $show_logo = 'eu' !== $badge_style && 'eu_basic' !== $badge_style; $config = array( 'position' => $pos, 'style' => $badge_style, 'shape' => AICL_Plugin::opt( 'badge_shape' ), 'iconBase' => AICL_URL . 'assets/eu-icons/', 'iconVariant' => $eu_variant, 'modal' => (bool) AICL_Plugin::opt( 'badge_modal' ), 'modalTitle' => (string) AICL_Plugin::opt( 'badge_modal_title' ), 'modalText' => (string) AICL_Plugin::opt( 'badge_modal_text' ), 'logo' => $show_logo ? (string) AICL_Plugin::opt( 'badge_logo' ) : '', 'typeHeadings' => array( 'trainedAlgorithmicMedia' => __( 'AI-generated image', 'aim-transparency' ), 'compositeWithTrainedAlgorithmicMedia' => __( 'AI-edited image', 'aim-transparency' ), 'basicAI' => __( 'AI content', 'aim-transparency' ), 'algorithmicMedia' => __( 'Algorithmically generated image', 'aim-transparency' ), 'digitalCapture' => __( 'Camera photograph', 'aim-transparency' ), 'digitalCreation' => __( 'Human-created image', 'aim-transparency' ), 'composite' => __( 'Composite image', 'aim-transparency' ), ), 'typeBodies' => array( 'trainedAlgorithmicMedia' => __( 'This image was generated by an artificial-intelligence model.', 'aim-transparency' ), 'compositeWithTrainedAlgorithmicMedia' => __( 'This is a real image that was modified using artificial intelligence.', 'aim-transparency' ), 'basicAI' => __( 'Artificial intelligence was involved in creating this image.', 'aim-transparency' ), 'algorithmicMedia' => __( 'This image was produced by an algorithm (not AI).', 'aim-transparency' ), 'digitalCapture' => __( 'This photograph was captured from real life with a camera. No AI was involved.', 'aim-transparency' ), 'digitalCreation' => __( 'This image was created by a human using digital tools, without generative AI.', 'aim-transparency' ), 'composite' => __( 'This image is a composite of non-AI elements, such as photographs and 3D renders.', 'aim-transparency' ), ), 'ariaAi' => __( 'artificially generated', 'aim-transparency' ), 'ariaNonAi' => __( 'content disclosure', 'aim-transparency' ), 'closeLabel' => __( 'Close', 'aim-transparency' ), 'modalFooter' => __( 'Disclosed under the EU AI Act (Article 50), labelled by the site owner.', 'aim-transparency' ), ); if ( AICL_Plugin::opt( 'scan_all' ) && AICL_Plugin::flagged_count() > 0 ) { $config['scanAll'] = true; $config['map'] = $this->flagged_url_map(); } wp_add_inline_script( 'aicl-badge', 'window.AICL=' . wp_json_encode( $config ) . ';', 'before' ); } /** * Builds a map of flagged attachment basenames to badge labels for the JS universal scan. * * @since 1.0.0 * * @return array Map of attachment basename to a [token, badge label] pair. */ protected function flagged_url_map() { $cached = get_transient( 'aicl_url_map' ); if ( is_array( $cached ) ) { return $cached; } $ids = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids', 'posts_per_page' => 500, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- bounded, cached front-end map. 'meta_key' => AICL_Plugin::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- bounded, cached front-end map. 'no_found_rows' => true, ) ); $map = array(); foreach ( $ids as $id ) { $file = get_attached_file( $id ); $token = AICL_Plugin::display_type( $id ); if ( $file && '' !== $token ) { $map[ wp_basename( $file ) ] = array( $token, AICL_Plugin::badge_text_for( $token ) ); } } set_transient( 'aicl_url_map', $map, 12 * HOUR_IN_SECONDS ); return $map; } /* ---------------------------------------------------------------------- */ /* JSON-LD */ /* ---------------------------------------------------------------------- */ /** * Records a flagged attachment for the footer JSON-LD graph. * * Runs before the visible-badge gate at every front-end injection point, so * badge-hidden source types (e.g. provenance-only types) still emit their * machine-readable JSON-LD as should_badge() documents. * * @since 1.2.1 * * @param int $attachment_id The attachment ID. * @return void */ protected function track( $attachment_id ) { if ( $attachment_id > 0 && '' !== AICL_Plugin::get_type( $attachment_id ) ) { $this->seen[ $attachment_id ] = true; } } /** * Outputs JSON-LD ImageObject metadata for every flagged image seen this request. * * @since 1.0.0 * * @return void */ public function json_ld() { if ( ! AICL_Plugin::opt( 'jsonld_enabled' ) || empty( $this->seen ) ) { return; } $graph = array(); foreach ( array_keys( $this->seen ) as $id ) { $token = AICL_Plugin::get_type( $id ); $url = wp_get_attachment_image_url( $id, 'full' ); if ( '' === $token || ! $url ) { continue; } $graph[] = array( '@type' => 'ImageObject', 'contentUrl' => $url, 'creditText' => AICL_Plugin::label_for( $token ), 'additionalProperty' => array( '@type' => 'PropertyValue', 'propertyID' => 'IPTC:DigitalSourceType', 'value' => AICL_Plugin::uri_for( $token ), ), ); } if ( ! $graph ) { return; } $doc = array( '@context' => 'https://schema.org', '@graph' => $graph, ); echo "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() returns inherently-escaped JSON. } }