, 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.
}
}