Plugin Directory

Changeset 3705284


Ignore:
Timestamp:
09/21/2026 09:48:42 AM (8 weeks ago)
Author:
goldenpixel
Message:

Release OznaczAI 1.7.0

Location:
oznaczai-image-labeling
Files:
18 added
18 edited
1 copied

Legend:

Unmodified
Added
Removed
  • oznaczai-image-labeling/tags/1.7.0/assets/css/admin.css

    r3641997 r3705284  
    595595}
    596596
     597/* Image symbol variants inside the admin previews. Mirrors the frontend rule so
     598   the preview keeps its WYSIWYG promise. */
     599.oznaczai-preset-demo b.oznaczai-preview-symbol--image,
     600.oznaczai-preview-badge b.oznaczai-preview-symbol--image,
     601.oznaczai-symbol-preview b.oznaczai-preview-symbol--image {
     602        min-width: 0;
     603        width: calc(18px * var(--oznaczai-preview-icon-ratio, 1));
     604        height: 18px;
     605        border-radius: 0;
     606        background-color: transparent;
     607        background-image: var(--oznaczai-preview-icon-on-light);
     608        background-repeat: no-repeat;
     609        background-position: center;
     610        background-size: contain;
     611        font-size: 0;
     612}
     613
     614.oznaczai-preview-badge--minimal b.oznaczai-preview-symbol--image,
     615.oznaczai-preview-badge--dark b.oznaczai-preview-symbol--image,
     616.oznaczai-preview-badge--contrast-light b.oznaczai-preview-symbol--image {
     617        background-image: var(--oznaczai-preview-icon-on-dark);
     618}
     619
    597620.oznaczai-preview-badge--icon-only {
    598621        width: 24px;
    … …  
    673696        border-top: 1px solid #e4e8ef;
    674697        align-items: start;
     698}
     699
     700.oznaczai-shortcode {
     701        display: inline-block;
     702        padding: 4px 8px;
     703        border: 1px solid #e4e8ef;
     704        border-radius: 4px;
     705        background: #f6f7f9;
     706        font-size: 12px;
     707}
     708
     709.oznaczai-symbol-row {
     710        display: grid;
     711        grid-template-columns: minmax(0, 280px) minmax(0, 1fr);
     712        gap: 12px 24px;
     713        align-items: start;
     714        margin-top: 18px;
     715        padding-top: 16px;
     716        border-top: 1px solid #e4e8ef;
     717}
     718
     719.oznaczai-symbol-row > .description {
     720        margin: 0;
     721        align-self: center;
     722        color: #6b7280;
     723        font-size: 12px;
     724        line-height: 1.5;
     725}
     726
     727.oznaczai-symbol-row > .oznaczai-toggle {
     728        grid-column: 1 / -1;
     729}
     730
     731@media (max-width: 782px) {
     732        .oznaczai-symbol-row {
     733                grid-template-columns: minmax(0, 1fr);
     734        }
    675735}
    676736
  • oznaczai-image-labeling/tags/1.7.0/assets/css/frontend.css

    r3641997 r3705284  
    258258}
    259259
     260/* AI transparency legend ([oznaczai_transparency_legend]).
     261   Deliberately plain: it inherits the theme's typography and colours so it
     262   reads as part of the page rather than as a plugin widget. */
     263.oznaczai-legend {
     264        margin: 0 0 1.5em;
     265}
     266
     267.oznaczai-legend__title {
     268        margin: 0 0 0.75em;
     269        font-size: 1.15em;
     270}
     271
     272.oznaczai-legend__list {
     273        margin: 0;
     274        padding: 0;
     275        list-style: none;
     276}
     277
     278.oznaczai-legend__item {
     279        display: flex;
     280        align-items: flex-start;
     281        gap: 0.75em;
     282        padding: 0.6em 0;
     283        border-bottom: 1px solid rgba(127, 127, 127, 0.22);
     284}
     285
     286.oznaczai-legend__item:last-child {
     287        border-bottom: 0;
     288}
     289
     290.oznaczai-legend__symbol {
     291        display: inline-flex;
     292        align-items: center;
     293        justify-content: center;
     294        flex: 0 0 auto;
     295        width: 20px;
     296        height: 20px;
     297        margin-top: 0.15em;
     298        border-radius: 3px;
     299        background: rgba(127, 127, 127, 0.16);
     300        font-size: 9px;
     301        font-weight: 600;
     302        line-height: 1;
     303}
     304
     305.oznaczai-legend__symbol--image {
     306        width: auto;
     307        max-width: 100%;
     308        border-radius: 0;
     309        background: none;
     310}
     311
     312.oznaczai-legend__body {
     313        display: flex;
     314        flex-direction: column;
     315        gap: 0.15em;
     316        min-width: 0;
     317}
     318
     319.oznaczai-legend__name {
     320        font-weight: 600;
     321}
     322
     323.oznaczai-legend__description {
     324        opacity: 0.85;
     325}
     326
     327.oznaczai-legend__note {
     328        margin: 0.9em 0 0;
     329        font-size: 0.875em;
     330        opacity: 0.75;
     331}
     332
     333/* Image symbol variants (official EU icons).
     334   The artwork is a fixed two-tone drawing, so it is placed as an image rather
     335   than recoloured. The white artwork is the default because the default preset
     336   draws a light label on a dark translucent surface; the black artwork is used
     337   wherever the label sits on a light surface. */
     338.oznaczai-badge__icon--image {
     339        width: calc(var(--oznaczai-icon-size, 13px) * var(--oznaczai-icon-ratio, 1));
     340        height: var(--oznaczai-icon-size, 13px);
     341        border-radius: 0;
     342        background-color: transparent;
     343        background-image: var(--oznaczai-icon-on-dark);
     344        background-repeat: no-repeat;
     345        background-position: center;
     346        background-size: contain;
     347        font-size: 0;
     348}
     349
     350.oznaczai-badge--light .oznaczai-badge__icon--image,
     351.oznaczai-badge--glass .oznaczai-badge__icon--image,
     352.oznaczai-badge--caption .oznaczai-badge__icon--image,
     353.oznaczai-badge--icon-only .oznaczai-badge__icon--image,
     354.oznaczai-badge--contrast-dark .oznaczai-badge__icon--image,
     355.oznaczai-bg-note.oznaczai-badge--contrast-dark .oznaczai-badge__icon--image {
     356        background-color: transparent;
     357        background-image: var(--oznaczai-icon-on-light);
     358}
     359
     360.oznaczai-badge--contrast-light .oznaczai-badge__icon--image,
     361.oznaczai-bg-note.oznaczai-badge--contrast-light .oznaczai-badge__icon--image {
     362        background-color: transparent;
     363        background-image: var(--oznaczai-icon-on-dark);
     364}
     365
     366/* When the symbol already spells out its own meaning there is no text node to
     367   space away from it. */
     368.oznaczai-badge--symbol-lockup {
     369        gap: 0;
     370}
     371
    260372.oznaczai-badge__text {
    261373        min-width: 0;
  • oznaczai-image-labeling/tags/1.7.0/assets/js/admin.js

    r3641997 r3705284  
    221221                }
    222222
    223                 function renderBadge(element, template, label, forceIcon) {
     223                function escapeAttribute(value) {
     224                        return escapeText(value).replace(/"/g, '"');
     225                }
     226
     227                function activeSymbol(status) {
     228                        var variants = window.oznaczaiImageLabelingAdmin && window.oznaczaiImageLabelingAdmin.iconVariants
     229                                ? window.oznaczaiImageLabelingAdmin.iconVariants
     230                                : {};
     231                        var variant = variants[fieldValue('icon_variant', 'oznaczai')] || variants.oznaczai;
     232                        if (!variant || !variant.symbols) {
     233                                return null;
     234                        }
     235                        return variant.symbols[status] || variant.symbols.generated || null;
     236                }
     237
     238                function symbolMarkup(status) {
     239                        var symbol = activeSymbol(status);
     240
     241                        if (symbol && symbol.onLight && symbol.onDark) {
     242                                return '<b aria-hidden="true" class="oznaczai-preview-symbol--image" style="' +
     243                                        '--oznaczai-preview-icon-on-light:url(&quot;' + escapeAttribute(symbol.onLight) + '&quot;);' +
     244                                        '--oznaczai-preview-icon-on-dark:url(&quot;' + escapeAttribute(symbol.onDark) + '&quot;);' +
     245                                        '--oznaczai-preview-icon-ratio:' + (parseFloat(symbol.ratio) || 1) + '"></b>';
     246                        }
     247
     248                        return '<b aria-hidden="true">' + escapeText(symbol && symbol.text ? symbol.text : 'AI') + '</b>';
     249                }
     250
     251                // Mirrors shouldRenderText() in frontend.js so the preview cannot promise
     252                // something the published label will not do.
     253                function symbolCarriesWording(status) {
     254                        var symbol = activeSymbol(status);
     255                        return !!(symbol && symbol.onLight && symbol.ratio && parseFloat(symbol.ratio) > 1.5);
     256                }
     257
     258                function renderBadge(element, template, label, forceIcon, status) {
    224259                        var config = applyPresetConfig(element, template);
    225260                        var showIcon = forceIcon || (iconInput && iconInput.checked);
    226                         var text = config.iconOnly ? '' : escapeText(label);
     261                        var hideText = showIcon && !checked('icon_show_text') && symbolCarriesWording(status || previewType);
     262                        var text = (config.iconOnly || hideText) ? '' : escapeText(label);
    227263                        var isPresetDemo = element.hasAttribute('data-oznaczai-preset-preview');
    228264                        var baseClass = isPresetDemo ? 'oznaczai-preset-demo' : 'oznaczai-preview-badge';
    … …  
    231267                                element.classList.add('oznaczai-preview-badge--icon-only');
    232268                        }
    233                         element.innerHTML = (showIcon ? '<b aria-hidden="true">AI</b>' : '') + text;
     269                        element.innerHTML = (showIcon ? symbolMarkup(status || previewType) : '') + text;
    234270                        element.setAttribute('aria-label', label);
    235271                        return config;
    … …  
    291327                        }
    292328                        if (behavior === 'icon' && !previewLabel.querySelector('b')) {
    293                                 previewLabel.innerHTML = '<b aria-hidden="true">AI</b>';
     329                                previewLabel.innerHTML = symbolMarkup(previewType);
    294330                        }
    295331                        previewLabel.classList.toggle('oznaczai-preview-badge--short-label', behavior === 'short');
    … …  
    384420                        input.addEventListener('change', refreshPreview);
    385421                });
     422
     423                // Symbol variant and its text toggle change both the live preview and the
     424                // preset cards, so the cards are re-rendered too.
     425                Array.prototype.forEach.call(
     426                        document.querySelectorAll('[data-oznaczai-icon-variant], input[name$="[icon_show_text]"]'),
     427                        function (input) {
     428                                input.addEventListener('change', function () {
     429                                        renderPresetCards();
     430                                        refreshPreview();
     431                                });
     432                        }
     433                );
    386434
    387435                document.addEventListener('click', function (event) {
  • oznaczai-image-labeling/tags/1.7.0/assets/js/frontend.js

    r3641997 r3705284  
    1515                '[style*="background-image"]'
    1616        ].join(',');
     17        var revolutionSelector = 'sr7-module, rs-module';
     18        var ownNodeSelector = '.oznaczai-badge, .oznaczai-bg-note, .oznaczai-popover';
    1719        var scheduled = false;
    1820        var config = window.oznaczaiImageLabelingFrontend || {};
    1921        var mediaMap = config.mediaMap || {};
    2022        var schemaEntries = {};
     23        var schemaDirty = false;
     24
     25        // Scan scheduling state. The observer collects the smallest useful roots
     26        // instead of forcing a full document rescan for every unrelated mutation.
     27        var pendingRoots = [];
     28        var pendingFull = false;
     29        var maxPendingRoots = 60;
     30
     31        // Guards against the observer reacting to OznaczAI's own DOM writes.
     32        var applying = 0;
     33        var observer = null;
     34
     35        // Caches. WeakMap/WeakSet keep entries collectable with their elements.
     36        var revolutionCache = new WeakMap();
     37        var revolutionEpoch = 0;
     38        var contrastKeys = new WeakMap();
     39        var noteSignatures = new WeakMap();
     40        var inlineBackgrounds = new WeakMap();
     41        var loadWatched = new WeakSet();
     42        var sliderScanQueued = new WeakSet();
     43
     44        var resizeTimer = null;
    2145
    2246        function meaningfulChildren(element) {
    … …  
    5579        }
    5680
    57         function buildIcon() {
     81        function iconVariant() {
     82                var variants = config.iconVariants || {};
     83                var key = config.iconVariant || 'oznaczai';
     84                return variants[key] || variants.oznaczai || null;
     85        }
     86
     87        function resolveSymbol(status) {
     88                var variant = iconVariant();
     89                if (!variant || !variant.symbols) {
     90                        return null;
     91                }
     92                return variant.symbols[status] || variant.symbols.generated || null;
     93        }
     94
     95        function buildIcon(status) {
    5896                var icon = document.createElement('span');
     97                var symbol = resolveSymbol(status);
     98
    5999                icon.className = 'oznaczai-badge__icon';
    60100                icon.setAttribute('aria-hidden', 'true');
    61                 icon.textContent = 'AI';
     101
     102                // Image symbols are drawn from a file rather than inlined, so the same
     103                // asset is cached once and the markup stays small on every page.
     104                if (symbol && symbol.onLight && symbol.onDark) {
     105                        icon.classList.add('oznaczai-badge__icon--image');
     106                        icon.style.setProperty('--oznaczai-icon-on-light', 'url("' + symbol.onLight + '")');
     107                        icon.style.setProperty('--oznaczai-icon-on-dark', 'url("' + symbol.onDark + '")');
     108                        icon.style.setProperty('--oznaczai-icon-ratio', symbol.ratio || 1);
     109                        return icon;
     110                }
     111
     112                icon.textContent = symbol && symbol.text ? symbol.text : 'AI';
    62113                return icon;
    63114        }
    64115
    65         function fillLabel(element, label, showIcon) {
     116        /**
     117         * Decide whether the wording is drawn beside the symbol. It is always kept
     118         * when there is no symbol, and it is only dropped for symbols that already
     119         * spell out their own meaning.
     120         */
     121        function shouldRenderText(status, showIcon) {
     122                var symbol;
     123
     124                if (!showIcon || config.iconShowText !== false) {
     125                        return true;
     126                }
     127
     128                symbol = resolveSymbol(status);
     129                return !(symbol && symbol.onLight && symbol.ratio && symbol.ratio > 1.5);
     130        }
     131
     132        function fillLabel(element, label, showIcon, status) {
     133                var withText = shouldRenderText(status, showIcon);
     134
    66135                element.textContent = '';
    67136                if (showIcon) {
    68                         element.appendChild(buildIcon());
    69                 }
    70                 element.appendChild(buildBadgeTextElement(label));
     137                        element.appendChild(buildIcon(status));
     138                }
     139                if (withText) {
     140                        element.appendChild(buildBadgeTextElement(label));
     141                }
     142                element.classList.toggle('oznaczai-badge--symbol-lockup', !withText);
     143                // The accessible name always carries the full wording, including when the
     144                // visible text is suppressed or the symbol is image-only.
    71145                element.setAttribute('role', 'note');
    72146                element.setAttribute('aria-label', label);
    … …  
    217291        }
    218292
     293        /**
     294         * Build a cheap key describing every input the contrast result depends on.
     295         * Canvas sampling only runs again when one of these actually changed.
     296         */
     297        function contrastKey(badge, source) {
     298                var badgeRect = badge.getBoundingClientRect();
     299                var sourceKey = '';
     300
     301                if (source && source.tagName === 'IMG') {
     302                        sourceKey = (source.currentSrc || source.getAttribute('src') || '') + ':' + (source.complete ? '1' : '0');
     303                }
     304
     305                return [
     306                        sourceKey,
     307                        Math.round(badgeRect.width),
     308                        Math.round(badgeRect.height),
     309                        Math.round(badgeRect.left),
     310                        Math.round(badgeRect.top)
     311                ].join('|');
     312        }
     313
    219314        function applyAutoContrast(badge, source, fallbackElement) {
    220315                var level;
     316                var key;
     317
     318                if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) {
     319                        clearContrastClasses(badge);
     320                        return;
     321                }
     322                if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') {
     323                        clearContrastClasses(badge);
     324                        return;
     325                }
     326
     327                key = contrastKey(badge, source);
     328                if (contrastKeys.get(badge) === key) {
     329                        return;
     330                }
    221331
    222332                clearContrastClasses(badge);
    223                 if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) {
    224                         return;
    225                 }
    226                 if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') {
    227                         return;
    228                 }
     333                // Record the attempt before sampling. A sample that cannot succeed for
     334                // these inputs (tainted canvas, fully transparent ancestry) will not
     335                // succeed on a retry either, so retrying costs work and changes nothing.
     336                // A genuine change produces a different key and is picked up normally.
     337                contrastKeys.set(badge, key);
    229338
    230339                if (source && source.tagName === 'IMG') {
    … …  
    266375        function getCurrentTemplate() {
    267376                return config.template || 'minimal';
    268         }
    269 
    270         function templateClasses() {
    271                 return [
    272                         'oznaczai-badge--minimal',
    273                         'oznaczai-badge--compact',
    274                         'oznaczai-badge--subtle',
    275                         'oznaczai-badge--light',
    276                         'oznaczai-badge--caption',
    277                         'oznaczai-badge--glass',
    278                         'oznaczai-badge--dark',
    279                         'oznaczai-badge--icon-only'
    280                 ];
    281         }
    282 
    283         function containerTemplateClasses() {
    284                 return [
    285                         'oznaczai-template--minimal',
    286                         'oznaczai-template--compact',
    287                         'oznaczai-template--subtle',
    288                         'oznaczai-template--light',
    289                         'oznaczai-template--caption',
    290                         'oznaczai-template--glass',
    291                         'oznaczai-template--dark',
    292                         'oznaczai-template--icon-only'
    293                 ];
    294377        }
    295378
    … …  
    338421
    339422        function resolveContextPosition(context, fallback, status, presetOverride) {
    340                 var style = config.style || {};
    341                 var hero = style.hero || {};
    342423                var presetConfigs = config.presetConfigs || {};
    343424                var custom = resolveStyleCustom(context);
    … …  
    458539                }
    459540
     541                if (schemaEntries[absoluteUrl]) {
     542                        return;
     543                }
     544
    460545                schemaEntries[absoluteUrl] = {
    461546                        '@type': 'ImageObject',
    … …  
    463548                        digitalSourceType: sourceType
    464549                };
     550                schemaDirty = true;
    465551        }
    466552
    … …  
    469555                var graph;
    470556
    471                 if (!config.schemaMarkup) {
     557                if (!config.schemaMarkup || !schemaDirty) {
    472558                        return;
    473559                }
    … …  
    492578                        '@graph': graph
    493579                });
     580                schemaDirty = false;
    494581        }
    495582
    … …  
    501588                var device;
    502589                var behavior;
     590                var status;
    503591
    504592                if (!badge) {
    … …  
    506594                }
    507595
     596                status = getImageStatus(image);
    508597                badge.classList.remove('oznaczai-badge--symbol-only', 'oznaczai-badge--short-label');
    509598                badge.removeAttribute('title');
    … …  
    521610                if (behavior === 'icon') {
    522611                        if (!badge.querySelector('.oznaczai-badge__icon')) {
    523                                 badge.insertBefore(buildIcon(), badge.firstChild);
     612                                badge.insertBefore(buildIcon(status), badge.firstChild);
    524613                        }
    525614                        badge.classList.add('oznaczai-badge--symbol-only');
    … …  
    536625                if (width > 0 && width <= 160) {
    537626                        if (!badge.querySelector('.oznaczai-badge__icon')) {
    538                                 badge.insertBefore(buildIcon(), badge.firstChild);
     627                                badge.insertBefore(buildIcon(status), badge.firstChild);
    539628                        }
    540629                        badge.classList.remove('oznaczai-badge--short-label');
    … …  
    546635        }
    547636
    548         function refreshResponsiveSymbols() {
    549                 var images = document.querySelectorAll(imageSelector);
     637        function findImageBadge(image) {
     638                var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image');
     639                return container ? getDirectBadge(container) : null;
     640        }
     641
     642        /**
     643         * Re-evaluate responsive behaviour for already labeled images. This depends on
     644         * viewport and element width, so it runs on resize and after image load only.
     645         */
     646        function refreshResponsiveSymbols(root) {
     647                var scope = root && root.querySelectorAll ? root : document;
     648                var images = scope.querySelectorAll(imageSelector);
     649
    550650                Array.prototype.forEach.call(images, function (image) {
    551                         var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image');
    552                         var badge = container ? getDirectBadge(container) : null;
    553                         var template = resolveTemplate(getImageStatus(image), image, null);
    554                         var showIcon = getCurrentShowIcon();
    555                         updateResponsiveBadge(image, badge, template, showIcon, getCurrentImageLabel(image));
     651                        var badge = findImageBadge(image);
     652                        var template;
     653
     654                        if (!badge) {
     655                                return;
     656                        }
     657
     658                        template = resolveTemplate(getImageStatus(image), image, null);
     659                        updateResponsiveBadge(image, badge, template, getCurrentShowIcon(), getCurrentImageLabel(image));
     660                        applyAutoContrast(badge, image, badge.parentElement);
    556661                });
    557662        }
    … …  
    561666                        return false;
    562667                }
    563                 return !!image.closest('sr7-module, rs-module');
     668                return !!image.closest(revolutionSelector);
     669        }
     670
     671        /**
     672         * Everything the rendered badge depends on. When this is unchanged there is
     673         * nothing to rebuild, so the expensive path is skipped entirely.
     674         */
     675        function imageSignature(image) {
     676                return [
     677                        image.getAttribute('data-oznaczai-type') || '',
     678                        image.getAttribute('data-oznaczai-label') || '',
     679                        image.getAttribute('data-oznaczai-custom-label') || '',
     680                        image.getAttribute('data-oznaczai-preset') || ''
     681                ].join('|');
     682        }
     683
     684        function watchImageLoad(image) {
     685                if (loadWatched.has(image) || image.complete) {
     686                        return;
     687                }
     688                loadWatched.add(image);
     689                image.addEventListener('load', function () {
     690                        var badge = findImageBadge(image);
     691                        if (!badge) {
     692                                return;
     693                        }
     694                        withOwnMutations(function () {
     695                                updateResponsiveBadge(image, badge, resolveTemplate(getImageStatus(image), image, null), getCurrentShowIcon(), getCurrentImageLabel(image));
     696                                applyAutoContrast(badge, image, badge.parentElement);
     697                        });
     698                }, { once: true });
    564699        }
    565700
    566701        function createBadge(image) {
    567                 var label = getCurrentImageLabel(image);
    568                 var position = getCurrentPosition();
    569                 var status = getImageStatus(image);
    570                 var context = resolveContext(image, null);
    571                 var template = resolveTemplate(status, image, context);
    572                 var showIcon = getCurrentShowIcon();
     702                var signature = imageSignature(image);
     703                var label;
     704                var position;
     705                var status;
     706                var context;
     707                var template;
     708                var showIcon;
    573709                var target;
    574710                var parent;
    … …  
    579715                var originalImageWidth;
    580716
     717                if (image.getAttribute('data-oznaczai-ready') === '1' && image.getAttribute('data-oznaczai-sig') === signature) {
     718                        return;
     719                }
     720
     721                label = getCurrentImageLabel(image);
     722                position = getCurrentPosition();
     723                status = getImageStatus(image);
     724                context = resolveContext(image, null);
     725                template = resolveTemplate(status, image, context);
     726                showIcon = getCurrentShowIcon();
     727
    581728                if (!label) {
    582729                        return;
    … …  
    587734                if (isManagedByRevolution(image)) {
    588735                        image.setAttribute('data-oznaczai-ready', '1');
    589                         return;
    590                 }
    591 
    592                 registerSchemaEntry(image.currentSrc || image.getAttribute('src'), getImageStatus(image));
     736                        image.setAttribute('data-oznaczai-sig', signature);
     737                        return;
     738                }
     739
     740                registerSchemaEntry(image.currentSrc || image.getAttribute('src'), status);
    593741
    594742                originalParentWidth = image.parentElement ? image.parentElement.getBoundingClientRect().width : 0;
    … …  
    684832                );
    685833                badge.classList.add('oznaczai-badge--' + template);
    686                 fillLabel(badge, label, showIcon);
     834                fillLabel(badge, label, showIcon, status);
    687835                applyStyleData(badge, status, template, context);
    688836                applyAutoContrast(badge, image, container);
    689837                updateResponsiveBadge(image, badge, template, showIcon, label);
    690838                setupPopover(badge, label);
     839                watchImageLoad(image);
    691840                image.setAttribute('data-oznaczai-ready', '1');
     841                image.setAttribute('data-oznaczai-sig', signature);
    692842        }
    693843
    … …  
    727877        }
    728878
     879        /**
     880         * mediaMap entries no longer carry the label text. Resolve it the same way a
     881         * regular image does so background and slider labels stay identical.
     882         */
     883        function resolveMapLabel(data) {
     884                var status = data && data.status ? data.status : 'generated';
     885                if (data && data.label) {
     886                        return data.label;
     887                }
     888                return (config.labels && config.labels[status]) || '';
     889        }
     890
     891        function mapEntryToData(data, url) {
     892                if (!data) {
     893                        return null;
     894                }
     895                // NOTE: `preset` is deliberately not carried over. Up to and including
     896                // 1.6.24 the background and Slider Revolution extractors never passed the
     897                // per-image preset through, so hero/slider labels have always used the
     898                // hero preset. Propagating it here would silently restyle existing sites
     899                // on upgrade, so the change is left for a release that can announce it.
     900                return {
     901                        id: data.id,
     902                        status: data.status,
     903                        label: resolveMapLabel(data),
     904                        url: url
     905                };
     906        }
     907
    729908        function addUniqueUrl(urls, value) {
    730909                var clean = typeof value === 'string' ? value.trim() : '';
    … …  
    8441023        }
    8451024
     1025        /**
     1026         * Cheap fingerprint used to decide whether the expensive full slide walk has
     1027         * to run again. Slider Revolution swaps sources and markup on slide change,
     1028         * so those are exactly the inputs worth watching.
     1029         */
     1030        function revolutionSlideKey(slide) {
     1031                var image = slide.querySelector ? slide.querySelector('img, sr7-bg, rs-bg') : null;
     1032                return [
     1033                        revolutionEpoch,
     1034                        slide.childElementCount,
     1035                        image ? (image.getAttribute('src') || image.getAttribute('data-src') || image.getAttribute('data-lazyload') || '') : '',
     1036                        slide.getAttribute('data-key') || slide.getAttribute('data-skey') || ''
     1037                ].join('|');
     1038        }
     1039
    8461040        function findAutomaticRevolutionSlideData(slide) {
    847                 var urls = extractRevolutionSlideUrls(slide);
     1041                var key = revolutionSlideKey(slide);
     1042                var cached = revolutionCache.get(slide);
     1043                var urls;
    8481044                var i;
    8491045                var data;
     1046                var result = null;
     1047
     1048                if (cached && cached.key === key) {
     1049                        return cached.data;
     1050                }
     1051
     1052                urls = extractRevolutionSlideUrls(slide);
    8501053
    8511054                for (i = 0; i < urls.length; i += 1) {
    8521055                        data = lookupMedia(urls[i]);
    8531056                        if (data) {
    854                                 return {
    855                                         id: data.id,
    856                                         status: data.status,
    857                                         label: data.label,
    858                                         url: urls[i]
    859                                 };
    860                         }
    861                 }
    862 
    863                 return null;
     1057                                result = mapEntryToData(data, urls[i]);
     1058                                break;
     1059                        }
     1060                }
     1061
     1062                revolutionCache.set(slide, { key: key, data: result });
     1063                return result;
    8641064        }
    8651065
    … …  
    8851085                        data = lookupMedia(urls[i]);
    8861086                        if (data) {
    887                                 return {
    888                                         id: data.id,
    889                                         status: data.status,
    890                                         label: data.label,
    891                                         url: urls[i]
    892                                 };
     1087                                return mapEntryToData(data, urls[i]);
    8931088                        }
    8941089                }
    … …  
    8981093
    8991094        function findSliderOwner(element) {
    900                 var revolution = element.closest('sr7-module, rs-module');
     1095                var revolution = element.closest(revolutionSelector);
    9011096                if (revolution) {
    9021097                        return revolution;
    … …  
    9101105
    9111106        function findSectionOwner(element) {
    912                 var revolution = element.closest('sr7-module, rs-module');
     1107                var revolution = element.closest(revolutionSelector);
    9131108                if (revolution) {
    9141109                        return revolution;
    … …  
    9341129                var note;
    9351130                var template;
     1131                var signature;
    9361132                if (!owner || !owner.parentNode) {
    9371133                        return null;
    … …  
    9451141                        owner.parentNode.insertBefore(note, owner.nextSibling);
    9461142                }
    947                 setBelowNoteAlignment(note, position);
    948                 fillLabel(note, label, config.showIcon !== false);
     1143
    9491144                status = status || 'generated';
    9501145                context = context || 'background';
    9511146                template = preset || resolveTemplate(status, null, context);
     1147                signature = [label, position, status, template, context].join('|');
     1148
     1149                // Slider libraries mutate slide classes on every transition frame. Without
     1150                // this guard the note would be torn down and rebuilt each time.
     1151                if (noteSignatures.get(note) === signature) {
     1152                        return note;
     1153                }
     1154                noteSignatures.set(note, signature);
     1155
     1156                setBelowNoteAlignment(note, position);
     1157                fillLabel(note, label, config.showIcon !== false, status);
    9521158                note.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only');
    9531159                note.classList.add('oznaczai-badge--' + template);
    … …  
    10541260                if (active) {
    10551261                        labeled = active.hasAttribute('data-oznaczai-slide-label') ? active : null;
    1056                 } else if (slider.matches('sr7-module, rs-module')) {
     1262                } else if (slider.matches(revolutionSelector)) {
    10571263                        labeled = null;
    10581264                } else {
    … …  
    10971303                if ('MutationObserver' in window) {
    10981304                        new MutationObserver(function () {
    1099                                 updateSliderNote(slider);
     1305                                // Slide transitions fire dozens of records per second. Collapse them
     1306                                // into one update per frame.
     1307                                if (sliderScanQueued.has(slider)) {
     1308                                        return;
     1309                                }
     1310                                sliderScanQueued.add(slider);
     1311                                window.requestAnimationFrame(function () {
     1312                                        sliderScanQueued.delete(slider);
     1313                                        withOwnMutations(function () {
     1314                                                updateSliderNote(slider);
     1315                                        });
     1316                                });
    11001317                        }).observe(slider, {
    11011318                                attributes: true,
    … …  
    11491366        function createOverlayLabel(target, data, display, context) {
    11501367                var badge = target.querySelector(':scope > .oznaczai-badge.oznaczai-badge--bg');
     1368                var status = data.status || 'generated';
     1369                var template;
     1370                var signature;
     1371
    11511372                context = context || 'background';
    1152                 var template = data.preset || resolveTemplate(data.status || 'generated', null, context);
     1373                template = data.preset || resolveTemplate(status, null, context);
     1374                signature = [data.label, display, status, template, context].join('|');
     1375
     1376                if (badge && noteSignatures.get(badge) === signature) {
     1377                        return;
     1378                }
    11531379
    11541380                target.classList.add('oznaczai-bg-target');
    … …  
    11701396                }
    11711397
     1398                noteSignatures.set(badge, signature);
     1399
    11721400                badge.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only', 'oznaczai-badge--hero-bar');
    11731401                badge.classList.add('oznaczai-badge--' + template);
    … …  
    11751403                        badge.classList.add('oznaczai-badge--hero-bar');
    11761404                }
    1177                 fillLabel(badge, data.label, config.showIcon !== false);
    1178                 applyStyleData(badge, data.status || 'generated', template, context);
     1405                fillLabel(badge, data.label, config.showIcon !== false, status);
     1406                applyStyleData(badge, status, template, context);
    11791407                applyAutoContrast(badge, null, target);
    11801408                setupPopover(badge, data.label);
    … …  
    12491477                        return;
    12501478                }
     1479                // A negative result is remembered too. Without this, every generic
     1480                // hero/banner/Elementor node was re-measured with getComputedStyle on
     1481                // every single scan.
     1482                if (target.getAttribute('data-oznaczai-bg-checked') === '1') {
     1483                        return;
     1484                }
    12511485                // Real content images are handled by createBadge(). A generic hero/banner
    12521486                // selector must never promote an <img> into a second background disclosure.
    12531487                if (target.matches && target.matches('img, picture, source')) {
     1488                        target.setAttribute('data-oznaczai-bg-checked', '1');
    12541489                        return;
    12551490                }
    … …  
    12571492                if (data) {
    12581493                        applyBackgroundLabel(target, data);
    1259                 }
     1494                        return;
     1495                }
     1496                target.setAttribute('data-oznaczai-bg-checked', '1');
    12601497        }
    12611498
    … …  
    12711508        }
    12721509
    1273         function scan(root) {
    1274                 var images;
    1275                 var manualBackgrounds;
    1276                 var automaticBackgrounds;
    1277                 var monitoredSliders;
    1278                 var revolutionSliders;
    1279 
    1280                 root = root || document;
    1281                 images = collect(root, imageSelector);
    1282                 manualBackgrounds = collect(root, manualBackgroundSelector);
    1283                 automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : [];
    1284                 monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]');
    1285                 revolutionSliders = config.autoBackgrounds ? collect(root, 'sr7-module, rs-module') : [];
     1510        function scanRoot(root) {
     1511                var images = collect(root, imageSelector);
     1512                var manualBackgrounds = collect(root, manualBackgroundSelector);
     1513                var automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : [];
     1514                var revolutionSliders = config.autoBackgrounds ? collect(root, revolutionSelector) : [];
     1515                var monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]');
    12861516
    12871517                images.forEach(createBadge);
    … …  
    12901520                revolutionSliders.forEach(scanRevolutionSlider);
    12911521                monitoredSliders.forEach(updateSliderNote);
    1292                 refreshResponsiveSymbols();
    1293                 flushSchemaMarkup();
    1294         }
    1295 
    1296         function scheduleScan() {
     1522        }
     1523
     1524        /**
     1525         * Run a callback with the global observer detached so OznaczAI never reacts
     1526         * to its own DOM writes. disconnect() also empties the pending record queue,
     1527         * which is what actually breaks the observer -> scan -> observer loop.
     1528         */
     1529        function withOwnMutations(callback) {
     1530                applying += 1;
     1531                if (observer) {
     1532                        observer.disconnect();
     1533                }
     1534                try {
     1535                        callback();
     1536                } finally {
     1537                        applying -= 1;
     1538                        if (observer && applying === 0) {
     1539                                startObserving();
     1540                        }
     1541                }
     1542        }
     1543
     1544        function scan(root) {
     1545                withOwnMutations(function () {
     1546                        scanRoot(root || document);
     1547                        flushSchemaMarkup();
     1548                });
     1549        }
     1550
     1551        function isOwnNode(node) {
     1552                return !!(node && node.nodeType === 1 && node.closest && node.closest(ownNodeSelector));
     1553        }
     1554
     1555        /**
     1556         * Compare only the inline background-image declaration. This reads the
     1557         * element's own style object, so unlike getComputedStyle it does not force
     1558         * the browser to flush pending style or layout work.
     1559         */
     1560        function inlineBackgroundChanged(element) {
     1561                var current;
     1562
     1563                if (!element || !element.style) {
     1564                        return true;
     1565                }
     1566
     1567                current = element.style.backgroundImage || '';
     1568                if (inlineBackgrounds.get(element) === current) {
     1569                        return false;
     1570                }
     1571
     1572                inlineBackgrounds.set(element, current);
     1573                return true;
     1574        }
     1575
     1576        function queueRoot(node) {
     1577                var scopeRoot;
     1578
     1579                if (pendingFull || !node || node.nodeType !== 1 || isOwnNode(node)) {
     1580                        return;
     1581                }
     1582
     1583                // Anything inside a Slider Revolution module has to be evaluated at module
     1584                // level, because slide detection works on the whole module.
     1585                scopeRoot = node.closest ? node.closest(revolutionSelector) : null;
     1586                scopeRoot = scopeRoot || node;
     1587
     1588                if (pendingRoots.indexOf(scopeRoot) !== -1) {
     1589                        return;
     1590                }
     1591
     1592                if (pendingRoots.length >= maxPendingRoots) {
     1593                        pendingFull = true;
     1594                        pendingRoots.length = 0;
     1595                        return;
     1596                }
     1597
     1598                pendingRoots.push(scopeRoot);
     1599        }
     1600
     1601        function runScheduledScan() {
     1602                var roots = pendingRoots.slice();
     1603                var full = pendingFull;
     1604
     1605                scheduled = false;
     1606                pendingRoots.length = 0;
     1607                pendingFull = false;
     1608
     1609                withOwnMutations(function () {
     1610                        if (full) {
     1611                                scanRoot(document);
     1612                        } else {
     1613                                roots.forEach(function (root) {
     1614                                        if (root === document || (root.isConnected !== false)) {
     1615                                                scanRoot(root);
     1616                                        }
     1617                                });
     1618                        }
     1619                        flushSchemaMarkup();
     1620                });
     1621        }
     1622
     1623        function scheduleScan(full) {
     1624                if (full === true) {
     1625                        pendingFull = true;
     1626                        pendingRoots.length = 0;
     1627                }
    12971628                if (scheduled) {
    12981629                        return;
    12991630                }
    13001631                scheduled = true;
    1301                 window.requestAnimationFrame(function () {
    1302                         scheduled = false;
    1303                         scan(document);
    1304                 });
     1632                window.requestAnimationFrame(runScheduledScan);
     1633        }
     1634
     1635        function scheduleFullScan() {
     1636                scheduleScan(true);
     1637        }
     1638
     1639        function startObserving() {
     1640                if (!observer) {
     1641                        return;
     1642                }
     1643                observer.observe(document.body, {
     1644                        childList: true,
     1645                        subtree: true,
     1646                        attributes: true,
     1647                        // `class` is intentionally not observed globally. Slider state classes
     1648                        // are handled by the per-slider observer in ensureSliderMonitoring(),
     1649                        // and every other class change on the page is irrelevant to labeling.
     1650                        attributeFilter: ['style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb']
     1651                });
     1652        }
     1653
     1654        function handleMutations(mutations) {
     1655                var queued = false;
     1656                var i;
     1657                var mutation;
     1658                var j;
     1659                var node;
     1660
     1661                if (applying > 0) {
     1662                        return;
     1663                }
     1664
     1665                for (i = 0; i < mutations.length; i += 1) {
     1666                        mutation = mutations[i];
     1667
     1668                        if (mutation.type === 'attributes') {
     1669                                if (isOwnNode(mutation.target)) {
     1670                                        continue;
     1671                                }
     1672                                // Scroll animations and motion effects rewrite `style` on every
     1673                                // frame without ever touching the background. Reading the inline
     1674                                // value is cheap and avoids forcing a style recalculation for
     1675                                // changes that cannot affect labeling.
     1676                                if (mutation.attributeName === 'style' && !inlineBackgroundChanged(mutation.target)) {
     1677                                        continue;
     1678                                }
     1679                                // A background source may have just been swapped in, so the cached
     1680                                // negative result for this element is no longer trustworthy.
     1681                                if (mutation.target.hasAttribute && mutation.target.hasAttribute('data-oznaczai-bg-checked')) {
     1682                                        mutation.target.removeAttribute('data-oznaczai-bg-checked');
     1683                                }
     1684                                queueRoot(mutation.target);
     1685                                queued = true;
     1686                                continue;
     1687                        }
     1688
     1689                        if (mutation.addedNodes) {
     1690                                for (j = 0; j < mutation.addedNodes.length; j += 1) {
     1691                                        node = mutation.addedNodes[j];
     1692                                        if (node.nodeType !== 1 || isOwnNode(node)) {
     1693                                                continue;
     1694                                        }
     1695                                        queueRoot(node);
     1696                                        queued = true;
     1697                                }
     1698                        }
     1699                }
     1700
     1701                if (queued) {
     1702                        scheduleScan();
     1703                }
     1704        }
     1705
     1706        function handleResize() {
     1707                if (resizeTimer) {
     1708                        window.clearTimeout(resizeTimer);
     1709                }
     1710                resizeTimer = window.setTimeout(function () {
     1711                        resizeTimer = null;
     1712                        withOwnMutations(function () {
     1713                                refreshResponsiveSymbols(document);
     1714                        });
     1715                }, 200);
     1716        }
     1717
     1718        function handleRevolutionEvent() {
     1719                // Slide media changed, so the per-slide URL cache has to be rebuilt.
     1720                revolutionEpoch += 1;
     1721                scheduleFullScan();
    13051722        }
    13061723
    13071724        function init() {
    13081725                scan(document);
     1726                withOwnMutations(function () {
     1727                        refreshResponsiveSymbols(document);
     1728                });
     1729
     1730                // Themes and lazy-loaders often finish late. These two passes are cheap
     1731                // now because processed images short-circuit on their signature.
    13091732                window.setTimeout(function () { scan(document); }, 700);
    13101733                window.setTimeout(function () { scan(document); }, 1800);
    1311                 window.addEventListener('resize', scheduleScan, { passive: true });
     1734                window.addEventListener('resize', handleResize, { passive: true });
    13121735
    13131736                // Slider Revolution 7 exposes native lifecycle events. Re-scan after a
    … …  
    13151738                // current-slide disclosure stay synchronized. Legacy SR6 remains covered
    13161739                // by the DOM observer and data-slideactive monitoring below.
    1317                 document.addEventListener('sr.module.ready', scheduleScan);
    1318                 document.addEventListener('sr.slide.afterChange', scheduleScan);
     1740                document.addEventListener('sr.module.ready', handleRevolutionEvent);
     1741                document.addEventListener('sr.slide.afterChange', handleRevolutionEvent);
    13191742
    13201743                if ('MutationObserver' in window) {
    1321                         new MutationObserver(function (mutations) {
    1322                                 var shouldScan = mutations.some(function (mutation) {
    1323                                         return (mutation.addedNodes && mutation.addedNodes.length > 0) || mutation.type === 'attributes';
    1324                                 });
    1325                                 if (shouldScan) {
    1326                                         scheduleScan();
    1327                                 }
    1328                         }).observe(document.body, {
    1329                                 childList: true,
    1330                                 subtree: true,
    1331                                 attributes: true,
    1332                                 attributeFilter: ['class', 'style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb', 'data-current', 'data-slideactive']
    1333                         });
     1744                        observer = new MutationObserver(handleMutations);
     1745                        startObserving();
    13341746                }
    13351747
  • oznaczai-image-labeling/tags/1.7.0/changelog.txt

    r3641997 r3705284  
     1= 1.7.0 =
     2* Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled.
     3* Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly.
     4* Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked.
     5* Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected.
     6* Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label.
     7* Removed a database write that could happen while a public page was being rendered.
     8* Bulk labeling now writes the labeled media index once instead of once per image.
     9* Reduced the size of the labeled media data passed to the browser by removing duplicated entries.
     10* The legacy storage migration now runs only on install and version change instead of on every request.
     11* Fixed a migration from very old versions that could store a label preset that no longer exists.
     12* Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint.
     13* Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it.
     14* Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged.
     15* Removed settings screen code that was no longer used by the current panel.
     16
    117= 1.6.24 =
    218* Rebuilt preset cards with a simple flex column flow so title, description and preview badge can never overlap.
  • oznaczai-image-labeling/tags/1.7.0/includes/class-oznaczai-image-labeling-plugin.php

    r3641997 r3705284  
    2121        const OPTION_LABELED_IDS        = 'oznaczai_image_labeling_labeled_media_ids';
    2222        const OPTION_VERSION            = 'oznaczai_image_labeling_version';
     23        const OPTION_TRANSPARENCY_PAGE  = 'oznaczai_image_labeling_transparency_page_id';
    2324        const TRANSIENT_MEDIA_MAP       = 'oznaczai_image_labeling_media_map_v1';
    2425
    … …  
    6768
    6869        /**
     70         * Cached labeled attachment index for the current request.
     71         *
     72         * @var array<int>|null
     73         */
     74        private $labeled_ids = null;
     75
     76        /**
     77         * Stale index entries detected while building the media map on a public
     78         * request. They are written out on the next administrative save.
     79         *
     80         * @var array<int>|null
     81         */
     82        private $labeled_ids_pending_cleanup = null;
     83
     84        /**
    6985         * Get singleton instance.
    7086         *
    … …  
    178194                add_filter( 'manage_media_columns', array( $this, 'add_media_column' ) );
    179195                add_action( 'manage_media_custom_column', array( $this, 'render_media_column' ), 10, 2 );
     196                add_action( 'restrict_manage_posts', array( $this, 'render_media_library_filter' ), 10, 2 );
     197                add_action( 'pre_get_posts', array( $this, 'filter_media_library_query' ) );
    180198                add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) );
    181199                add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_actions' ), 10, 3 );
    182200                add_action( 'admin_notices', array( $this, 'show_bulk_action_notice' ) );
     201                add_action( 'admin_notices', array( $this, 'show_transparency_page_notice' ) );
    183202                add_action( 'wp_ajax_oznaczai_image_labeling_update_media_status', array( $this, 'ajax_update_media_status' ) );
     203                add_action( 'admin_post_oznaczai_image_labeling_create_transparency_page', array( $this, 'handle_create_transparency_page' ) );
    184204
    185205                add_action( 'edit_attachment', array( $this, 'clear_media_map_cache' ) );
    … …  
    195215         */
    196216        public function maybe_upgrade() {
    197                 self::migrate_legacy_storage();
    198 
    199217                $stored_version = (string) get_option( self::OPTION_VERSION, '' );
    200218                if ( '' !== $stored_version && ! version_compare( $stored_version, OZNACZAI_IMAGE_LABELING_VERSION, '<' ) ) {
    201219                        return;
    202220                }
     221
     222                // Only reached on a fresh install or a version change. Installations that
     223                // are already up to date no longer pay for the legacy lookup on every
     224                // request. Activation still calls this directly, so upgrades that skip
     225                // deactivation are covered either way.
     226                self::migrate_legacy_storage();
    203227
    204228                if ( '' === $stored_version || version_compare( $stored_version, '1.6.0', '<' ) ) {
    … …  
    234258                add_shortcode( 'oznaczai_image_label', array( $this, 'render_badge_shortcode' ) );
    235259                add_shortcode( 'oznaczai_image_label_background', array( $this, 'render_background_badge_shortcode' ) );
     260                add_shortcode( 'oznaczai_transparency_legend', array( $this, 'render_transparency_legend_shortcode' ) );
    236261        }
    237262
    … …  
    281306                        'hero_position'       => 'below',
    282307                        'show_icon'           => 1,
     308                        'icon_variant'        => 'oznaczai',
     309                        'icon_show_text'      => 1,
    283310                        'bg_display'          => 'below',
    284311                        'auto_backgrounds'    => 1,
    … …  
    403430
    404431                        if ( empty( $stored['template'] ) && ! empty( $stored['style'] ) ) {
    405                                 $stored['template'] = 'light' === $stored['style'] ? 'light' : 'subtle';
     432                                // 'subtle' was dropped from the preset list, so it resolved to no
     433                                // preset configuration and the frontend already fell back to the
     434                                // 'minimal' custom properties. Storing 'minimal' keeps exactly the
     435                                // same rendered label while making the stored value valid again.
     436                                $stored['template'] = 'light' === $stored['style'] ? 'light' : 'minimal';
    406437                        }
    407438
    … …  
    626657
    627658        /**
     659         * Label symbol variants.
     660         *
     661         * A preset controls how the label looks. A symbol variant controls which
     662         * mark appears inside it. They are independent on purpose, so every existing
     663         * preset keeps working with every symbol.
     664         *
     665         * The default variant reproduces the historical OznaczAI "AI" mark exactly,
     666         * so an upgrade never changes an existing site's labels on its own.
     667         *
     668         * The bundled EU assets are the official European Commission icons. See
     669         * assets/icons/eu/README.txt for their source and terms of use.
     670         *
     671         * @return array<string,array<string,mixed>>
     672         */
     673        private static function get_icon_variants() {
     674                $base = OZNACZAI_IMAGE_LABELING_URL . 'assets/icons/eu/';
     675
     676                $basic = array(
     677                        'onLight' => $base . 'eu-ai-black.svg',
     678                        'onDark'  => $base . 'eu-ai-white.svg',
     679                        'ratio'   => 1,
     680                );
     681
     682                return array(
     683                        'oznaczai'  => array(
     684                                'label'   => __( 'OznaczAI symbol', 'oznaczai-image-labeling' ),
     685                                'symbols' => array(
     686                                        'generated'     => array( 'text' => 'AI' ),
     687                                        'modified'      => array( 'text' => 'AI' ),
     688                                        'visualization' => array( 'text' => 'AI' ),
     689                                ),
     690                        ),
     691                        'eu-basic'  => array(
     692                                'label'   => __( 'EU AI icon', 'oznaczai-image-labeling' ),
     693                                'symbols' => array(
     694                                        'generated'     => $basic,
     695                                        'modified'      => $basic,
     696                                        'visualization' => $basic,
     697                                ),
     698                        ),
     699                        'eu-status' => array(
     700                                'label'   => __( 'EU AI icons per label type', 'oznaczai-image-labeling' ),
     701                                'symbols' => array(
     702                                        'generated'     => array(
     703                                                'onLight' => $base . 'eu-ai-generated-black.svg',
     704                                                'onDark'  => $base . 'eu-ai-generated-white.svg',
     705                                                'ratio'   => 3.157,
     706                                        ),
     707                                        'modified'      => array(
     708                                                'onLight' => $base . 'eu-ai-modified-black.svg',
     709                                                'onDark'  => $base . 'eu-ai-modified-white.svg',
     710                                                'ratio'   => 3,
     711                                        ),
     712                                        // There is no official variant meaning "visualization", so this
     713                                        // status intentionally falls back to the neutral basic icon
     714                                        // instead of being presented as fully generated or modified.
     715                                        'visualization' => $basic,
     716                                ),
     717                        ),
     718                );
     719        }
     720
     721        /**
     722         * Validate a symbol variant key.
     723         *
     724         * @param string $variant Variant key.
     725         * @return string
     726         */
     727        private function sanitize_icon_variant( $variant ) {
     728                $variant = sanitize_key( $variant );
     729                return array_key_exists( $variant, self::get_icon_variants() ) ? $variant : 'oznaczai';
     730        }
     731
     732        /**
     733         * Whether the active symbol variant already carries its own wording.
     734         *
     735         * The EU per-type assets are wide lockups that spell out their meaning as
     736         * vector outlines, so repeating the label text beside them is redundant.
     737         *
     738         * @param string $variant Variant key.
     739         * @return bool
     740         */
     741        private function icon_variant_has_wording( $variant ) {
     742                return 'eu-status' === $variant;
     743        }
     744
     745        /**
    628746         * Validate a preset key.
    629747         *
    … …  
    706824
    707825        /**
    708          * Return custom style config for a supported visual context.
    709          *
    710          * @param string $context  Context key.
    711          * @param array  $settings Current settings.
    712          * @return array
    713          */
    714         private function get_context_custom_style_config( $context, $settings ) {
    715                 return array(
    716                         'textColor'    => $settings[ 'context_' . $context . '_text_color' ],
    717                         'noBackground' => ! empty( $settings[ 'context_' . $context . '_no_background' ] ),
    718                         'bgColor'      => $settings[ 'context_' . $context . '_bg_color' ],
    719                         'bgOpacity'    => (int) $settings[ 'context_' . $context . '_bg_opacity' ],
    720                         'borderColor'  => $settings[ 'context_' . $context . '_border_color' ],
    721                         'borderWidth'  => (int) $settings[ 'context_' . $context . '_border_width' ],
    722                         'radius'       => (int) $settings[ 'context_' . $context . '_radius' ],
    723                         'fontSize'     => (int) $settings[ 'context_' . $context . '_font_size' ],
    724                         'fontWeight'   => (int) $settings[ 'context_' . $context . '_font_weight' ],
    725                         'paddingX'     => (int) $settings[ 'context_' . $context . '_padding_x' ],
    726                         'paddingY'     => (int) $settings[ 'context_' . $context . '_padding_y' ],
    727                         'iconColor'    => $settings[ 'context_' . $context . '_icon_color' ],
    728                         'iconSize'     => (int) $settings[ 'context_' . $context . '_icon_size' ],
    729                         'shadow'       => ! empty( $settings[ 'context_' . $context . '_shadow' ] ),
    730                         'position'     => $settings[ 'context_' . $context . '_position' ],
    731                 );
    732         }
    733 
    734         /**
    735826         * Localized default public labels.
    736827         *
    … …  
    13321423
    13331424                        $valid_ids[] = $attachment_id;
     1425                        $full_url    = wp_get_attachment_url( $attachment_id );
    13341426                        $urls        = array_filter(
    13351427                                array(
    1336                                         wp_get_attachment_url( $attachment_id ),
     1428                                        $full_url,
    13371429                                        function_exists( 'wp_get_original_image_url' ) ? wp_get_original_image_url( $attachment_id ) : '',
    13381430                                )
    … …  
    13401432
    13411433                        $metadata = wp_get_attachment_metadata( $attachment_id );
    1342                         $full_url = wp_get_attachment_url( $attachment_id );
    13431434                        if ( $full_url && is_array( $metadata ) && ! empty( $metadata['sizes'] ) ) {
    13441435                                $base_url = trailingslashit( dirname( $full_url ) );
    … …  
    13651456                                        continue;
    13661457                                }
    1367                                 $map[ $path ]           = $data;
    1368                                 $map[ $path . '.webp' ] = $data;
    1369                                 $webp_path              = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path );
     1458                                $map[ $path ] = $data;
     1459
     1460                                // A "<file>.webp" companion path is intentionally not stored. The
     1461                                // frontend lookup already strips a trailing .webp and retries, so
     1462                                // the extra key was pure duplication of every entry. The variant
     1463                                // where the extension is replaced is still needed, because that
     1464                                // form cannot be derived by stripping.
     1465                                $webp_path = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path );
    13701466                                if ( is_string( $webp_path ) && $webp_path !== $path ) {
    13711467                                        $map[ $webp_path ] = $data;
    … …  
    13741470                }
    13751471
     1472                // Stale entries are recorded for the next admin-side write instead of
     1473                // being flushed here. This method runs on public page views, which must
     1474                // never issue a database write.
    13761475                if ( $valid_ids !== $ids ) {
    1377                         update_option( self::OPTION_LABELED_IDS, $valid_ids, false );
     1476                        $this->labeled_ids_pending_cleanup = $valid_ids;
    13781477                }
    13791478
    … …  
    13911490         */
    13921491        private function get_labeled_media_ids() {
     1492                if ( is_array( $this->labeled_ids ) ) {
     1493                        return $this->labeled_ids;
     1494                }
     1495
    13931496                $ids = get_option( self::OPTION_LABELED_IDS, null );
    13941497                if ( ! is_array( $ids ) ) {
    1395                         return $this->rebuild_labeled_media_index();
     1498                        $this->labeled_ids = $this->rebuild_labeled_media_index();
     1499                        return $this->labeled_ids;
    13961500                }
    13971501
    13981502                $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
    13991503                sort( $ids, SORT_NUMERIC );
     1504                $this->labeled_ids = $ids;
    14001505
    14011506                return $ids;
    … …  
    14461551                $ids = array_values( array_unique( $ids ) );
    14471552                sort( $ids, SORT_NUMERIC );
     1553                $this->labeled_ids = $ids;
    14481554                update_option( self::OPTION_LABELED_IDS, $ids, false );
    14491555
    … …  
    14561562         * @param int    $attachment_id Attachment ID.
    14571563         * @param string $status        Sanitized status.
     1564         * @param bool   $defer         Keep the change in memory instead of writing.
    14581565         * @return void
    14591566         */
    1460         private function update_labeled_media_index( $attachment_id, $status ) {
     1567        private function update_labeled_media_index( $attachment_id, $status, $defer = false ) {
    14611568                $attachment_id = absint( $attachment_id );
    14621569                if ( ! $attachment_id ) {
    … …  
    14721579
    14731580                sort( $ids, SORT_NUMERIC );
     1581                $this->labeled_ids = $ids;
     1582
     1583                if ( $defer ) {
     1584                        return;
     1585                }
     1586
     1587                $this->persist_labeled_media_index();
     1588        }
     1589
     1590        /**
     1591         * Write the labeled media index once.
     1592         *
     1593         * Bulk actions collect every change first and call this at the end, so a
     1594         * 200-image selection performs one write instead of 200.
     1595         *
     1596         * @return void
     1597         */
     1598        private function persist_labeled_media_index() {
     1599                $ids = $this->get_labeled_media_ids();
     1600
     1601                if ( is_array( $this->labeled_ids_pending_cleanup ) ) {
     1602                        $ids = array_values( array_intersect( $ids, $this->labeled_ids_pending_cleanup ) );
     1603                        sort( $ids, SORT_NUMERIC );
     1604                        $this->labeled_ids                 = $ids;
     1605                        $this->labeled_ids_pending_cleanup = null;
     1606                }
     1607
    14741608                update_option( self::OPTION_LABELED_IDS, $ids, false );
    14751609        }
    … …  
    14841618                $this->update_labeled_media_index( absint( $attachment_id ), 'none' );
    14851619                $this->clear_media_map_cache();
     1620        }
     1621
     1622        /**
     1623         * Check whether an attachment currently carries an OznaczAI label.
     1624         *
     1625         * @param int $attachment_id Attachment ID.
     1626         * @return bool
     1627         */
     1628        private function is_labeled_attachment( $attachment_id ) {
     1629                $attachment_id = absint( $attachment_id );
     1630                if ( ! $attachment_id ) {
     1631                        return false;
     1632                }
     1633
     1634                return in_array( $attachment_id, $this->get_labeled_media_ids(), true );
    14861635        }
    14871636
    … …  
    15791728         */
    15801729        public function clear_media_map_cache_after_metadata_update( $data, $attachment_id ) {
    1581                 unset( $attachment_id );
     1730                // This filter also runs for every plain upload and every thumbnail
     1731                // regeneration. Rebuilding the whole map for media that carries no label
     1732                // turned a 500-image import into 500 cold rebuilds on the next page view.
     1733                if ( ! $this->is_labeled_attachment( $attachment_id ) ) {
     1734                        return $data;
     1735                }
     1736
    15821737                $this->clear_media_map_cache();
    15831738                return $data;
    … …  
    16331788                                ),
    16341789                                'presetConfigs'   => self::get_preset_configs(),
     1790                                'iconVariants'   => self::get_icon_variants(),
     1791                                'iconVariant'    => $this->sanitize_icon_variant( $settings['icon_variant'] ),
     1792                                'iconShowText'   => ! empty( $settings['icon_show_text'] ),
    16351793                                'showIcon'       => ! empty( $settings['show_icon'] ),
    16361794                                'template'       => $settings['template'],
    … …  
    16961854                                        array(
    16971855                                                'presetConfigs' => self::get_preset_configs(),
     1856                                                'iconVariants'  => self::get_icon_variants(),
    16981857                                        )
    16991858                                );
    … …  
    17201879
    17211880        /**
    1722          * Register settings and fields.
     1881         * Register the settings option.
     1882         *
     1883         * The screen is rendered by render_settings_page() rather than through
     1884         * do_settings_sections(), so only the option registration is needed here.
     1885         * It provides the options.php allow-list entry and the sanitize callback.
    17231886         *
    17241887         * @return void
    … …  
    17331896                                'default'           => self::default_settings(),
    17341897                        )
    1735                 );
    1736 
    1737                 add_settings_section(
    1738                         'oznaczai_image_labeling_labels_section',
    1739                         __( 'Label text', 'oznaczai-image-labeling' ),
    1740                         '__return_false',
    1741                         'oznaczai-image-labeling'
    1742                 );
    1743 
    1744                 $this->register_text_setting_field( 'label_generated', __( 'AI-generated image', 'oznaczai-image-labeling' ) );
    1745                 $this->register_text_setting_field( 'label_modified', __( 'AI-modified image', 'oznaczai-image-labeling' ) );
    1746                 $this->register_text_setting_field( 'label_visualization', __( 'AI visualization', 'oznaczai-image-labeling' ) );
    1747 
    1748                 add_settings_section(
    1749                         'oznaczai_image_labeling_appearance_section',
    1750                         __( 'Label appearance', 'oznaczai-image-labeling' ),
    1751                         '__return_false',
    1752                         'oznaczai-image-labeling'
    1753                 );
    1754 
    1755                 add_settings_field(
    1756                         'position',
    1757                         __( 'Position', 'oznaczai-image-labeling' ),
    1758                         array( $this, 'render_position_field' ),
    1759                         'oznaczai-image-labeling',
    1760                         'oznaczai_image_labeling_appearance_section'
    1761                 );
    1762 
    1763                 add_settings_field(
    1764                         'template',
    1765                         __( 'Template', 'oznaczai-image-labeling' ),
    1766                         array( $this, 'render_template_field' ),
    1767                         'oznaczai-image-labeling',
    1768                         'oznaczai_image_labeling_appearance_section'
    1769                 );
    1770 
    1771                 add_settings_field(
    1772                         'show_icon',
    1773                         __( 'AI icon', 'oznaczai-image-labeling' ),
    1774                         array( $this, 'render_icon_field' ),
    1775                         'oznaczai-image-labeling',
    1776                         'oznaczai_image_labeling_appearance_section'
    1777                 );
    1778 
    1779                 add_settings_field(
    1780                         'responsive_symbol',
    1781                         __( 'Small-image symbol', 'oznaczai-image-labeling' ),
    1782                         array( $this, 'render_responsive_symbol_field' ),
    1783                         'oznaczai-image-labeling',
    1784                         'oznaczai_image_labeling_appearance_section'
    1785                 );
    1786 
    1787                 add_settings_section(
    1788                         'oznaczai_image_labeling_background_section',
    1789                         __( 'Hero and background images', 'oznaczai-image-labeling' ),
    1790                         '__return_false',
    1791                         'oznaczai-image-labeling'
    1792                 );
    1793 
    1794                 add_settings_field(
    1795                         'bg_display',
    1796                         __( 'Hero / background display', 'oznaczai-image-labeling' ),
    1797                         array( $this, 'render_bg_display_field' ),
    1798                         'oznaczai-image-labeling',
    1799                         'oznaczai_image_labeling_background_section'
    1800                 );
    1801 
    1802                 add_settings_field(
    1803                         'auto_backgrounds',
    1804                         __( 'Automatic detection', 'oznaczai-image-labeling' ),
    1805                         array( $this, 'render_auto_backgrounds_field' ),
    1806                         'oznaczai-image-labeling',
    1807                         'oznaczai_image_labeling_background_section'
    1808                 );
    1809 
    1810                 add_settings_section(
    1811                         'oznaczai_image_labeling_metadata_section',
    1812                         __( 'Metadata and automation', 'oznaczai-image-labeling' ),
    1813                         '__return_false',
    1814                         'oznaczai-image-labeling'
    1815                 );
    1816 
    1817                 add_settings_field(
    1818                         'detect_metadata',
    1819                         __( 'Upload metadata hints', 'oznaczai-image-labeling' ),
    1820                         array( $this, 'render_detect_metadata_field' ),
    1821                         'oznaczai-image-labeling',
    1822                         'oznaczai_image_labeling_metadata_section'
    1823                 );
    1824 
    1825                 add_settings_field(
    1826                         'schema_markup',
    1827                         __( 'Schema.org metadata', 'oznaczai-image-labeling' ),
    1828                         array( $this, 'render_schema_markup_field' ),
    1829                         'oznaczai-image-labeling',
    1830                         'oznaczai_image_labeling_metadata_section'
    1831                 );
    1832         }
    1833 
    1834         /**
    1835          * Register one text option field.
    1836          *
    1837          * @param string $key   Setting key.
    1838          * @param string $label Field label.
    1839          * @return void
    1840          */
    1841         private function register_text_setting_field( $key, $label ) {
    1842                 add_settings_field(
    1843                         $key,
    1844                         $label,
    1845                         array( $this, 'render_text_field' ),
    1846                         'oznaczai-image-labeling',
    1847                         'oznaczai_image_labeling_labels_section',
    1848                         array( 'key' => $key )
    18491898                );
    18501899        }
    … …  
    19061955                        'hero_position'       => isset( $input['hero_position'] ) && in_array( $input['hero_position'], $context_positions, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['hero_position'],
    19071956                        'show_icon'           => empty( $input['show_icon'] ) ? 0 : 1,
     1957                        'icon_variant'        => $this->sanitize_icon_variant( isset( $input['icon_variant'] ) ? $input['icon_variant'] : $defaults['icon_variant'] ),
     1958                        'icon_show_text'      => empty( $input['icon_show_text'] ) ? 0 : 1,
    19081959                        'bg_display'          => isset( $input['bg_display'] ) && in_array( $input['bg_display'], $bg_displays, true ) ? $input['bg_display'] : ( isset( $input['hero_position'] ) && in_array( $input['hero_position'], $bg_displays, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['bg_display'] ),
    19091960                        'auto_backgrounds'    => empty( $input['auto_backgrounds'] ) ? 0 : 1,
    … …  
    19692020                $value = is_numeric( $value ) ? (int) $value : (int) $default;
    19702021                return max( $min, min( $max, $value ) );
    1971         }
    1972 
    1973         /**
    1974          * Render settings text field.
    1975          *
    1976          * @param array $args Field arguments.
    1977          * @return void
    1978          */
    1979         public function render_text_field( $args ) {
    1980                 $settings = $this->get_settings();
    1981                 $key      = $args['key'];
    1982                 $status   = str_replace( 'label_', '', $key );
    1983                 $defaults = $this->get_default_label_texts();
    1984                 $placeholder = isset( $defaults[ $status ] ) ? $defaults[ $status ] : '';
    1985                 printf(
    1986                         '<input type="text" class="regular-text" maxlength="160" name="%1$s[%2$s]" value="%3$s" placeholder="%4$s">',
    1987                         esc_attr( self::OPTION_SETTINGS ),
    1988                         esc_attr( $key ),
    1989                         esc_attr( $settings[ $key ] ),
    1990                         esc_attr( $placeholder )
    1991                 );
    1992                 echo '<p class="description">' . esc_html__( 'Leave empty to use the translated default for the active site language.', 'oznaczai-image-labeling' ) . '</p>';
    1993         }
    1994 
    1995         /**
    1996          * Render position field.
    1997          *
    1998          * @return void
    1999          */
    2000         public function render_position_field() {
    2001                 $settings = $this->get_settings();
    2002                 $options  = array(
    2003                         'bottom-left'  => __( 'Bottom left', 'oznaczai-image-labeling' ),
    2004                         'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),
    2005                         'top-left'     => __( 'Top left', 'oznaczai-image-labeling' ),
    2006                         'top-right'    => __( 'Top right', 'oznaczai-image-labeling' ),
    2007                 );
    2008 
    2009                 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[position]">';
    2010                 foreach ( $options as $value => $label ) {
    2011                         printf(
    2012                                 '<option value="%1$s"%2$s>%3$s</option>',
    2013                                 esc_attr( $value ),
    2014                                 selected( $settings['position'], $value, false ),
    2015                                 esc_html( $label )
    2016                         );
    2017                 }
    2018                 echo '</select>';
    2019                 echo '<p class="description">' . esc_html__( 'For the Caption below image template, this setting controls left or right caption alignment.', 'oznaczai-image-labeling' ) . '</p>';
    2020         }
    2021 
    2022         /**
    2023          * Render template field.
    2024          *
    2025          * @return void
    2026          */
    2027         public function render_template_field() {
    2028                 $settings = $this->get_settings();
    2029                 $options  = array(
    2030                         'minimal' => array(
    2031                                 'label'       => __( 'Minimal', 'oznaczai-image-labeling' ),
    2032                                 'description' => __( 'The least prominent option. Very light background, without borders or shadows.', 'oznaczai-image-labeling' ),
    2033                         ),
    2034                         'compact' => array(
    2035                                 'label'       => __( 'Compact', 'oznaczai-image-labeling' ),
    2036                                 'description' => __( 'A small dark label with restrained visual weight.', 'oznaczai-image-labeling' ),
    2037                         ),
    2038                         'subtle'  => array(
    2039                                 'label'       => __( 'Subtle', 'oznaczai-image-labeling' ),
    2040                                 'description' => __( 'A semi-transparent label without shadow or bold text.', 'oznaczai-image-labeling' ),
    2041                         ),
    2042                         'light'    => array(
    2043                                 'label'       => __( 'Light', 'oznaczai-image-labeling' ),
    2044                                 'description' => __( 'Light, delicate text with an almost invisible background.', 'oznaczai-image-labeling' ),
    2045                         ),
    2046                         'caption'  => array(
    2047                                 'label'       => __( 'Caption below image', 'oznaczai-image-labeling' ),
    2048                                 'description' => __( 'A lightweight caption directly below the image. It does not cover the image.', 'oznaczai-image-labeling' ),
    2049                         ),
    2050                 );
    2051 
    2052                 echo '<div class="oznaczai-template-grid">';
    2053                 foreach ( $options as $value => $option ) {
    2054                         printf(
    2055                                 '<label class="oznaczai-template-option"><input type="radio" name="%1$s[template]" value="%2$s"%3$s><span class="oznaczai-template-card"><span class="oznaczai-template-card__title">%4$s</span><span class="oznaczai-template-preview oznaczai-template-preview--%2$s"><span class="oznaczai-template-preview__icon" aria-hidden="true">AI</span><span>%6$s</span></span><span class="oznaczai-template-card__description">%5$s</span></span></label>',
    2056                                 esc_attr( self::OPTION_SETTINGS ),
    2057                                 esc_attr( $value ),
    2058                                 checked( $settings['template'], $value, false ),
    2059                                 esc_html( $option['label'] ),
    2060                                 esc_html( $option['description'] ),
    2061                                 esc_html__( 'AI-generated image', 'oznaczai-image-labeling' )
    2062                         );
    2063                 }
    2064                 echo '</div>';
    2065                 echo '<p class="description">' . esc_html__( 'Minimal is the least prominent template. You can also disable the AI icon for an even lighter result.', 'oznaczai-image-labeling' ) . '</p>';
    2066         }
    2067 
    2068         /**
    2069          * Render icon checkbox.
    2070          *
    2071          * @return void
    2072          */
    2073         public function render_icon_field() {
    2074                 $settings = $this->get_settings();
    2075                 printf(
    2076                         '<label><input type="checkbox" name="%1$s[show_icon]" value="1"%2$s> %3$s</label>',
    2077                         esc_attr( self::OPTION_SETTINGS ),
    2078                         checked( ! empty( $settings['show_icon'] ), true, false ),
    2079                         esc_html__( 'Show a small icon next to the text', 'oznaczai-image-labeling' )
    2080                 );
    2081         }
    2082 
    2083         /**
    2084          * Render small-image symbol setting.
    2085          *
    2086          * @return void
    2087          */
    2088         public function render_responsive_symbol_field() {
    2089                 $settings = $this->get_settings();
    2090                 printf(
    2091                         '<label><input type="checkbox" name="%1$s[responsive_symbol]" value="1"%2$s> %3$s</label>',
    2092                         esc_attr( self::OPTION_SETTINGS ),
    2093                         checked( ! empty( $settings['responsive_symbol'] ), true, false ),
    2094                         esc_html__( 'Use a compact AI symbol on small images instead of the full text label', 'oznaczai-image-labeling' )
    2095                 );
    2096                 echo '<p class="description">' . esc_html__( 'The symbol expands to the full label on hover or keyboard focus. The accessible label remains available to assistive technology. Caption below image is never collapsed.', 'oznaczai-image-labeling' ) . '</p>';
    2097         }
    2098 
    2099 
    2100         /**
    2101          * Render hero/background display field.
    2102          *
    2103          * @return void
    2104          */
    2105         public function render_bg_display_field() {
    2106                 $settings = $this->get_settings();
    2107                 $options  = array(
    2108                         'below'        => __( 'Below image / section', 'oznaczai-image-labeling' ),
    2109                         'bar'          => __( 'Subtle bar at the bottom of the hero', 'oznaczai-image-labeling' ),
    2110                         'bottom-left'  => __( 'Bottom left in hero', 'oznaczai-image-labeling' ),
    2111                         'bottom-right' => __( 'Bottom right in hero', 'oznaczai-image-labeling' ),
    2112                 );
    2113 
    2114                 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[bg_display]">';
    2115                 foreach ( $options as $value => $label ) {
    2116                         printf(
    2117                                 '<option value="%1$s"%2$s>%3$s</option>',
    2118                                 esc_attr( $value ),
    2119                                 selected( $settings['bg_display'], $value, false ),
    2120                                 esc_html( $label )
    2121                         );
    2122                 }
    2123                 echo '</select>';
    2124                 echo '<p class="description">' . esc_html__( 'For a custom section background, add the CSS class oznaczai-bg-generated, oznaczai-bg-modified, or oznaczai-bg-visualization. The Below image / section option is the safest choice for heroes with their own text.', 'oznaczai-image-labeling' ) . '</p>';
    2125         }
    2126 
    2127 
    2128         /**
    2129          * Render automatic background detection field.
    2130          *
    2131          * @return void
    2132          */
    2133         public function render_auto_backgrounds_field() {
    2134                 $settings = $this->get_settings();
    2135                 printf(
    2136                         '<label><input type="checkbox" name="%1$s[auto_backgrounds]" value="1"%2$s> %3$s</label>',
    2137                         esc_attr( self::OPTION_SETTINGS ),
    2138                         checked( ! empty( $settings['auto_backgrounds'] ), true, false ),
    2139                         esc_html__( 'Automatically detect labeled images in hero sections, section backgrounds, and Elementor sliders', 'oznaczai-image-labeling' )
    2140                 );
    2141                 echo '<p class="description">' . esc_html__( 'Mark the file in the Media Library. No CSS classes or shortcode are required for supported layouts.', 'oznaczai-image-labeling' ) . '</p>';
    2142         }
    2143 
    2144         /**
    2145          * Render upload metadata detection setting.
    2146          *
    2147          * @return void
    2148          */
    2149         public function render_detect_metadata_field() {
    2150                 $settings = $this->get_settings();
    2151                 printf(
    2152                         '<label><input type="checkbox" name="%1$s[detect_metadata]" value="1"%2$s> %3$s</label>',
    2153                         esc_attr( self::OPTION_SETTINGS ),
    2154                         checked( ! empty( $settings['detect_metadata'] ), true, false ),
    2155                         esc_html__( 'Check newly uploaded images for AI-related IPTC/XMP, Content Credentials markers, and common generator signatures', 'oznaczai-image-labeling' )
    2156                 );
    2157                 echo '<p class="description">' . esc_html__( 'Detection only creates a review hint. It never labels an image automatically and does not validate C2PA signatures.', 'oznaczai-image-labeling' ) . '</p>';
    2158         }
    2159 
    2160         /**
    2161          * Render Schema.org setting.
    2162          *
    2163          * @return void
    2164          */
    2165         public function render_schema_markup_field() {
    2166                 $settings = $this->get_settings();
    2167                 printf(
    2168                         '<label><input type="checkbox" name="%1$s[schema_markup]" value="1"%2$s> %3$s</label>',
    2169                         esc_attr( self::OPTION_SETTINGS ),
    2170                         checked( ! empty( $settings['schema_markup'] ), true, false ),
    2171                         esc_html__( 'Add Schema.org digitalSourceType JSON-LD for labeled images found on the page', 'oznaczai-image-labeling' )
    2172                 );
    2173                 echo '<p class="description">' . esc_html__( 'This is machine-readable publisher metadata. It is separate from Content Credentials and does not provide or guarantee legal compliance.', 'oznaczai-image-labeling' ) . '</p>';
    21742022        }
    21752023
    … …  
    24582306                                                                        <?php endforeach; ?>
    24592307                                                                </div>
     2308                                                                <div class="oznaczai-symbol-row">
     2309                                                                        <label class="oznaczai-field">
     2310                                                                                <span><?php esc_html_e( 'Label symbol', 'oznaczai-image-labeling' ); ?></span>
     2311                                                                                <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[icon_variant]" data-oznaczai-icon-variant>
     2312                                                                                        <?php foreach ( self::get_icon_variants() as $variant_key => $variant ) : ?>
     2313                                                                                                <option value="<?php echo esc_attr( $variant_key ); ?>" <?php selected( $this->sanitize_icon_variant( $settings['icon_variant'] ), $variant_key ); ?>><?php echo esc_html( $variant['label'] ); ?></option>
     2314                                                                                        <?php endforeach; ?>
     2315                                                                                </select>
     2316                                                                        </label>
     2317                                                                        <p class="description">
     2318                                                                                <?php esc_html_e( 'The OznaczAI symbol is the default. The EU options use the official European Commission icons for labelling AI-generated content. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning.', 'oznaczai-image-labeling' ); ?>
     2319                                                                        </p>
     2320                                                                        <?php $this->render_toggle_control( 'icon_show_text', __( 'Show text next to the symbol', 'oznaczai-image-labeling' ), __( 'Turn this off to show only the symbol when it already contains its own wording.', 'oznaczai-image-labeling' ), $settings ); ?>
     2321                                                                </div>
    24602322                                                                <div class="oznaczai-advanced-grid">
    24612323                                                                        <?php $this->render_toggle_control( 'show_icon', __( 'Show AI icon', 'oznaczai-image-labeling' ), __( 'Display the small AI mark next to the label text.', 'oznaczai-image-labeling' ), $settings ); ?>
    24622324                                                                        <?php $this->render_toggle_control( 'schema_markup', __( 'Schema.org JSON-LD', 'oznaczai-image-labeling' ), __( 'Add digitalSourceType metadata for labeled images found on the page.', 'oznaczai-image-labeling' ), $settings ); ?>
    24632325                                                                </div>
     2326                                                        </section>
     2327
     2328                                                        <section class="oznaczai-card" id="oznaczai-transparency">
     2329                                                                <div class="oznaczai-card-head">
     2330                                                                        <h2><?php esc_html_e( '7. AI transparency legend', 'oznaczai-image-labeling' ); ?></h2>
     2331                                                                        <span aria-hidden="true">i</span>
     2332                                                                </div>
     2333                                                                <p class="oznaczai-card-intro">
     2334                                                                        <?php esc_html_e( 'Place this shortcode on any page to explain what your AI labels mean. It follows your current label texts and symbol automatically.', 'oznaczai-image-labeling' ); ?>
     2335                                                                </p>
     2336                                                                <p><code class="oznaczai-shortcode">[oznaczai_transparency_legend]</code></p>
     2337                                                                <?php $transparency_page = $this->get_transparency_page(); ?>
     2338                                                                <?php if ( $transparency_page instanceof WP_Post ) : ?>
     2339                                                                        <p class="description">
     2340                                                                                <?php
     2341                                                                                printf(
     2342                                                                                        /* translators: %s: link to the existing page. */
     2343                                                                                        esc_html__( 'A page already exists: %s', 'oznaczai-image-labeling' ),
     2344                                                                                        '<a href="' . esc_url( (string) get_edit_post_link( $transparency_page->ID, 'url' ) ) . '">' . esc_html( get_the_title( $transparency_page ) ) . '</a>'
     2345                                                                                );
     2346                                                                                ?>
     2347                                                                        </p>
     2348                                                                <?php elseif ( current_user_can( 'publish_pages' ) ) : ?>
     2349                                                                        <p class="description"><?php esc_html_e( 'You can also create a draft page containing this legend. It is never published automatically.', 'oznaczai-image-labeling' ); ?></p>
     2350                                                                        <p>
     2351                                                                                <a class="button button-secondary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oznaczai_image_labeling_create_transparency_page' ), 'oznaczai_image_labeling_create_transparency_page' ) ); ?>">
     2352                                                                                        <?php esc_html_e( 'Create AI transparency page', 'oznaczai-image-labeling' ); ?>
     2353                                                                                </a>
     2354                                                                        </p>
     2355                                                                <?php endif; ?>
    24642356                                                        </section>
    24652357
    … …  
    25682460
    25692461        /**
    2570          * Render context appearance controls.
    2571          *
    2572          * @param string $context  Context key.
    2573          * @param string $label    Context label.
    2574          * @param array  $settings Current settings.
    2575          * @return void
    2576          */
    2577         private function render_context_appearance_controls( $context, $label, $settings ) {
    2578                 $prefix = 'context_' . $context;
    2579                 $positions = array(
    2580                         'top-left'     => __( 'Top left', 'oznaczai-image-labeling' ),
    2581                         'top-right'    => __( 'Top right', 'oznaczai-image-labeling' ),
    2582                         'bottom-left'  => __( 'Bottom left', 'oznaczai-image-labeling' ),
    2583                         'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),
    2584                         'below'        => __( 'Below image / section', 'oznaczai-image-labeling' ),
    2585                         'bar'          => __( 'Subtle bar', 'oznaczai-image-labeling' ),
    2586                 );
    2587                 ?>
    2588                 <div class="oznaczai-context-style-card" data-oznaczai-context-style="<?php echo esc_attr( $context ); ?>">
    2589                         <h3><?php echo esc_html( $label ); ?></h3>
    2590                         <div class="oznaczai-mode-row">
    2591                                 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="global" <?php checked( $settings[ $prefix . '_mode' ], 'global' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use global settings', 'oznaczai-image-labeling' ); ?></label>
    2592                                 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="preset" <?php checked( $settings[ $prefix . '_mode' ], 'preset' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use separate preset', 'oznaczai-image-labeling' ); ?></label>
    2593                                 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="custom" <?php checked( $settings[ $prefix . '_mode' ], 'custom' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Customize manually', 'oznaczai-image-labeling' ); ?></label>
    2594                         </div>
    2595                         <div data-oznaczai-context-preset="<?php echo esc_attr( $context ); ?>">
    2596                                 <label class="oznaczai-field">
    2597                                         <span><?php esc_html_e( 'Preset', 'oznaczai-image-labeling' ); ?></span>
    2598                                         <?php $this->render_preset_select( $prefix . '_preset', $settings[ $prefix . '_preset' ] ); ?>
    2599                                 </label>
    2600                         </div>
    2601                         <div class="oznaczai-style-grid" data-oznaczai-context-custom="<?php echo esc_attr( $context ); ?>">
    2602                                 <?php
    2603                                 $this->render_style_input( $prefix . '_text_color', __( 'Text color', 'oznaczai-image-labeling' ), 'color', $settings );
    2604                                 ?>
    2605                                 <div class="oznaczai-background-tools">
    2606                                         <p class="oznaczai-label"><?php esc_html_e( 'Background', 'oznaczai-image-labeling' ); ?></p>
    2607                                         <label class="oznaczai-checkline">
    2608                                                 <input type="checkbox" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_no_background]" value="1" <?php checked( ! empty( $settings[ $prefix . '_no_background' ] ) ); ?> data-oznaczai-context-input>
    2609                                                 <?php esc_html_e( 'No background', 'oznaczai-image-labeling' ); ?>
    2610                                         </label>
    2611                                 </div>
    2612                                 <?php
    2613                                 $this->render_style_input( $prefix . '_bg_color', __( 'Background color', 'oznaczai-image-labeling' ), 'color', $settings );
    2614                                 ?>
    2615                                 <label class="oznaczai-field oznaczai-range-field">
    2616                                         <span><?php esc_html_e( 'Background opacity', 'oznaczai-image-labeling' ); ?></span>
    2617                                         <input type="range" min="0" max="100" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_bg_opacity]" value="<?php echo esc_attr( $settings[ $prefix . '_bg_opacity' ] ); ?>" data-oznaczai-context-input>
    2618                                         <output><?php echo esc_html( absint( $settings[ $prefix . '_bg_opacity' ] ) ); ?>%</output>
    2619                                 </label>
    2620                                 <?php
    2621                                 $this->render_style_input( $prefix . '_border_color', __( 'Border color', 'oznaczai-image-labeling' ), 'color', $settings );
    2622                                 $this->render_style_input( $prefix . '_border_width', __( 'Border width', 'oznaczai-image-labeling' ), 'number', $settings, 0, 6 );
    2623                                 $this->render_style_input( $prefix . '_radius', __( 'Radius', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );
    2624                                 $this->render_style_input( $prefix . '_font_size', __( 'Font size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 18 );
    2625                                 $this->render_style_input( $prefix . '_font_weight', __( 'Font weight', 'oznaczai-image-labeling' ), 'number', $settings, 300, 800 );
    2626                                 $this->render_style_input( $prefix . '_padding_x', __( 'Horizontal padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 24 );
    2627                                 $this->render_style_input( $prefix . '_padding_y', __( 'Vertical padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );
    2628                                 $this->render_style_input( $prefix . '_icon_color', __( 'AI icon color', 'oznaczai-image-labeling' ), 'color', $settings );
    2629                                 $this->render_style_input( $prefix . '_icon_size', __( 'AI icon size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 24 );
    2630                                 ?>
    2631                                 <?php $this->render_toggle_control( $prefix . '_shadow', __( 'Subtle shadow', 'oznaczai-image-labeling' ), __( 'Add a restrained shadow to this context label.', 'oznaczai-image-labeling' ), $settings ); ?>
    2632                                 <?php if ( in_array( $context, array( 'background', 'slider' ), true ) ) : ?>
    2633                                         <label class="oznaczai-field">
    2634                                                 <span><?php esc_html_e( 'Label position', 'oznaczai-image-labeling' ); ?></span>
    2635                                                 <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_position]" data-oznaczai-context-input>
    2636                                                         <?php foreach ( $positions as $value => $position_label ) : ?>
    2637                                                                 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings[ $prefix . '_position' ], $value ); ?>><?php echo esc_html( $position_label ); ?></option>
    2638                                                         <?php endforeach; ?>
    2639                                                 </select>
    2640                                         </label>
    2641                                 <?php endif; ?>
    2642                         </div>
    2643                 </div>
    2644                 <?php
    2645         }
    2646 
    2647         /**
    26482462         * Render a compact style input.
    26492463         *
    … …  
    27642578
    27652579        /**
     2580         * Explanations shown in the transparency legend.
     2581         *
     2582         * These describe what the site owner stated about an image. They are not
     2583         * legal statements and must not be worded as any kind of compliance claim.
     2584         *
     2585         * @return array<string,string>
     2586         */
     2587        private function get_legend_descriptions() {
     2588                return array(
     2589                        'generated'     => __( 'The site owner has marked this image as generated using AI.', 'oznaczai-image-labeling' ),
     2590                        'modified'      => __( 'The site owner has marked this image as modified using AI.', 'oznaczai-image-labeling' ),
     2591                        'visualization' => __( 'The site owner has marked this image as a visualization created using AI.', 'oznaczai-image-labeling' ),
     2592                );
     2593        }
     2594
     2595        /**
     2596         * Render the symbol used for a status inside the legend.
     2597         *
     2598         * @param string $status  Image status.
     2599         * @param string $variant Active symbol variant.
     2600         * @return string
     2601         */
     2602        private function render_legend_symbol( $status, $variant ) {
     2603                $variants = self::get_icon_variants();
     2604                $symbols  = isset( $variants[ $variant ]['symbols'] ) ? $variants[ $variant ]['symbols'] : array();
     2605                $symbol   = isset( $symbols[ $status ] ) ? $symbols[ $status ] : array();
     2606
     2607                if ( ! empty( $symbol['onLight'] ) ) {
     2608                        return sprintf(
     2609                                '<img class="oznaczai-legend__symbol oznaczai-legend__symbol--image" src="%1$s" alt="" width="%2$d" height="20" loading="lazy" decoding="async">',
     2610                                esc_url( $symbol['onLight'] ),
     2611                                absint( round( 20 * ( isset( $symbol['ratio'] ) ? (float) $symbol['ratio'] : 1 ) ) )
     2612                        );
     2613                }
     2614
     2615                return sprintf(
     2616                        '<span class="oznaczai-legend__symbol" aria-hidden="true">%s</span>',
     2617                        esc_html( isset( $symbol['text'] ) ? $symbol['text'] : 'AI' )
     2618                );
     2619        }
     2620
     2621        /**
     2622         * Render the "AI transparency" legend.
     2623         *
     2624         * The legend is built from the statuses, label texts and symbol variant that
     2625         * are actually in use, so it never drifts from what visitors see on images.
     2626         *
     2627         * @param array $atts Shortcode attributes.
     2628         * @return string
     2629         */
     2630        public function render_transparency_legend_shortcode( $atts ) {
     2631                $atts = shortcode_atts(
     2632                        array(
     2633                                'title' => '',
     2634                        ),
     2635                        $atts,
     2636                        'oznaczai_transparency_legend'
     2637                );
     2638
     2639                $settings     = $this->get_settings();
     2640                $variant      = $this->sanitize_icon_variant( $settings['icon_variant'] );
     2641                $descriptions = $this->get_legend_descriptions();
     2642                $title        = $this->sanitize_label( $atts['title'] );
     2643                $output       = '<div class="oznaczai-legend">';
     2644
     2645                if ( '' !== $title ) {
     2646                        $output .= '<h2 class="oznaczai-legend__title">' . esc_html( $title ) . '</h2>';
     2647                }
     2648
     2649                $output .= '<ul class="oznaczai-legend__list">';
     2650
     2651                foreach ( array( 'generated', 'modified', 'visualization' ) as $status ) {
     2652                        $output .= sprintf(
     2653                                '<li class="oznaczai-legend__item oznaczai-legend__item--%1$s">%2$s<span class="oznaczai-legend__body"><span class="oznaczai-legend__name">%3$s</span><span class="oznaczai-legend__description">%4$s</span></span></li>',
     2654                                esc_attr( $status ),
     2655                                $this->render_legend_symbol( $status, $variant ),
     2656                                esc_html( $this->get_configured_label( $status ) ),
     2657                                esc_html( isset( $descriptions[ $status ] ) ? $descriptions[ $status ] : '' )
     2658                        );
     2659                }
     2660
     2661                $output .= '</ul>';
     2662                $output .= '<p class="oznaczai-legend__note">' . esc_html__( 'Labels are applied by the site owner. Not every image created or edited with AI carries a label, and these markings do not constitute a legal assessment.', 'oznaczai-image-labeling' ) . '</p>';
     2663                $output .= '</div>';
     2664
     2665                return $output;
     2666        }
     2667
     2668        /**
     2669         * Return the existing transparency page, if one is still usable.
     2670         *
     2671         * @return WP_Post|null
     2672         */
     2673        private function get_transparency_page() {
     2674                $page_id = absint( get_option( self::OPTION_TRANSPARENCY_PAGE, 0 ) );
     2675                if ( ! $page_id ) {
     2676                        return null;
     2677                }
     2678
     2679                $page = get_post( $page_id );
     2680                if ( ! $page instanceof WP_Post || 'page' !== $page->post_type || 'trash' === $page->post_status ) {
     2681                        return null;
     2682                }
     2683
     2684                return $page;
     2685        }
     2686
     2687        /**
     2688         * Create a draft "AI transparency" page containing the legend shortcode.
     2689         *
     2690         * The page is only ever created on an explicit administrator action and is
     2691         * always left as a draft, so nothing is published without review.
     2692         *
     2693         * @return void
     2694         */
     2695        public function handle_create_transparency_page() {
     2696                if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'publish_pages' ) ) {
     2697                        wp_die(
     2698                                esc_html__( 'You do not have permission to create this page.', 'oznaczai-image-labeling' ),
     2699                                '',
     2700                                array( 'response' => 403 )
     2701                        );
     2702                }
     2703
     2704                check_admin_referer( 'oznaczai_image_labeling_create_transparency_page' );
     2705
     2706                $settings_url = admin_url( 'admin.php?page=oznaczai-image-labeling' );
     2707                $existing     = $this->get_transparency_page();
     2708
     2709                if ( $existing instanceof WP_Post ) {
     2710                        wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'exists', $settings_url ) );
     2711                        exit;
     2712                }
     2713
     2714                $page_id = wp_insert_post(
     2715                        array(
     2716                                'post_title'   => __( 'AI transparency', 'oznaczai-image-labeling' ),
     2717                                'post_content' => '[oznaczai_transparency_legend]',
     2718                                'post_status'  => 'draft',
     2719                                'post_type'    => 'page',
     2720                        ),
     2721                        true
     2722                );
     2723
     2724                if ( is_wp_error( $page_id ) || ! $page_id ) {
     2725                        wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'error', $settings_url ) );
     2726                        exit;
     2727                }
     2728
     2729                update_option( self::OPTION_TRANSPARENCY_PAGE, absint( $page_id ), false );
     2730
     2731                wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'created', $settings_url ) );
     2732                exit;
     2733        }
     2734
     2735        /**
     2736         * Show the result of the transparency page action.
     2737         *
     2738         * @return void
     2739         */
     2740        public function show_transparency_page_notice() {
     2741                // The notice belongs to the OznaczAI settings screen, which is where the
     2742                // action redirects back to. Restricting it here stops the query parameter
     2743                // from surfacing the notice on unrelated wp-admin screens.
     2744                $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     2745                if ( ! $screen || 'toplevel_page_oznaczai-image-labeling' !== $screen->id ) {
     2746                        return;
     2747                }
     2748
     2749                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data.
     2750                if ( ! isset( $_GET['oznaczai_image_labeling_page'] ) || ! current_user_can( 'manage_options' ) ) {
     2751                        return;
     2752                }
     2753
     2754                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data.
     2755                $result = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_page'] ) );
     2756                $page   = $this->get_transparency_page();
     2757
     2758                if ( 'error' === $result ) {
     2759                        printf(
     2760                                '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
     2761                                esc_html__( 'The AI transparency page could not be created.', 'oznaczai-image-labeling' )
     2762                        );
     2763                        return;
     2764                }
     2765
     2766                if ( ! in_array( $result, array( 'created', 'exists' ), true ) || ! $page instanceof WP_Post ) {
     2767                        return;
     2768                }
     2769
     2770                $message = 'created' === $result
     2771                        ? __( 'Draft page created. Review the wording and publish it when you are ready.', 'oznaczai-image-labeling' )
     2772                        : __( 'The AI transparency page already exists.', 'oznaczai-image-labeling' );
     2773
     2774                printf(
     2775                        '<div class="notice notice-success is-dismissible"><p>%1$s <a href="%2$s">%3$s</a></p></div>',
     2776                        esc_html( $message ),
     2777                        esc_url( (string) get_edit_post_link( $page->ID, 'url' ) ),
     2778                        esc_html__( 'Edit the page', 'oznaczai-image-labeling' )
     2779                );
     2780        }
     2781
     2782        /**
    27662783         * Add media list column.
    27672784         *
    … …  
    28362853
    28372854        /**
     2855         * Available Media Library filter choices.
     2856         *
     2857         * @return array<string,string>
     2858         */
     2859        private function get_media_filter_choices() {
     2860                return array(
     2861                        'generated'     => __( 'AI label: AI-generated', 'oznaczai-image-labeling' ),
     2862                        'modified'      => __( 'AI label: AI-modified', 'oznaczai-image-labeling' ),
     2863                        'visualization' => __( 'AI label: AI visualization', 'oznaczai-image-labeling' ),
     2864                        'unlabeled'     => __( 'AI label: no label', 'oznaczai-image-labeling' ),
     2865                        'hint'          => __( 'AI label: metadata hint found', 'oznaczai-image-labeling' ),
     2866                );
     2867        }
     2868
     2869        /**
     2870         * Read the requested Media Library filter.
     2871         *
     2872         * @return string Empty string when no supported filter is active.
     2873         */
     2874        private function get_requested_media_filter() {
     2875                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters.
     2876                if ( ! isset( $_GET['oznaczai_image_labeling_filter'] ) ) {
     2877                        return '';
     2878                }
     2879
     2880                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters.
     2881                $filter = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_filter'] ) );
     2882
     2883                return array_key_exists( $filter, $this->get_media_filter_choices() ) ? $filter : '';
     2884        }
     2885
     2886        /**
     2887         * Render the OznaczAI filter next to the native Media Library filters.
     2888         *
     2889         * @param string $post_type Current list table post type.
     2890         * @param string $which     Tablenav position.
     2891         * @return void
     2892         */
     2893        public function render_media_library_filter( $post_type, $which ) {
     2894                global $pagenow;
     2895
     2896                if ( 'attachment' !== $post_type || 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) {
     2897                        return;
     2898                }
     2899
     2900                // The media list table renders this action twice; only the top bar carries
     2901                // the native filter controls and the Filter submit button.
     2902                if ( 'bar' !== $which ) {
     2903                        return;
     2904                }
     2905
     2906                $current = $this->get_requested_media_filter();
     2907
     2908                echo '<label class="screen-reader-text" for="oznaczai-image-labeling-filter">';
     2909                esc_html_e( 'Filter by AI label', 'oznaczai-image-labeling' );
     2910                echo '</label>';
     2911                echo '<select name="oznaczai_image_labeling_filter" id="oznaczai-image-labeling-filter">';
     2912                printf(
     2913                        '<option value=""%1$s>%2$s</option>',
     2914                        selected( $current, '', false ),
     2915                        esc_html__( 'All AI labels', 'oznaczai-image-labeling' )
     2916                );
     2917                foreach ( $this->get_media_filter_choices() as $value => $label ) {
     2918                        printf(
     2919                                '<option value="%1$s"%2$s>%3$s</option>',
     2920                                esc_attr( $value ),
     2921                                selected( $current, $value, false ),
     2922                                esc_html( $label )
     2923                        );
     2924                }
     2925                echo '</select>';
     2926        }
     2927
     2928        /**
     2929         * Apply the OznaczAI filter to the Media Library list query.
     2930         *
     2931         * Filtering happens in the WordPress query so large libraries are never
     2932         * transferred to the browser.
     2933         *
     2934         * @param WP_Query $query Current query.
     2935         * @return void
     2936         */
     2937        public function filter_media_library_query( $query ) {
     2938                global $pagenow;
     2939
     2940                if ( ! is_admin() || ! $query instanceof WP_Query || ! $query->is_main_query() ) {
     2941                        return;
     2942                }
     2943
     2944                if ( 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) {
     2945                        return;
     2946                }
     2947
     2948                $filter = $this->get_requested_media_filter();
     2949                if ( '' === $filter ) {
     2950                        return;
     2951                }
     2952
     2953                $meta_query = $query->get( 'meta_query' );
     2954                $meta_query = is_array( $meta_query ) ? $meta_query : array();
     2955
     2956                if ( 'hint' === $filter ) {
     2957                        $meta_query[] = array(
     2958                                'key'     => self::META_DETECTION_STATUS,
     2959                                'compare' => 'EXISTS',
     2960                        );
     2961                } elseif ( 'unlabeled' === $filter ) {
     2962                        // Current releases delete the meta row when a label is removed, but an
     2963                        // installation upgraded from an older version can still hold an empty
     2964                        // or explicit 'none' value, so both shapes count as unlabeled.
     2965                        $meta_query[] = array(
     2966                                'relation' => 'OR',
     2967                                array(
     2968                                        'key'     => self::META_STATUS,
     2969                                        'compare' => 'NOT EXISTS',
     2970                                ),
     2971                                array(
     2972                                        'key'     => self::META_STATUS,
     2973                                        'value'   => array( '', 'none' ),
     2974                                        'compare' => 'IN',
     2975                                ),
     2976                        );
     2977                } else {
     2978                        $meta_query[] = array(
     2979                                'key'     => self::META_STATUS,
     2980                                'value'   => $filter,
     2981                                'compare' => '=',
     2982                        );
     2983                }
     2984
     2985                $query->set( 'meta_query', $meta_query );
     2986
     2987                // OznaczAI only labels images. Without this, every PDF and video would
     2988                // appear under "no label". An explicit media type choice still wins.
     2989                if ( in_array( $filter, array( 'unlabeled', 'hint' ), true ) && ! $query->get( 'post_mime_type' ) ) {
     2990                        $query->set( 'post_mime_type', 'image' );
     2991                }
     2992        }
     2993
     2994        /**
    28382995         * Save status changed directly in the Media list.
    28392996         *
    … …  
    29333090
    29343091                        unset( $this->status_cache[ $post_id ], $this->label_cache[ $post_id ] );
    2935                         $this->update_labeled_media_index( $post_id, $status );
     3092                        $this->update_labeled_media_index( $post_id, $status, true );
    29363093                        ++$count;
    29373094                }
    29383095
    29393096                if ( $count > 0 ) {
     3097                        $this->persist_labeled_media_index();
    29403098                        $this->clear_media_map_cache();
    29413099                        $this->purge_page_cache();
  • oznaczai-image-labeling/tags/1.7.0/oznaczai.php

    r3641997 r3705284  
    33 * Plugin Name: OznaczAI – AI Image Disclosure for EU AI Act
    44 * Description: Adds visible labels to AI-generated and AI-modified images to support transparency disclosures, including EU AI Act use cases.
    5  * Version:     1.6.24
     5 * Version:     1.7.0
    66 * Author:      Golden Pixel
    77 * License:     GPL-2.0-or-later
    … …  
    99 * Text Domain: oznaczai-image-labeling
    1010 * Requires at least: 6.2
    11  * Tested up to: 7.0
     11 * Tested up to: 7.1
    1212 * Requires PHP: 7.4
    1313 */
    … …  
    3232}
    3333
    34 define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1.6.24' );
     34define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1.7.0' );
    3535define( 'OZNACZAI_IMAGE_LABELING_FILE', __FILE__ );
    3636define( 'OZNACZAI_IMAGE_LABELING_DIR', plugin_dir_path( __FILE__ ) );
  • oznaczai-image-labeling/tags/1.7.0/readme.txt

    r3641997 r3705284  
    33Tags: eu ai act, ai disclosure, ai transparency, deepfake, image labeling
    44Requires at least: 6.2
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.6.24
     7Stable tag: 1.7.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232* No label
    3333
    34 
    35 == Screenshots ==
    36 
    37 1. Label AI-generated and AI-modified images directly from the WordPress Media Library.
    38 2. Configure label presets, custom styling, hero and slider labels, and live preview.
    39 3. Visible AI disclosure labels on a real website, including hero images and images inside page content.
    40 4. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens.
    41 
    42 
    4334= Main features =
    4435
    4536* Quick status selection in the Media Library list view
    4637* Automatic saving without opening each attachment
     38* Filter the Media Library by AI label, including images with no label
    4739* Bulk labeling for multiple images
    4840* Custom label wording
    … …  
    7365The Caption below image preset places the disclosure outside the image so it does not cover visual content.
    7466
     67= Label symbol =
     68
     69The label symbol is separate from the label preset, so any symbol can be combined with any preset.
     70
     71* OznaczAI symbol (default)
     72* EU AI icon
     73* EU AI icons per label type
     74
     75The EU options use the official European Commission icons for labelling AI-generated content. AI-generated uses the Fully AI-Generated icon and AI-modified uses the Partially AI-Modified icon. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning.
     76
     77Choosing an EU icon does not change what your labels mean and is not a statement about legal compliance. Existing installations keep the OznaczAI symbol until the setting is changed.
     78
     79= AI transparency legend =
     80
     81The shortcode below outputs a short, neutral legend that explains the labels used on your site. It follows your current label texts and symbol automatically.
     82
     83[oznaczai_transparency_legend]
     84
     85You can optionally create a draft page containing this legend from the plugin settings screen. The page is never published automatically.
     86
    7587== Installation ==
    7688
    … …  
    117129No WordPress plugin can provide or guarantee complete legal compliance. Site owners should assess their own content, role, audience, and applicable legal requirements.
    118130
     131== Third-party assets ==
     132
     133The optional EU label symbols are the official icons for labelling AI-generated content published by the European Commission at https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content
     134
     135The published terms state that the icons "are made publicly available for everyone to use freely, without the need for attribution to the Commission or the AI Office". The files bundled with OznaczAI are unmodified copies of the official SVG assets; only their file names were changed. See assets/icons/eu/README.txt in the plugin folder for the recorded source and terms.
     136
     137OznaczAI is not affiliated with, endorsed by, or approved by the European Commission or the AI Office. Displaying an icon does not establish that any legal obligation applies to an image or that it has been met.
     138
    119139== Privacy ==
    120140
    … …  
    143163It automatically detects many common WordPress and Elementor implementations, including Slider Revolution 6/7 image layers and slide backgrounds. Custom themes or other third-party widgets may require one of the fallback CSS classes or the background label shortcode.
    144164
     165== Screenshots ==
     166
     1671. Label AI-generated and AI-modified images directly from the WordPress Media Library.
     1682. Configure label presets, custom styling, hero and slider labels, and live preview.
     1693. Visible AI disclosure labels on a real website, including hero images and images inside page content.
     1704. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens.
     171
    145172== Changelog ==
     173
     174= 1.7.0 =
     175* Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled.
     176* Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly.
     177* Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked.
     178* Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected.
     179* Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label.
     180* Removed a database write that could happen while a public page was being rendered.
     181* Bulk labeling now writes the labeled media index once instead of once per image.
     182* Reduced the size of the labeled media data passed to the browser by removing duplicated entries.
     183* The legacy storage migration now runs only on install and version change instead of on every request.
     184* Fixed a migration from very old versions that could store a label preset that no longer exists.
     185* Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint.
     186* Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it.
     187* Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged.
     188* Removed settings screen code that was no longer used by the current panel.
    146189
    147190= 1.6.24 =
  • oznaczai-image-labeling/tags/1.7.0/uninstall.php

    r3641997 r3705284  
    1414delete_option( 'oznaczai_image_labeling_labeled_media_ids' );
    1515delete_option( 'oznaczai_image_labeling_version' );
     16
     17/*
     18 * Only the stored reference is removed. Any page created through the
     19 * "AI transparency" action is ordinary site content that the administrator
     20 * owns and may have edited or published, so it is never deleted here.
     21 */
     22delete_option( 'oznaczai_image_labeling_transparency_page_id' );
     23
    1624delete_transient( 'oznaczai_image_labeling_media_map_v1' );
  • oznaczai-image-labeling/trunk/assets/css/admin.css

    r3641997 r3705284  
    595595}
    596596
     597/* Image symbol variants inside the admin previews. Mirrors the frontend rule so
     598   the preview keeps its WYSIWYG promise. */
     599.oznaczai-preset-demo b.oznaczai-preview-symbol--image,
     600.oznaczai-preview-badge b.oznaczai-preview-symbol--image,
     601.oznaczai-symbol-preview b.oznaczai-preview-symbol--image {
     602        min-width: 0;
     603        width: calc(18px * var(--oznaczai-preview-icon-ratio, 1));
     604        height: 18px;
     605        border-radius: 0;
     606        background-color: transparent;
     607        background-image: var(--oznaczai-preview-icon-on-light);
     608        background-repeat: no-repeat;
     609        background-position: center;
     610        background-size: contain;
     611        font-size: 0;
     612}
     613
     614.oznaczai-preview-badge--minimal b.oznaczai-preview-symbol--image,
     615.oznaczai-preview-badge--dark b.oznaczai-preview-symbol--image,
     616.oznaczai-preview-badge--contrast-light b.oznaczai-preview-symbol--image {
     617        background-image: var(--oznaczai-preview-icon-on-dark);
     618}
     619
    597620.oznaczai-preview-badge--icon-only {
    598621        width: 24px;
    … …  
    673696        border-top: 1px solid #e4e8ef;
    674697        align-items: start;
     698}
     699
     700.oznaczai-shortcode {
     701        display: inline-block;
     702        padding: 4px 8px;
     703        border: 1px solid #e4e8ef;
     704        border-radius: 4px;
     705        background: #f6f7f9;
     706        font-size: 12px;
     707}
     708
     709.oznaczai-symbol-row {
     710        display: grid;
     711        grid-template-columns: minmax(0, 280px) minmax(0, 1fr);
     712        gap: 12px 24px;
     713        align-items: start;
     714        margin-top: 18px;
     715        padding-top: 16px;
     716        border-top: 1px solid #e4e8ef;
     717}
     718
     719.oznaczai-symbol-row > .description {
     720        margin: 0;
     721        align-self: center;
     722        color: #6b7280;
     723        font-size: 12px;
     724        line-height: 1.5;
     725}
     726
     727.oznaczai-symbol-row > .oznaczai-toggle {
     728        grid-column: 1 / -1;
     729}
     730
     731@media (max-width: 782px) {
     732        .oznaczai-symbol-row {
     733                grid-template-columns: minmax(0, 1fr);
     734        }
    675735}
    676736
  • oznaczai-image-labeling/trunk/assets/css/frontend.css

    r3641997 r3705284  
    258258}
    259259
     260/* AI transparency legend ([oznaczai_transparency_legend]).
     261   Deliberately plain: it inherits the theme's typography and colours so it
     262   reads as part of the page rather than as a plugin widget. */
     263.oznaczai-legend {
     264        margin: 0 0 1.5em;
     265}
     266
     267.oznaczai-legend__title {
     268        margin: 0 0 0.75em;
     269        font-size: 1.15em;
     270}
     271
     272.oznaczai-legend__list {
     273        margin: 0;
     274        padding: 0;
     275        list-style: none;
     276}
     277
     278.oznaczai-legend__item {
     279        display: flex;
     280        align-items: flex-start;
     281        gap: 0.75em;
     282        padding: 0.6em 0;
     283        border-bottom: 1px solid rgba(127, 127, 127, 0.22);
     284}
     285
     286.oznaczai-legend__item:last-child {
     287        border-bottom: 0;
     288}
     289
     290.oznaczai-legend__symbol {
     291        display: inline-flex;
     292        align-items: center;
     293        justify-content: center;
     294        flex: 0 0 auto;
     295        width: 20px;
     296        height: 20px;
     297        margin-top: 0.15em;
     298        border-radius: 3px;
     299        background: rgba(127, 127, 127, 0.16);
     300        font-size: 9px;
     301        font-weight: 600;
     302        line-height: 1;
     303}
     304
     305.oznaczai-legend__symbol--image {
     306        width: auto;
     307        max-width: 100%;
     308        border-radius: 0;
     309        background: none;
     310}
     311
     312.oznaczai-legend__body {
     313        display: flex;
     314        flex-direction: column;
     315        gap: 0.15em;
     316        min-width: 0;
     317}
     318
     319.oznaczai-legend__name {
     320        font-weight: 600;
     321}
     322
     323.oznaczai-legend__description {
     324        opacity: 0.85;
     325}
     326
     327.oznaczai-legend__note {
     328        margin: 0.9em 0 0;
     329        font-size: 0.875em;
     330        opacity: 0.75;
     331}
     332
     333/* Image symbol variants (official EU icons).
     334   The artwork is a fixed two-tone drawing, so it is placed as an image rather
     335   than recoloured. The white artwork is the default because the default preset
     336   draws a light label on a dark translucent surface; the black artwork is used
     337   wherever the label sits on a light surface. */
     338.oznaczai-badge__icon--image {
     339        width: calc(var(--oznaczai-icon-size, 13px) * var(--oznaczai-icon-ratio, 1));
     340        height: var(--oznaczai-icon-size, 13px);
     341        border-radius: 0;
     342        background-color: transparent;
     343        background-image: var(--oznaczai-icon-on-dark);
     344        background-repeat: no-repeat;
     345        background-position: center;
     346        background-size: contain;
     347        font-size: 0;
     348}
     349
     350.oznaczai-badge--light .oznaczai-badge__icon--image,
     351.oznaczai-badge--glass .oznaczai-badge__icon--image,
     352.oznaczai-badge--caption .oznaczai-badge__icon--image,
     353.oznaczai-badge--icon-only .oznaczai-badge__icon--image,
     354.oznaczai-badge--contrast-dark .oznaczai-badge__icon--image,
     355.oznaczai-bg-note.oznaczai-badge--contrast-dark .oznaczai-badge__icon--image {
     356        background-color: transparent;
     357        background-image: var(--oznaczai-icon-on-light);
     358}
     359
     360.oznaczai-badge--contrast-light .oznaczai-badge__icon--image,
     361.oznaczai-bg-note.oznaczai-badge--contrast-light .oznaczai-badge__icon--image {
     362        background-color: transparent;
     363        background-image: var(--oznaczai-icon-on-dark);
     364}
     365
     366/* When the symbol already spells out its own meaning there is no text node to
     367   space away from it. */
     368.oznaczai-badge--symbol-lockup {
     369        gap: 0;
     370}
     371
    260372.oznaczai-badge__text {
    261373        min-width: 0;
  • oznaczai-image-labeling/trunk/assets/js/admin.js

    r3641997 r3705284  
    221221                }
    222222
    223                 function renderBadge(element, template, label, forceIcon) {
     223                function escapeAttribute(value) {
     224                        return escapeText(value).replace(/"/g, '&quot;');
     225                }
     226
     227                function activeSymbol(status) {
     228                        var variants = window.oznaczaiImageLabelingAdmin && window.oznaczaiImageLabelingAdmin.iconVariants
     229                                ? window.oznaczaiImageLabelingAdmin.iconVariants
     230                                : {};
     231                        var variant = variants[fieldValue('icon_variant', 'oznaczai')] || variants.oznaczai;
     232                        if (!variant || !variant.symbols) {
     233                                return null;
     234                        }
     235                        return variant.symbols[status] || variant.symbols.generated || null;
     236                }
     237
     238                function symbolMarkup(status) {
     239                        var symbol = activeSymbol(status);
     240
     241                        if (symbol && symbol.onLight && symbol.onDark) {
     242                                return '<b aria-hidden="true" class="oznaczai-preview-symbol--image" style="' +
     243                                        '--oznaczai-preview-icon-on-light:url(&quot;' + escapeAttribute(symbol.onLight) + '&quot;);' +
     244                                        '--oznaczai-preview-icon-on-dark:url(&quot;' + escapeAttribute(symbol.onDark) + '&quot;);' +
     245                                        '--oznaczai-preview-icon-ratio:' + (parseFloat(symbol.ratio) || 1) + '"></b>';
     246                        }
     247
     248                        return '<b aria-hidden="true">' + escapeText(symbol && symbol.text ? symbol.text : 'AI') + '</b>';
     249                }
     250
     251                // Mirrors shouldRenderText() in frontend.js so the preview cannot promise
     252                // something the published label will not do.
     253                function symbolCarriesWording(status) {
     254                        var symbol = activeSymbol(status);
     255                        return !!(symbol && symbol.onLight && symbol.ratio && parseFloat(symbol.ratio) > 1.5);
     256                }
     257
     258                function renderBadge(element, template, label, forceIcon, status) {
    224259                        var config = applyPresetConfig(element, template);
    225260                        var showIcon = forceIcon || (iconInput && iconInput.checked);
    226                         var text = config.iconOnly ? '' : escapeText(label);
     261                        var hideText = showIcon && !checked('icon_show_text') && symbolCarriesWording(status || previewType);
     262                        var text = (config.iconOnly || hideText) ? '' : escapeText(label);
    227263                        var isPresetDemo = element.hasAttribute('data-oznaczai-preset-preview');
    228264                        var baseClass = isPresetDemo ? 'oznaczai-preset-demo' : 'oznaczai-preview-badge';
    … …  
    231267                                element.classList.add('oznaczai-preview-badge--icon-only');
    232268                        }
    233                         element.innerHTML = (showIcon ? '<b aria-hidden="true">AI</b>' : '') + text;
     269                        element.innerHTML = (showIcon ? symbolMarkup(status || previewType) : '') + text;
    234270                        element.setAttribute('aria-label', label);
    235271                        return config;
    … …  
    291327                        }
    292328                        if (behavior === 'icon' && !previewLabel.querySelector('b')) {
    293                                 previewLabel.innerHTML = '<b aria-hidden="true">AI</b>';
     329                                previewLabel.innerHTML = symbolMarkup(previewType);
    294330                        }
    295331                        previewLabel.classList.toggle('oznaczai-preview-badge--short-label', behavior === 'short');
    … …  
    384420                        input.addEventListener('change', refreshPreview);
    385421                });
     422
     423                // Symbol variant and its text toggle change both the live preview and the
     424                // preset cards, so the cards are re-rendered too.
     425                Array.prototype.forEach.call(
     426                        document.querySelectorAll('[data-oznaczai-icon-variant], input[name$="[icon_show_text]"]'),
     427                        function (input) {
     428                                input.addEventListener('change', function () {
     429                                        renderPresetCards();
     430                                        refreshPreview();
     431                                });
     432                        }
     433                );
    386434
    387435                document.addEventListener('click', function (event) {
  • oznaczai-image-labeling/trunk/assets/js/frontend.js

    r3641997 r3705284  
    1515                '[style*="background-image"]'
    1616        ].join(',');
     17        var revolutionSelector = 'sr7-module, rs-module';
     18        var ownNodeSelector = '.oznaczai-badge, .oznaczai-bg-note, .oznaczai-popover';
    1719        var scheduled = false;
    1820        var config = window.oznaczaiImageLabelingFrontend || {};
    1921        var mediaMap = config.mediaMap || {};
    2022        var schemaEntries = {};
     23        var schemaDirty = false;
     24
     25        // Scan scheduling state. The observer collects the smallest useful roots
     26        // instead of forcing a full document rescan for every unrelated mutation.
     27        var pendingRoots = [];
     28        var pendingFull = false;
     29        var maxPendingRoots = 60;
     30
     31        // Guards against the observer reacting to OznaczAI's own DOM writes.
     32        var applying = 0;
     33        var observer = null;
     34
     35        // Caches. WeakMap/WeakSet keep entries collectable with their elements.
     36        var revolutionCache = new WeakMap();
     37        var revolutionEpoch = 0;
     38        var contrastKeys = new WeakMap();
     39        var noteSignatures = new WeakMap();
     40        var inlineBackgrounds = new WeakMap();
     41        var loadWatched = new WeakSet();
     42        var sliderScanQueued = new WeakSet();
     43
     44        var resizeTimer = null;
    2145
    2246        function meaningfulChildren(element) {
    … …  
    5579        }
    5680
    57         function buildIcon() {
     81        function iconVariant() {
     82                var variants = config.iconVariants || {};
     83                var key = config.iconVariant || 'oznaczai';
     84                return variants[key] || variants.oznaczai || null;
     85        }
     86
     87        function resolveSymbol(status) {
     88                var variant = iconVariant();
     89                if (!variant || !variant.symbols) {
     90                        return null;
     91                }
     92                return variant.symbols[status] || variant.symbols.generated || null;
     93        }
     94
     95        function buildIcon(status) {
    5896                var icon = document.createElement('span');
     97                var symbol = resolveSymbol(status);
     98
    5999                icon.className = 'oznaczai-badge__icon';
    60100                icon.setAttribute('aria-hidden', 'true');
    61                 icon.textContent = 'AI';
     101
     102                // Image symbols are drawn from a file rather than inlined, so the same
     103                // asset is cached once and the markup stays small on every page.
     104                if (symbol && symbol.onLight && symbol.onDark) {
     105                        icon.classList.add('oznaczai-badge__icon--image');
     106                        icon.style.setProperty('--oznaczai-icon-on-light', 'url("' + symbol.onLight + '")');
     107                        icon.style.setProperty('--oznaczai-icon-on-dark', 'url("' + symbol.onDark + '")');
     108                        icon.style.setProperty('--oznaczai-icon-ratio', symbol.ratio || 1);
     109                        return icon;
     110                }
     111
     112                icon.textContent = symbol && symbol.text ? symbol.text : 'AI';
    62113                return icon;
    63114        }
    64115
    65         function fillLabel(element, label, showIcon) {
     116        /**
     117         * Decide whether the wording is drawn beside the symbol. It is always kept
     118         * when there is no symbol, and it is only dropped for symbols that already
     119         * spell out their own meaning.
     120         */
     121        function shouldRenderText(status, showIcon) {
     122                var symbol;
     123
     124                if (!showIcon || config.iconShowText !== false) {
     125                        return true;
     126                }
     127
     128                symbol = resolveSymbol(status);
     129                return !(symbol && symbol.onLight && symbol.ratio && symbol.ratio > 1.5);
     130        }
     131
     132        function fillLabel(element, label, showIcon, status) {
     133                var withText = shouldRenderText(status, showIcon);
     134
    66135                element.textContent = '';
    67136                if (showIcon) {
    68                         element.appendChild(buildIcon());
    69                 }
    70                 element.appendChild(buildBadgeTextElement(label));
     137                        element.appendChild(buildIcon(status));
     138                }
     139                if (withText) {
     140                        element.appendChild(buildBadgeTextElement(label));
     141                }
     142                element.classList.toggle('oznaczai-badge--symbol-lockup', !withText);
     143                // The accessible name always carries the full wording, including when the
     144                // visible text is suppressed or the symbol is image-only.
    71145                element.setAttribute('role', 'note');
    72146                element.setAttribute('aria-label', label);
    … …  
    217291        }
    218292
     293        /**
     294         * Build a cheap key describing every input the contrast result depends on.
     295         * Canvas sampling only runs again when one of these actually changed.
     296         */
     297        function contrastKey(badge, source) {
     298                var badgeRect = badge.getBoundingClientRect();
     299                var sourceKey = '';
     300
     301                if (source && source.tagName === 'IMG') {
     302                        sourceKey = (source.currentSrc || source.getAttribute('src') || '') + ':' + (source.complete ? '1' : '0');
     303                }
     304
     305                return [
     306                        sourceKey,
     307                        Math.round(badgeRect.width),
     308                        Math.round(badgeRect.height),
     309                        Math.round(badgeRect.left),
     310                        Math.round(badgeRect.top)
     311                ].join('|');
     312        }
     313
    219314        function applyAutoContrast(badge, source, fallbackElement) {
    220315                var level;
     316                var key;
     317
     318                if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) {
     319                        clearContrastClasses(badge);
     320                        return;
     321                }
     322                if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') {
     323                        clearContrastClasses(badge);
     324                        return;
     325                }
     326
     327                key = contrastKey(badge, source);
     328                if (contrastKeys.get(badge) === key) {
     329                        return;
     330                }
    221331
    222332                clearContrastClasses(badge);
    223                 if (!config.autoContrast || !badge || badge.classList.contains('oznaczai-badge--caption')) {
    224                         return;
    225                 }
    226                 if (badge.classList.contains('oznaczai-badge--custom') && badge.style.getPropertyValue('--oznaczai-bg-color') === 'transparent') {
    227                         return;
    228                 }
     333                // Record the attempt before sampling. A sample that cannot succeed for
     334                // these inputs (tainted canvas, fully transparent ancestry) will not
     335                // succeed on a retry either, so retrying costs work and changes nothing.
     336                // A genuine change produces a different key and is picked up normally.
     337                contrastKeys.set(badge, key);
    229338
    230339                if (source && source.tagName === 'IMG') {
    … …  
    266375        function getCurrentTemplate() {
    267376                return config.template || 'minimal';
    268         }
    269 
    270         function templateClasses() {
    271                 return [
    272                         'oznaczai-badge--minimal',
    273                         'oznaczai-badge--compact',
    274                         'oznaczai-badge--subtle',
    275                         'oznaczai-badge--light',
    276                         'oznaczai-badge--caption',
    277                         'oznaczai-badge--glass',
    278                         'oznaczai-badge--dark',
    279                         'oznaczai-badge--icon-only'
    280                 ];
    281         }
    282 
    283         function containerTemplateClasses() {
    284                 return [
    285                         'oznaczai-template--minimal',
    286                         'oznaczai-template--compact',
    287                         'oznaczai-template--subtle',
    288                         'oznaczai-template--light',
    289                         'oznaczai-template--caption',
    290                         'oznaczai-template--glass',
    291                         'oznaczai-template--dark',
    292                         'oznaczai-template--icon-only'
    293                 ];
    294377        }
    295378
    … …  
    338421
    339422        function resolveContextPosition(context, fallback, status, presetOverride) {
    340                 var style = config.style || {};
    341                 var hero = style.hero || {};
    342423                var presetConfigs = config.presetConfigs || {};
    343424                var custom = resolveStyleCustom(context);
    … …  
    458539                }
    459540
     541                if (schemaEntries[absoluteUrl]) {
     542                        return;
     543                }
     544
    460545                schemaEntries[absoluteUrl] = {
    461546                        '@type': 'ImageObject',
    … …  
    463548                        digitalSourceType: sourceType
    464549                };
     550                schemaDirty = true;
    465551        }
    466552
    … …  
    469555                var graph;
    470556
    471                 if (!config.schemaMarkup) {
     557                if (!config.schemaMarkup || !schemaDirty) {
    472558                        return;
    473559                }
    … …  
    492578                        '@graph': graph
    493579                });
     580                schemaDirty = false;
    494581        }
    495582
    … …  
    501588                var device;
    502589                var behavior;
     590                var status;
    503591
    504592                if (!badge) {
    … …  
    506594                }
    507595
     596                status = getImageStatus(image);
    508597                badge.classList.remove('oznaczai-badge--symbol-only', 'oznaczai-badge--short-label');
    509598                badge.removeAttribute('title');
    … …  
    521610                if (behavior === 'icon') {
    522611                        if (!badge.querySelector('.oznaczai-badge__icon')) {
    523                                 badge.insertBefore(buildIcon(), badge.firstChild);
     612                                badge.insertBefore(buildIcon(status), badge.firstChild);
    524613                        }
    525614                        badge.classList.add('oznaczai-badge--symbol-only');
    … …  
    536625                if (width > 0 && width <= 160) {
    537626                        if (!badge.querySelector('.oznaczai-badge__icon')) {
    538                                 badge.insertBefore(buildIcon(), badge.firstChild);
     627                                badge.insertBefore(buildIcon(status), badge.firstChild);
    539628                        }
    540629                        badge.classList.remove('oznaczai-badge--short-label');
    … …  
    546635        }
    547636
    548         function refreshResponsiveSymbols() {
    549                 var images = document.querySelectorAll(imageSelector);
     637        function findImageBadge(image) {
     638                var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image');
     639                return container ? getDirectBadge(container) : null;
     640        }
     641
     642        /**
     643         * Re-evaluate responsive behaviour for already labeled images. This depends on
     644         * viewport and element width, so it runs on resize and after image load only.
     645         */
     646        function refreshResponsiveSymbols(root) {
     647                var scope = root && root.querySelectorAll ? root : document;
     648                var images = scope.querySelectorAll(imageSelector);
     649
    550650                Array.prototype.forEach.call(images, function (image) {
    551                         var container = image.closest('.oznaczai-wrapper, .oznaczai-container, .woocommerce-product-gallery__image');
    552                         var badge = container ? getDirectBadge(container) : null;
    553                         var template = resolveTemplate(getImageStatus(image), image, null);
    554                         var showIcon = getCurrentShowIcon();
    555                         updateResponsiveBadge(image, badge, template, showIcon, getCurrentImageLabel(image));
     651                        var badge = findImageBadge(image);
     652                        var template;
     653
     654                        if (!badge) {
     655                                return;
     656                        }
     657
     658                        template = resolveTemplate(getImageStatus(image), image, null);
     659                        updateResponsiveBadge(image, badge, template, getCurrentShowIcon(), getCurrentImageLabel(image));
     660                        applyAutoContrast(badge, image, badge.parentElement);
    556661                });
    557662        }
    … …  
    561666                        return false;
    562667                }
    563                 return !!image.closest('sr7-module, rs-module');
     668                return !!image.closest(revolutionSelector);
     669        }
     670
     671        /**
     672         * Everything the rendered badge depends on. When this is unchanged there is
     673         * nothing to rebuild, so the expensive path is skipped entirely.
     674         */
     675        function imageSignature(image) {
     676                return [
     677                        image.getAttribute('data-oznaczai-type') || '',
     678                        image.getAttribute('data-oznaczai-label') || '',
     679                        image.getAttribute('data-oznaczai-custom-label') || '',
     680                        image.getAttribute('data-oznaczai-preset') || ''
     681                ].join('|');
     682        }
     683
     684        function watchImageLoad(image) {
     685                if (loadWatched.has(image) || image.complete) {
     686                        return;
     687                }
     688                loadWatched.add(image);
     689                image.addEventListener('load', function () {
     690                        var badge = findImageBadge(image);
     691                        if (!badge) {
     692                                return;
     693                        }
     694                        withOwnMutations(function () {
     695                                updateResponsiveBadge(image, badge, resolveTemplate(getImageStatus(image), image, null), getCurrentShowIcon(), getCurrentImageLabel(image));
     696                                applyAutoContrast(badge, image, badge.parentElement);
     697                        });
     698                }, { once: true });
    564699        }
    565700
    566701        function createBadge(image) {
    567                 var label = getCurrentImageLabel(image);
    568                 var position = getCurrentPosition();
    569                 var status = getImageStatus(image);
    570                 var context = resolveContext(image, null);
    571                 var template = resolveTemplate(status, image, context);
    572                 var showIcon = getCurrentShowIcon();
     702                var signature = imageSignature(image);
     703                var label;
     704                var position;
     705                var status;
     706                var context;
     707                var template;
     708                var showIcon;
    573709                var target;
    574710                var parent;
    … …  
    579715                var originalImageWidth;
    580716
     717                if (image.getAttribute('data-oznaczai-ready') === '1' && image.getAttribute('data-oznaczai-sig') === signature) {
     718                        return;
     719                }
     720
     721                label = getCurrentImageLabel(image);
     722                position = getCurrentPosition();
     723                status = getImageStatus(image);
     724                context = resolveContext(image, null);
     725                template = resolveTemplate(status, image, context);
     726                showIcon = getCurrentShowIcon();
     727
    581728                if (!label) {
    582729                        return;
    … …  
    587734                if (isManagedByRevolution(image)) {
    588735                        image.setAttribute('data-oznaczai-ready', '1');
    589                         return;
    590                 }
    591 
    592                 registerSchemaEntry(image.currentSrc || image.getAttribute('src'), getImageStatus(image));
     736                        image.setAttribute('data-oznaczai-sig', signature);
     737                        return;
     738                }
     739
     740                registerSchemaEntry(image.currentSrc || image.getAttribute('src'), status);
    593741
    594742                originalParentWidth = image.parentElement ? image.parentElement.getBoundingClientRect().width : 0;
    … …  
    684832                );
    685833                badge.classList.add('oznaczai-badge--' + template);
    686                 fillLabel(badge, label, showIcon);
     834                fillLabel(badge, label, showIcon, status);
    687835                applyStyleData(badge, status, template, context);
    688836                applyAutoContrast(badge, image, container);
    689837                updateResponsiveBadge(image, badge, template, showIcon, label);
    690838                setupPopover(badge, label);
     839                watchImageLoad(image);
    691840                image.setAttribute('data-oznaczai-ready', '1');
     841                image.setAttribute('data-oznaczai-sig', signature);
    692842        }
    693843
    … …  
    727877        }
    728878
     879        /**
     880         * mediaMap entries no longer carry the label text. Resolve it the same way a
     881         * regular image does so background and slider labels stay identical.
     882         */
     883        function resolveMapLabel(data) {
     884                var status = data && data.status ? data.status : 'generated';
     885                if (data && data.label) {
     886                        return data.label;
     887                }
     888                return (config.labels && config.labels[status]) || '';
     889        }
     890
     891        function mapEntryToData(data, url) {
     892                if (!data) {
     893                        return null;
     894                }
     895                // NOTE: `preset` is deliberately not carried over. Up to and including
     896                // 1.6.24 the background and Slider Revolution extractors never passed the
     897                // per-image preset through, so hero/slider labels have always used the
     898                // hero preset. Propagating it here would silently restyle existing sites
     899                // on upgrade, so the change is left for a release that can announce it.
     900                return {
     901                        id: data.id,
     902                        status: data.status,
     903                        label: resolveMapLabel(data),
     904                        url: url
     905                };
     906        }
     907
    729908        function addUniqueUrl(urls, value) {
    730909                var clean = typeof value === 'string' ? value.trim() : '';
    … …  
    8441023        }
    8451024
     1025        /**
     1026         * Cheap fingerprint used to decide whether the expensive full slide walk has
     1027         * to run again. Slider Revolution swaps sources and markup on slide change,
     1028         * so those are exactly the inputs worth watching.
     1029         */
     1030        function revolutionSlideKey(slide) {
     1031                var image = slide.querySelector ? slide.querySelector('img, sr7-bg, rs-bg') : null;
     1032                return [
     1033                        revolutionEpoch,
     1034                        slide.childElementCount,
     1035                        image ? (image.getAttribute('src') || image.getAttribute('data-src') || image.getAttribute('data-lazyload') || '') : '',
     1036                        slide.getAttribute('data-key') || slide.getAttribute('data-skey') || ''
     1037                ].join('|');
     1038        }
     1039
    8461040        function findAutomaticRevolutionSlideData(slide) {
    847                 var urls = extractRevolutionSlideUrls(slide);
     1041                var key = revolutionSlideKey(slide);
     1042                var cached = revolutionCache.get(slide);
     1043                var urls;
    8481044                var i;
    8491045                var data;
     1046                var result = null;
     1047
     1048                if (cached && cached.key === key) {
     1049                        return cached.data;
     1050                }
     1051
     1052                urls = extractRevolutionSlideUrls(slide);
    8501053
    8511054                for (i = 0; i < urls.length; i += 1) {
    8521055                        data = lookupMedia(urls[i]);
    8531056                        if (data) {
    854                                 return {
    855                                         id: data.id,
    856                                         status: data.status,
    857                                         label: data.label,
    858                                         url: urls[i]
    859                                 };
    860                         }
    861                 }
    862 
    863                 return null;
     1057                                result = mapEntryToData(data, urls[i]);
     1058                                break;
     1059                        }
     1060                }
     1061
     1062                revolutionCache.set(slide, { key: key, data: result });
     1063                return result;
    8641064        }
    8651065
    … …  
    8851085                        data = lookupMedia(urls[i]);
    8861086                        if (data) {
    887                                 return {
    888                                         id: data.id,
    889                                         status: data.status,
    890                                         label: data.label,
    891                                         url: urls[i]
    892                                 };
     1087                                return mapEntryToData(data, urls[i]);
    8931088                        }
    8941089                }
    … …  
    8981093
    8991094        function findSliderOwner(element) {
    900                 var revolution = element.closest('sr7-module, rs-module');
     1095                var revolution = element.closest(revolutionSelector);
    9011096                if (revolution) {
    9021097                        return revolution;
    … …  
    9101105
    9111106        function findSectionOwner(element) {
    912                 var revolution = element.closest('sr7-module, rs-module');
     1107                var revolution = element.closest(revolutionSelector);
    9131108                if (revolution) {
    9141109                        return revolution;
    … …  
    9341129                var note;
    9351130                var template;
     1131                var signature;
    9361132                if (!owner || !owner.parentNode) {
    9371133                        return null;
    … …  
    9451141                        owner.parentNode.insertBefore(note, owner.nextSibling);
    9461142                }
    947                 setBelowNoteAlignment(note, position);
    948                 fillLabel(note, label, config.showIcon !== false);
     1143
    9491144                status = status || 'generated';
    9501145                context = context || 'background';
    9511146                template = preset || resolveTemplate(status, null, context);
     1147                signature = [label, position, status, template, context].join('|');
     1148
     1149                // Slider libraries mutate slide classes on every transition frame. Without
     1150                // this guard the note would be torn down and rebuilt each time.
     1151                if (noteSignatures.get(note) === signature) {
     1152                        return note;
     1153                }
     1154                noteSignatures.set(note, signature);
     1155
     1156                setBelowNoteAlignment(note, position);
     1157                fillLabel(note, label, config.showIcon !== false, status);
    9521158                note.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only');
    9531159                note.classList.add('oznaczai-badge--' + template);
    … …  
    10541260                if (active) {
    10551261                        labeled = active.hasAttribute('data-oznaczai-slide-label') ? active : null;
    1056                 } else if (slider.matches('sr7-module, rs-module')) {
     1262                } else if (slider.matches(revolutionSelector)) {
    10571263                        labeled = null;
    10581264                } else {
    … …  
    10971303                if ('MutationObserver' in window) {
    10981304                        new MutationObserver(function () {
    1099                                 updateSliderNote(slider);
     1305                                // Slide transitions fire dozens of records per second. Collapse them
     1306                                // into one update per frame.
     1307                                if (sliderScanQueued.has(slider)) {
     1308                                        return;
     1309                                }
     1310                                sliderScanQueued.add(slider);
     1311                                window.requestAnimationFrame(function () {
     1312                                        sliderScanQueued.delete(slider);
     1313                                        withOwnMutations(function () {
     1314                                                updateSliderNote(slider);
     1315                                        });
     1316                                });
    11001317                        }).observe(slider, {
    11011318                                attributes: true,
    … …  
    11491366        function createOverlayLabel(target, data, display, context) {
    11501367                var badge = target.querySelector(':scope > .oznaczai-badge.oznaczai-badge--bg');
     1368                var status = data.status || 'generated';
     1369                var template;
     1370                var signature;
     1371
    11511372                context = context || 'background';
    1152                 var template = data.preset || resolveTemplate(data.status || 'generated', null, context);
     1373                template = data.preset || resolveTemplate(status, null, context);
     1374                signature = [data.label, display, status, template, context].join('|');
     1375
     1376                if (badge && noteSignatures.get(badge) === signature) {
     1377                        return;
     1378                }
    11531379
    11541380                target.classList.add('oznaczai-bg-target');
    … …  
    11701396                }
    11711397
     1398                noteSignatures.set(badge, signature);
     1399
    11721400                badge.classList.remove('oznaczai-badge--minimal', 'oznaczai-badge--compact', 'oznaczai-badge--subtle', 'oznaczai-badge--light', 'oznaczai-badge--caption', 'oznaczai-badge--glass', 'oznaczai-badge--dark', 'oznaczai-badge--icon-only', 'oznaczai-badge--hero-bar');
    11731401                badge.classList.add('oznaczai-badge--' + template);
    … …  
    11751403                        badge.classList.add('oznaczai-badge--hero-bar');
    11761404                }
    1177                 fillLabel(badge, data.label, config.showIcon !== false);
    1178                 applyStyleData(badge, data.status || 'generated', template, context);
     1405                fillLabel(badge, data.label, config.showIcon !== false, status);
     1406                applyStyleData(badge, status, template, context);
    11791407                applyAutoContrast(badge, null, target);
    11801408                setupPopover(badge, data.label);
    … …  
    12491477                        return;
    12501478                }
     1479                // A negative result is remembered too. Without this, every generic
     1480                // hero/banner/Elementor node was re-measured with getComputedStyle on
     1481                // every single scan.
     1482                if (target.getAttribute('data-oznaczai-bg-checked') === '1') {
     1483                        return;
     1484                }
    12511485                // Real content images are handled by createBadge(). A generic hero/banner
    12521486                // selector must never promote an <img> into a second background disclosure.
    12531487                if (target.matches && target.matches('img, picture, source')) {
     1488                        target.setAttribute('data-oznaczai-bg-checked', '1');
    12541489                        return;
    12551490                }
    … …  
    12571492                if (data) {
    12581493                        applyBackgroundLabel(target, data);
    1259                 }
     1494                        return;
     1495                }
     1496                target.setAttribute('data-oznaczai-bg-checked', '1');
    12601497        }
    12611498
    … …  
    12711508        }
    12721509
    1273         function scan(root) {
    1274                 var images;
    1275                 var manualBackgrounds;
    1276                 var automaticBackgrounds;
    1277                 var monitoredSliders;
    1278                 var revolutionSliders;
    1279 
    1280                 root = root || document;
    1281                 images = collect(root, imageSelector);
    1282                 manualBackgrounds = collect(root, manualBackgroundSelector);
    1283                 automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : [];
    1284                 monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]');
    1285                 revolutionSliders = config.autoBackgrounds ? collect(root, 'sr7-module, rs-module') : [];
     1510        function scanRoot(root) {
     1511                var images = collect(root, imageSelector);
     1512                var manualBackgrounds = collect(root, manualBackgroundSelector);
     1513                var automaticBackgrounds = config.autoBackgrounds ? collect(root, autoBackgroundSelector) : [];
     1514                var revolutionSliders = config.autoBackgrounds ? collect(root, revolutionSelector) : [];
     1515                var monitoredSliders = collect(root, '[data-oznaczai-slider-observed="1"]');
    12861516
    12871517                images.forEach(createBadge);
    … …  
    12901520                revolutionSliders.forEach(scanRevolutionSlider);
    12911521                monitoredSliders.forEach(updateSliderNote);
    1292                 refreshResponsiveSymbols();
    1293                 flushSchemaMarkup();
    1294         }
    1295 
    1296         function scheduleScan() {
     1522        }
     1523
     1524        /**
     1525         * Run a callback with the global observer detached so OznaczAI never reacts
     1526         * to its own DOM writes. disconnect() also empties the pending record queue,
     1527         * which is what actually breaks the observer -> scan -> observer loop.
     1528         */
     1529        function withOwnMutations(callback) {
     1530                applying += 1;
     1531                if (observer) {
     1532                        observer.disconnect();
     1533                }
     1534                try {
     1535                        callback();
     1536                } finally {
     1537                        applying -= 1;
     1538                        if (observer && applying === 0) {
     1539                                startObserving();
     1540                        }
     1541                }
     1542        }
     1543
     1544        function scan(root) {
     1545                withOwnMutations(function () {
     1546                        scanRoot(root || document);
     1547                        flushSchemaMarkup();
     1548                });
     1549        }
     1550
     1551        function isOwnNode(node) {
     1552                return !!(node && node.nodeType === 1 && node.closest && node.closest(ownNodeSelector));
     1553        }
     1554
     1555        /**
     1556         * Compare only the inline background-image declaration. This reads the
     1557         * element's own style object, so unlike getComputedStyle it does not force
     1558         * the browser to flush pending style or layout work.
     1559         */
     1560        function inlineBackgroundChanged(element) {
     1561                var current;
     1562
     1563                if (!element || !element.style) {
     1564                        return true;
     1565                }
     1566
     1567                current = element.style.backgroundImage || '';
     1568                if (inlineBackgrounds.get(element) === current) {
     1569                        return false;
     1570                }
     1571
     1572                inlineBackgrounds.set(element, current);
     1573                return true;
     1574        }
     1575
     1576        function queueRoot(node) {
     1577                var scopeRoot;
     1578
     1579                if (pendingFull || !node || node.nodeType !== 1 || isOwnNode(node)) {
     1580                        return;
     1581                }
     1582
     1583                // Anything inside a Slider Revolution module has to be evaluated at module
     1584                // level, because slide detection works on the whole module.
     1585                scopeRoot = node.closest ? node.closest(revolutionSelector) : null;
     1586                scopeRoot = scopeRoot || node;
     1587
     1588                if (pendingRoots.indexOf(scopeRoot) !== -1) {
     1589                        return;
     1590                }
     1591
     1592                if (pendingRoots.length >= maxPendingRoots) {
     1593                        pendingFull = true;
     1594                        pendingRoots.length = 0;
     1595                        return;
     1596                }
     1597
     1598                pendingRoots.push(scopeRoot);
     1599        }
     1600
     1601        function runScheduledScan() {
     1602                var roots = pendingRoots.slice();
     1603                var full = pendingFull;
     1604
     1605                scheduled = false;
     1606                pendingRoots.length = 0;
     1607                pendingFull = false;
     1608
     1609                withOwnMutations(function () {
     1610                        if (full) {
     1611                                scanRoot(document);
     1612                        } else {
     1613                                roots.forEach(function (root) {
     1614                                        if (root === document || (root.isConnected !== false)) {
     1615                                                scanRoot(root);
     1616                                        }
     1617                                });
     1618                        }
     1619                        flushSchemaMarkup();
     1620                });
     1621        }
     1622
     1623        function scheduleScan(full) {
     1624                if (full === true) {
     1625                        pendingFull = true;
     1626                        pendingRoots.length = 0;
     1627                }
    12971628                if (scheduled) {
    12981629                        return;
    12991630                }
    13001631                scheduled = true;
    1301                 window.requestAnimationFrame(function () {
    1302                         scheduled = false;
    1303                         scan(document);
    1304                 });
     1632                window.requestAnimationFrame(runScheduledScan);
     1633        }
     1634
     1635        function scheduleFullScan() {
     1636                scheduleScan(true);
     1637        }
     1638
     1639        function startObserving() {
     1640                if (!observer) {
     1641                        return;
     1642                }
     1643                observer.observe(document.body, {
     1644                        childList: true,
     1645                        subtree: true,
     1646                        attributes: true,
     1647                        // `class` is intentionally not observed globally. Slider state classes
     1648                        // are handled by the per-slider observer in ensureSliderMonitoring(),
     1649                        // and every other class change on the page is irrelevant to labeling.
     1650                        attributeFilter: ['style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb']
     1651                });
     1652        }
     1653
     1654        function handleMutations(mutations) {
     1655                var queued = false;
     1656                var i;
     1657                var mutation;
     1658                var j;
     1659                var node;
     1660
     1661                if (applying > 0) {
     1662                        return;
     1663                }
     1664
     1665                for (i = 0; i < mutations.length; i += 1) {
     1666                        mutation = mutations[i];
     1667
     1668                        if (mutation.type === 'attributes') {
     1669                                if (isOwnNode(mutation.target)) {
     1670                                        continue;
     1671                                }
     1672                                // Scroll animations and motion effects rewrite `style` on every
     1673                                // frame without ever touching the background. Reading the inline
     1674                                // value is cheap and avoids forcing a style recalculation for
     1675                                // changes that cannot affect labeling.
     1676                                if (mutation.attributeName === 'style' && !inlineBackgroundChanged(mutation.target)) {
     1677                                        continue;
     1678                                }
     1679                                // A background source may have just been swapped in, so the cached
     1680                                // negative result for this element is no longer trustworthy.
     1681                                if (mutation.target.hasAttribute && mutation.target.hasAttribute('data-oznaczai-bg-checked')) {
     1682                                        mutation.target.removeAttribute('data-oznaczai-bg-checked');
     1683                                }
     1684                                queueRoot(mutation.target);
     1685                                queued = true;
     1686                                continue;
     1687                        }
     1688
     1689                        if (mutation.addedNodes) {
     1690                                for (j = 0; j < mutation.addedNodes.length; j += 1) {
     1691                                        node = mutation.addedNodes[j];
     1692                                        if (node.nodeType !== 1 || isOwnNode(node)) {
     1693                                                continue;
     1694                                        }
     1695                                        queueRoot(node);
     1696                                        queued = true;
     1697                                }
     1698                        }
     1699                }
     1700
     1701                if (queued) {
     1702                        scheduleScan();
     1703                }
     1704        }
     1705
     1706        function handleResize() {
     1707                if (resizeTimer) {
     1708                        window.clearTimeout(resizeTimer);
     1709                }
     1710                resizeTimer = window.setTimeout(function () {
     1711                        resizeTimer = null;
     1712                        withOwnMutations(function () {
     1713                                refreshResponsiveSymbols(document);
     1714                        });
     1715                }, 200);
     1716        }
     1717
     1718        function handleRevolutionEvent() {
     1719                // Slide media changed, so the per-slide URL cache has to be rebuilt.
     1720                revolutionEpoch += 1;
     1721                scheduleFullScan();
    13051722        }
    13061723
    13071724        function init() {
    13081725                scan(document);
     1726                withOwnMutations(function () {
     1727                        refreshResponsiveSymbols(document);
     1728                });
     1729
     1730                // Themes and lazy-loaders often finish late. These two passes are cheap
     1731                // now because processed images short-circuit on their signature.
    13091732                window.setTimeout(function () { scan(document); }, 700);
    13101733                window.setTimeout(function () { scan(document); }, 1800);
    1311                 window.addEventListener('resize', scheduleScan, { passive: true });
     1734                window.addEventListener('resize', handleResize, { passive: true });
    13121735
    13131736                // Slider Revolution 7 exposes native lifecycle events. Re-scan after a
    … …  
    13151738                // current-slide disclosure stay synchronized. Legacy SR6 remains covered
    13161739                // by the DOM observer and data-slideactive monitoring below.
    1317                 document.addEventListener('sr.module.ready', scheduleScan);
    1318                 document.addEventListener('sr.slide.afterChange', scheduleScan);
     1740                document.addEventListener('sr.module.ready', handleRevolutionEvent);
     1741                document.addEventListener('sr.slide.afterChange', handleRevolutionEvent);
    13191742
    13201743                if ('MutationObserver' in window) {
    1321                         new MutationObserver(function (mutations) {
    1322                                 var shouldScan = mutations.some(function (mutation) {
    1323                                         return (mutation.addedNodes && mutation.addedNodes.length > 0) || mutation.type === 'attributes';
    1324                                 });
    1325                                 if (shouldScan) {
    1326                                         scheduleScan();
    1327                                 }
    1328                         }).observe(document.body, {
    1329                                 childList: true,
    1330                                 subtree: true,
    1331                                 attributes: true,
    1332                                 attributeFilter: ['class', 'style', 'src', 'srcset', 'data-src', 'data-lazyload', 'data-lazysrc', 'data-lazy-src', 'data-bg', 'data-thumb', 'data-current', 'data-slideactive']
    1333                         });
     1744                        observer = new MutationObserver(handleMutations);
     1745                        startObserving();
    13341746                }
    13351747
  • oznaczai-image-labeling/trunk/changelog.txt

    r3641997 r3705284  
     1= 1.7.0 =
     2* Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled.
     3* Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly.
     4* Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked.
     5* Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected.
     6* Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label.
     7* Removed a database write that could happen while a public page was being rendered.
     8* Bulk labeling now writes the labeled media index once instead of once per image.
     9* Reduced the size of the labeled media data passed to the browser by removing duplicated entries.
     10* The legacy storage migration now runs only on install and version change instead of on every request.
     11* Fixed a migration from very old versions that could store a label preset that no longer exists.
     12* Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint.
     13* Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it.
     14* Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged.
     15* Removed settings screen code that was no longer used by the current panel.
     16
    117= 1.6.24 =
    218* Rebuilt preset cards with a simple flex column flow so title, description and preview badge can never overlap.
  • oznaczai-image-labeling/trunk/includes/class-oznaczai-image-labeling-plugin.php

    r3641997 r3705284  
    2121        const OPTION_LABELED_IDS        = 'oznaczai_image_labeling_labeled_media_ids';
    2222        const OPTION_VERSION            = 'oznaczai_image_labeling_version';
     23        const OPTION_TRANSPARENCY_PAGE  = 'oznaczai_image_labeling_transparency_page_id';
    2324        const TRANSIENT_MEDIA_MAP       = 'oznaczai_image_labeling_media_map_v1';
    2425
    … …  
    6768
    6869        /**
     70         * Cached labeled attachment index for the current request.
     71         *
     72         * @var array<int>|null
     73         */
     74        private $labeled_ids = null;
     75
     76        /**
     77         * Stale index entries detected while building the media map on a public
     78         * request. They are written out on the next administrative save.
     79         *
     80         * @var array<int>|null
     81         */
     82        private $labeled_ids_pending_cleanup = null;
     83
     84        /**
    6985         * Get singleton instance.
    7086         *
    … …  
    178194                add_filter( 'manage_media_columns', array( $this, 'add_media_column' ) );
    179195                add_action( 'manage_media_custom_column', array( $this, 'render_media_column' ), 10, 2 );
     196                add_action( 'restrict_manage_posts', array( $this, 'render_media_library_filter' ), 10, 2 );
     197                add_action( 'pre_get_posts', array( $this, 'filter_media_library_query' ) );
    180198                add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) );
    181199                add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_actions' ), 10, 3 );
    182200                add_action( 'admin_notices', array( $this, 'show_bulk_action_notice' ) );
     201                add_action( 'admin_notices', array( $this, 'show_transparency_page_notice' ) );
    183202                add_action( 'wp_ajax_oznaczai_image_labeling_update_media_status', array( $this, 'ajax_update_media_status' ) );
     203                add_action( 'admin_post_oznaczai_image_labeling_create_transparency_page', array( $this, 'handle_create_transparency_page' ) );
    184204
    185205                add_action( 'edit_attachment', array( $this, 'clear_media_map_cache' ) );
    … …  
    195215         */
    196216        public function maybe_upgrade() {
    197                 self::migrate_legacy_storage();
    198 
    199217                $stored_version = (string) get_option( self::OPTION_VERSION, '' );
    200218                if ( '' !== $stored_version && ! version_compare( $stored_version, OZNACZAI_IMAGE_LABELING_VERSION, '<' ) ) {
    201219                        return;
    202220                }
     221
     222                // Only reached on a fresh install or a version change. Installations that
     223                // are already up to date no longer pay for the legacy lookup on every
     224                // request. Activation still calls this directly, so upgrades that skip
     225                // deactivation are covered either way.
     226                self::migrate_legacy_storage();
    203227
    204228                if ( '' === $stored_version || version_compare( $stored_version, '1.6.0', '<' ) ) {
    … …  
    234258                add_shortcode( 'oznaczai_image_label', array( $this, 'render_badge_shortcode' ) );
    235259                add_shortcode( 'oznaczai_image_label_background', array( $this, 'render_background_badge_shortcode' ) );
     260                add_shortcode( 'oznaczai_transparency_legend', array( $this, 'render_transparency_legend_shortcode' ) );
    236261        }
    237262
    … …  
    281306                        'hero_position'       => 'below',
    282307                        'show_icon'           => 1,
     308                        'icon_variant'        => 'oznaczai',
     309                        'icon_show_text'      => 1,
    283310                        'bg_display'          => 'below',
    284311                        'auto_backgrounds'    => 1,
    … …  
    403430
    404431                        if ( empty( $stored['template'] ) && ! empty( $stored['style'] ) ) {
    405                                 $stored['template'] = 'light' === $stored['style'] ? 'light' : 'subtle';
     432                                // 'subtle' was dropped from the preset list, so it resolved to no
     433                                // preset configuration and the frontend already fell back to the
     434                                // 'minimal' custom properties. Storing 'minimal' keeps exactly the
     435                                // same rendered label while making the stored value valid again.
     436                                $stored['template'] = 'light' === $stored['style'] ? 'light' : 'minimal';
    406437                        }
    407438
    … …  
    626657
    627658        /**
     659         * Label symbol variants.
     660         *
     661         * A preset controls how the label looks. A symbol variant controls which
     662         * mark appears inside it. They are independent on purpose, so every existing
     663         * preset keeps working with every symbol.
     664         *
     665         * The default variant reproduces the historical OznaczAI "AI" mark exactly,
     666         * so an upgrade never changes an existing site's labels on its own.
     667         *
     668         * The bundled EU assets are the official European Commission icons. See
     669         * assets/icons/eu/README.txt for their source and terms of use.
     670         *
     671         * @return array<string,array<string,mixed>>
     672         */
     673        private static function get_icon_variants() {
     674                $base = OZNACZAI_IMAGE_LABELING_URL . 'assets/icons/eu/';
     675
     676                $basic = array(
     677                        'onLight' => $base . 'eu-ai-black.svg',
     678                        'onDark'  => $base . 'eu-ai-white.svg',
     679                        'ratio'   => 1,
     680                );
     681
     682                return array(
     683                        'oznaczai'  => array(
     684                                'label'   => __( 'OznaczAI symbol', 'oznaczai-image-labeling' ),
     685                                'symbols' => array(
     686                                        'generated'     => array( 'text' => 'AI' ),
     687                                        'modified'      => array( 'text' => 'AI' ),
     688                                        'visualization' => array( 'text' => 'AI' ),
     689                                ),
     690                        ),
     691                        'eu-basic'  => array(
     692                                'label'   => __( 'EU AI icon', 'oznaczai-image-labeling' ),
     693                                'symbols' => array(
     694                                        'generated'     => $basic,
     695                                        'modified'      => $basic,
     696                                        'visualization' => $basic,
     697                                ),
     698                        ),
     699                        'eu-status' => array(
     700                                'label'   => __( 'EU AI icons per label type', 'oznaczai-image-labeling' ),
     701                                'symbols' => array(
     702                                        'generated'     => array(
     703                                                'onLight' => $base . 'eu-ai-generated-black.svg',
     704                                                'onDark'  => $base . 'eu-ai-generated-white.svg',
     705                                                'ratio'   => 3.157,
     706                                        ),
     707                                        'modified'      => array(
     708                                                'onLight' => $base . 'eu-ai-modified-black.svg',
     709                                                'onDark'  => $base . 'eu-ai-modified-white.svg',
     710                                                'ratio'   => 3,
     711                                        ),
     712                                        // There is no official variant meaning "visualization", so this
     713                                        // status intentionally falls back to the neutral basic icon
     714                                        // instead of being presented as fully generated or modified.
     715                                        'visualization' => $basic,
     716                                ),
     717                        ),
     718                );
     719        }
     720
     721        /**
     722         * Validate a symbol variant key.
     723         *
     724         * @param string $variant Variant key.
     725         * @return string
     726         */
     727        private function sanitize_icon_variant( $variant ) {
     728                $variant = sanitize_key( $variant );
     729                return array_key_exists( $variant, self::get_icon_variants() ) ? $variant : 'oznaczai';
     730        }
     731
     732        /**
     733         * Whether the active symbol variant already carries its own wording.
     734         *
     735         * The EU per-type assets are wide lockups that spell out their meaning as
     736         * vector outlines, so repeating the label text beside them is redundant.
     737         *
     738         * @param string $variant Variant key.
     739         * @return bool
     740         */
     741        private function icon_variant_has_wording( $variant ) {
     742                return 'eu-status' === $variant;
     743        }
     744
     745        /**
    628746         * Validate a preset key.
    629747         *
    … …  
    706824
    707825        /**
    708          * Return custom style config for a supported visual context.
    709          *
    710          * @param string $context  Context key.
    711          * @param array  $settings Current settings.
    712          * @return array
    713          */
    714         private function get_context_custom_style_config( $context, $settings ) {
    715                 return array(
    716                         'textColor'    => $settings[ 'context_' . $context . '_text_color' ],
    717                         'noBackground' => ! empty( $settings[ 'context_' . $context . '_no_background' ] ),
    718                         'bgColor'      => $settings[ 'context_' . $context . '_bg_color' ],
    719                         'bgOpacity'    => (int) $settings[ 'context_' . $context . '_bg_opacity' ],
    720                         'borderColor'  => $settings[ 'context_' . $context . '_border_color' ],
    721                         'borderWidth'  => (int) $settings[ 'context_' . $context . '_border_width' ],
    722                         'radius'       => (int) $settings[ 'context_' . $context . '_radius' ],
    723                         'fontSize'     => (int) $settings[ 'context_' . $context . '_font_size' ],
    724                         'fontWeight'   => (int) $settings[ 'context_' . $context . '_font_weight' ],
    725                         'paddingX'     => (int) $settings[ 'context_' . $context . '_padding_x' ],
    726                         'paddingY'     => (int) $settings[ 'context_' . $context . '_padding_y' ],
    727                         'iconColor'    => $settings[ 'context_' . $context . '_icon_color' ],
    728                         'iconSize'     => (int) $settings[ 'context_' . $context . '_icon_size' ],
    729                         'shadow'       => ! empty( $settings[ 'context_' . $context . '_shadow' ] ),
    730                         'position'     => $settings[ 'context_' . $context . '_position' ],
    731                 );
    732         }
    733 
    734         /**
    735826         * Localized default public labels.
    736827         *
    … …  
    13321423
    13331424                        $valid_ids[] = $attachment_id;
     1425                        $full_url    = wp_get_attachment_url( $attachment_id );
    13341426                        $urls        = array_filter(
    13351427                                array(
    1336                                         wp_get_attachment_url( $attachment_id ),
     1428                                        $full_url,
    13371429                                        function_exists( 'wp_get_original_image_url' ) ? wp_get_original_image_url( $attachment_id ) : '',
    13381430                                )
    … …  
    13401432
    13411433                        $metadata = wp_get_attachment_metadata( $attachment_id );
    1342                         $full_url = wp_get_attachment_url( $attachment_id );
    13431434                        if ( $full_url && is_array( $metadata ) && ! empty( $metadata['sizes'] ) ) {
    13441435                                $base_url = trailingslashit( dirname( $full_url ) );
    … …  
    13651456                                        continue;
    13661457                                }
    1367                                 $map[ $path ]           = $data;
    1368                                 $map[ $path . '.webp' ] = $data;
    1369                                 $webp_path              = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path );
     1458                                $map[ $path ] = $data;
     1459
     1460                                // A "<file>.webp" companion path is intentionally not stored. The
     1461                                // frontend lookup already strips a trailing .webp and retries, so
     1462                                // the extra key was pure duplication of every entry. The variant
     1463                                // where the extension is replaced is still needed, because that
     1464                                // form cannot be derived by stripping.
     1465                                $webp_path = preg_replace( '/\.(?:jpe?g|png)$/i', '.webp', $path );
    13701466                                if ( is_string( $webp_path ) && $webp_path !== $path ) {
    13711467                                        $map[ $webp_path ] = $data;
    … …  
    13741470                }
    13751471
     1472                // Stale entries are recorded for the next admin-side write instead of
     1473                // being flushed here. This method runs on public page views, which must
     1474                // never issue a database write.
    13761475                if ( $valid_ids !== $ids ) {
    1377                         update_option( self::OPTION_LABELED_IDS, $valid_ids, false );
     1476                        $this->labeled_ids_pending_cleanup = $valid_ids;
    13781477                }
    13791478
    … …  
    13911490         */
    13921491        private function get_labeled_media_ids() {
     1492                if ( is_array( $this->labeled_ids ) ) {
     1493                        return $this->labeled_ids;
     1494                }
     1495
    13931496                $ids = get_option( self::OPTION_LABELED_IDS, null );
    13941497                if ( ! is_array( $ids ) ) {
    1395                         return $this->rebuild_labeled_media_index();
     1498                        $this->labeled_ids = $this->rebuild_labeled_media_index();
     1499                        return $this->labeled_ids;
    13961500                }
    13971501
    13981502                $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
    13991503                sort( $ids, SORT_NUMERIC );
     1504                $this->labeled_ids = $ids;
    14001505
    14011506                return $ids;
    … …  
    14461551                $ids = array_values( array_unique( $ids ) );
    14471552                sort( $ids, SORT_NUMERIC );
     1553                $this->labeled_ids = $ids;
    14481554                update_option( self::OPTION_LABELED_IDS, $ids, false );
    14491555
    … …  
    14561562         * @param int    $attachment_id Attachment ID.
    14571563         * @param string $status        Sanitized status.
     1564         * @param bool   $defer         Keep the change in memory instead of writing.
    14581565         * @return void
    14591566         */
    1460         private function update_labeled_media_index( $attachment_id, $status ) {
     1567        private function update_labeled_media_index( $attachment_id, $status, $defer = false ) {
    14611568                $attachment_id = absint( $attachment_id );
    14621569                if ( ! $attachment_id ) {
    … …  
    14721579
    14731580                sort( $ids, SORT_NUMERIC );
     1581                $this->labeled_ids = $ids;
     1582
     1583                if ( $defer ) {
     1584                        return;
     1585                }
     1586
     1587                $this->persist_labeled_media_index();
     1588        }
     1589
     1590        /**
     1591         * Write the labeled media index once.
     1592         *
     1593         * Bulk actions collect every change first and call this at the end, so a
     1594         * 200-image selection performs one write instead of 200.
     1595         *
     1596         * @return void
     1597         */
     1598        private function persist_labeled_media_index() {
     1599                $ids = $this->get_labeled_media_ids();
     1600
     1601                if ( is_array( $this->labeled_ids_pending_cleanup ) ) {
     1602                        $ids = array_values( array_intersect( $ids, $this->labeled_ids_pending_cleanup ) );
     1603                        sort( $ids, SORT_NUMERIC );
     1604                        $this->labeled_ids                 = $ids;
     1605                        $this->labeled_ids_pending_cleanup = null;
     1606                }
     1607
    14741608                update_option( self::OPTION_LABELED_IDS, $ids, false );
    14751609        }
    … …  
    14841618                $this->update_labeled_media_index( absint( $attachment_id ), 'none' );
    14851619                $this->clear_media_map_cache();
     1620        }
     1621
     1622        /**
     1623         * Check whether an attachment currently carries an OznaczAI label.
     1624         *
     1625         * @param int $attachment_id Attachment ID.
     1626         * @return bool
     1627         */
     1628        private function is_labeled_attachment( $attachment_id ) {
     1629                $attachment_id = absint( $attachment_id );
     1630                if ( ! $attachment_id ) {
     1631                        return false;
     1632                }
     1633
     1634                return in_array( $attachment_id, $this->get_labeled_media_ids(), true );
    14861635        }
    14871636
    … …  
    15791728         */
    15801729        public function clear_media_map_cache_after_metadata_update( $data, $attachment_id ) {
    1581                 unset( $attachment_id );
     1730                // This filter also runs for every plain upload and every thumbnail
     1731                // regeneration. Rebuilding the whole map for media that carries no label
     1732                // turned a 500-image import into 500 cold rebuilds on the next page view.
     1733                if ( ! $this->is_labeled_attachment( $attachment_id ) ) {
     1734                        return $data;
     1735                }
     1736
    15821737                $this->clear_media_map_cache();
    15831738                return $data;
    … …  
    16331788                                ),
    16341789                                'presetConfigs'   => self::get_preset_configs(),
     1790                                'iconVariants'   => self::get_icon_variants(),
     1791                                'iconVariant'    => $this->sanitize_icon_variant( $settings['icon_variant'] ),
     1792                                'iconShowText'   => ! empty( $settings['icon_show_text'] ),
    16351793                                'showIcon'       => ! empty( $settings['show_icon'] ),
    16361794                                'template'       => $settings['template'],
    … …  
    16961854                                        array(
    16971855                                                'presetConfigs' => self::get_preset_configs(),
     1856                                                'iconVariants'  => self::get_icon_variants(),
    16981857                                        )
    16991858                                );
    … …  
    17201879
    17211880        /**
    1722          * Register settings and fields.
     1881         * Register the settings option.
     1882         *
     1883         * The screen is rendered by render_settings_page() rather than through
     1884         * do_settings_sections(), so only the option registration is needed here.
     1885         * It provides the options.php allow-list entry and the sanitize callback.
    17231886         *
    17241887         * @return void
    … …  
    17331896                                'default'           => self::default_settings(),
    17341897                        )
    1735                 );
    1736 
    1737                 add_settings_section(
    1738                         'oznaczai_image_labeling_labels_section',
    1739                         __( 'Label text', 'oznaczai-image-labeling' ),
    1740                         '__return_false',
    1741                         'oznaczai-image-labeling'
    1742                 );
    1743 
    1744                 $this->register_text_setting_field( 'label_generated', __( 'AI-generated image', 'oznaczai-image-labeling' ) );
    1745                 $this->register_text_setting_field( 'label_modified', __( 'AI-modified image', 'oznaczai-image-labeling' ) );
    1746                 $this->register_text_setting_field( 'label_visualization', __( 'AI visualization', 'oznaczai-image-labeling' ) );
    1747 
    1748                 add_settings_section(
    1749                         'oznaczai_image_labeling_appearance_section',
    1750                         __( 'Label appearance', 'oznaczai-image-labeling' ),
    1751                         '__return_false',
    1752                         'oznaczai-image-labeling'
    1753                 );
    1754 
    1755                 add_settings_field(
    1756                         'position',
    1757                         __( 'Position', 'oznaczai-image-labeling' ),
    1758                         array( $this, 'render_position_field' ),
    1759                         'oznaczai-image-labeling',
    1760                         'oznaczai_image_labeling_appearance_section'
    1761                 );
    1762 
    1763                 add_settings_field(
    1764                         'template',
    1765                         __( 'Template', 'oznaczai-image-labeling' ),
    1766                         array( $this, 'render_template_field' ),
    1767                         'oznaczai-image-labeling',
    1768                         'oznaczai_image_labeling_appearance_section'
    1769                 );
    1770 
    1771                 add_settings_field(
    1772                         'show_icon',
    1773                         __( 'AI icon', 'oznaczai-image-labeling' ),
    1774                         array( $this, 'render_icon_field' ),
    1775                         'oznaczai-image-labeling',
    1776                         'oznaczai_image_labeling_appearance_section'
    1777                 );
    1778 
    1779                 add_settings_field(
    1780                         'responsive_symbol',
    1781                         __( 'Small-image symbol', 'oznaczai-image-labeling' ),
    1782                         array( $this, 'render_responsive_symbol_field' ),
    1783                         'oznaczai-image-labeling',
    1784                         'oznaczai_image_labeling_appearance_section'
    1785                 );
    1786 
    1787                 add_settings_section(
    1788                         'oznaczai_image_labeling_background_section',
    1789                         __( 'Hero and background images', 'oznaczai-image-labeling' ),
    1790                         '__return_false',
    1791                         'oznaczai-image-labeling'
    1792                 );
    1793 
    1794                 add_settings_field(
    1795                         'bg_display',
    1796                         __( 'Hero / background display', 'oznaczai-image-labeling' ),
    1797                         array( $this, 'render_bg_display_field' ),
    1798                         'oznaczai-image-labeling',
    1799                         'oznaczai_image_labeling_background_section'
    1800                 );
    1801 
    1802                 add_settings_field(
    1803                         'auto_backgrounds',
    1804                         __( 'Automatic detection', 'oznaczai-image-labeling' ),
    1805                         array( $this, 'render_auto_backgrounds_field' ),
    1806                         'oznaczai-image-labeling',
    1807                         'oznaczai_image_labeling_background_section'
    1808                 );
    1809 
    1810                 add_settings_section(
    1811                         'oznaczai_image_labeling_metadata_section',
    1812                         __( 'Metadata and automation', 'oznaczai-image-labeling' ),
    1813                         '__return_false',
    1814                         'oznaczai-image-labeling'
    1815                 );
    1816 
    1817                 add_settings_field(
    1818                         'detect_metadata',
    1819                         __( 'Upload metadata hints', 'oznaczai-image-labeling' ),
    1820                         array( $this, 'render_detect_metadata_field' ),
    1821                         'oznaczai-image-labeling',
    1822                         'oznaczai_image_labeling_metadata_section'
    1823                 );
    1824 
    1825                 add_settings_field(
    1826                         'schema_markup',
    1827                         __( 'Schema.org metadata', 'oznaczai-image-labeling' ),
    1828                         array( $this, 'render_schema_markup_field' ),
    1829                         'oznaczai-image-labeling',
    1830                         'oznaczai_image_labeling_metadata_section'
    1831                 );
    1832         }
    1833 
    1834         /**
    1835          * Register one text option field.
    1836          *
    1837          * @param string $key   Setting key.
    1838          * @param string $label Field label.
    1839          * @return void
    1840          */
    1841         private function register_text_setting_field( $key, $label ) {
    1842                 add_settings_field(
    1843                         $key,
    1844                         $label,
    1845                         array( $this, 'render_text_field' ),
    1846                         'oznaczai-image-labeling',
    1847                         'oznaczai_image_labeling_labels_section',
    1848                         array( 'key' => $key )
    18491898                );
    18501899        }
    … …  
    19061955                        'hero_position'       => isset( $input['hero_position'] ) && in_array( $input['hero_position'], $context_positions, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['hero_position'],
    19071956                        'show_icon'           => empty( $input['show_icon'] ) ? 0 : 1,
     1957                        'icon_variant'        => $this->sanitize_icon_variant( isset( $input['icon_variant'] ) ? $input['icon_variant'] : $defaults['icon_variant'] ),
     1958                        'icon_show_text'      => empty( $input['icon_show_text'] ) ? 0 : 1,
    19081959                        'bg_display'          => isset( $input['bg_display'] ) && in_array( $input['bg_display'], $bg_displays, true ) ? $input['bg_display'] : ( isset( $input['hero_position'] ) && in_array( $input['hero_position'], $bg_displays, true ) ? sanitize_key( $input['hero_position'] ) : $defaults['bg_display'] ),
    19091960                        'auto_backgrounds'    => empty( $input['auto_backgrounds'] ) ? 0 : 1,
    … …  
    19692020                $value = is_numeric( $value ) ? (int) $value : (int) $default;
    19702021                return max( $min, min( $max, $value ) );
    1971         }
    1972 
    1973         /**
    1974          * Render settings text field.
    1975          *
    1976          * @param array $args Field arguments.
    1977          * @return void
    1978          */
    1979         public function render_text_field( $args ) {
    1980                 $settings = $this->get_settings();
    1981                 $key      = $args['key'];
    1982                 $status   = str_replace( 'label_', '', $key );
    1983                 $defaults = $this->get_default_label_texts();
    1984                 $placeholder = isset( $defaults[ $status ] ) ? $defaults[ $status ] : '';
    1985                 printf(
    1986                         '<input type="text" class="regular-text" maxlength="160" name="%1$s[%2$s]" value="%3$s" placeholder="%4$s">',
    1987                         esc_attr( self::OPTION_SETTINGS ),
    1988                         esc_attr( $key ),
    1989                         esc_attr( $settings[ $key ] ),
    1990                         esc_attr( $placeholder )
    1991                 );
    1992                 echo '<p class="description">' . esc_html__( 'Leave empty to use the translated default for the active site language.', 'oznaczai-image-labeling' ) . '</p>';
    1993         }
    1994 
    1995         /**
    1996          * Render position field.
    1997          *
    1998          * @return void
    1999          */
    2000         public function render_position_field() {
    2001                 $settings = $this->get_settings();
    2002                 $options  = array(
    2003                         'bottom-left'  => __( 'Bottom left', 'oznaczai-image-labeling' ),
    2004                         'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),
    2005                         'top-left'     => __( 'Top left', 'oznaczai-image-labeling' ),
    2006                         'top-right'    => __( 'Top right', 'oznaczai-image-labeling' ),
    2007                 );
    2008 
    2009                 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[position]">';
    2010                 foreach ( $options as $value => $label ) {
    2011                         printf(
    2012                                 '<option value="%1$s"%2$s>%3$s</option>',
    2013                                 esc_attr( $value ),
    2014                                 selected( $settings['position'], $value, false ),
    2015                                 esc_html( $label )
    2016                         );
    2017                 }
    2018                 echo '</select>';
    2019                 echo '<p class="description">' . esc_html__( 'For the Caption below image template, this setting controls left or right caption alignment.', 'oznaczai-image-labeling' ) . '</p>';
    2020         }
    2021 
    2022         /**
    2023          * Render template field.
    2024          *
    2025          * @return void
    2026          */
    2027         public function render_template_field() {
    2028                 $settings = $this->get_settings();
    2029                 $options  = array(
    2030                         'minimal' => array(
    2031                                 'label'       => __( 'Minimal', 'oznaczai-image-labeling' ),
    2032                                 'description' => __( 'The least prominent option. Very light background, without borders or shadows.', 'oznaczai-image-labeling' ),
    2033                         ),
    2034                         'compact' => array(
    2035                                 'label'       => __( 'Compact', 'oznaczai-image-labeling' ),
    2036                                 'description' => __( 'A small dark label with restrained visual weight.', 'oznaczai-image-labeling' ),
    2037                         ),
    2038                         'subtle'  => array(
    2039                                 'label'       => __( 'Subtle', 'oznaczai-image-labeling' ),
    2040                                 'description' => __( 'A semi-transparent label without shadow or bold text.', 'oznaczai-image-labeling' ),
    2041                         ),
    2042                         'light'    => array(
    2043                                 'label'       => __( 'Light', 'oznaczai-image-labeling' ),
    2044                                 'description' => __( 'Light, delicate text with an almost invisible background.', 'oznaczai-image-labeling' ),
    2045                         ),
    2046                         'caption'  => array(
    2047                                 'label'       => __( 'Caption below image', 'oznaczai-image-labeling' ),
    2048                                 'description' => __( 'A lightweight caption directly below the image. It does not cover the image.', 'oznaczai-image-labeling' ),
    2049                         ),
    2050                 );
    2051 
    2052                 echo '<div class="oznaczai-template-grid">';
    2053                 foreach ( $options as $value => $option ) {
    2054                         printf(
    2055                                 '<label class="oznaczai-template-option"><input type="radio" name="%1$s[template]" value="%2$s"%3$s><span class="oznaczai-template-card"><span class="oznaczai-template-card__title">%4$s</span><span class="oznaczai-template-preview oznaczai-template-preview--%2$s"><span class="oznaczai-template-preview__icon" aria-hidden="true">AI</span><span>%6$s</span></span><span class="oznaczai-template-card__description">%5$s</span></span></label>',
    2056                                 esc_attr( self::OPTION_SETTINGS ),
    2057                                 esc_attr( $value ),
    2058                                 checked( $settings['template'], $value, false ),
    2059                                 esc_html( $option['label'] ),
    2060                                 esc_html( $option['description'] ),
    2061                                 esc_html__( 'AI-generated image', 'oznaczai-image-labeling' )
    2062                         );
    2063                 }
    2064                 echo '</div>';
    2065                 echo '<p class="description">' . esc_html__( 'Minimal is the least prominent template. You can also disable the AI icon for an even lighter result.', 'oznaczai-image-labeling' ) . '</p>';
    2066         }
    2067 
    2068         /**
    2069          * Render icon checkbox.
    2070          *
    2071          * @return void
    2072          */
    2073         public function render_icon_field() {
    2074                 $settings = $this->get_settings();
    2075                 printf(
    2076                         '<label><input type="checkbox" name="%1$s[show_icon]" value="1"%2$s> %3$s</label>',
    2077                         esc_attr( self::OPTION_SETTINGS ),
    2078                         checked( ! empty( $settings['show_icon'] ), true, false ),
    2079                         esc_html__( 'Show a small icon next to the text', 'oznaczai-image-labeling' )
    2080                 );
    2081         }
    2082 
    2083         /**
    2084          * Render small-image symbol setting.
    2085          *
    2086          * @return void
    2087          */
    2088         public function render_responsive_symbol_field() {
    2089                 $settings = $this->get_settings();
    2090                 printf(
    2091                         '<label><input type="checkbox" name="%1$s[responsive_symbol]" value="1"%2$s> %3$s</label>',
    2092                         esc_attr( self::OPTION_SETTINGS ),
    2093                         checked( ! empty( $settings['responsive_symbol'] ), true, false ),
    2094                         esc_html__( 'Use a compact AI symbol on small images instead of the full text label', 'oznaczai-image-labeling' )
    2095                 );
    2096                 echo '<p class="description">' . esc_html__( 'The symbol expands to the full label on hover or keyboard focus. The accessible label remains available to assistive technology. Caption below image is never collapsed.', 'oznaczai-image-labeling' ) . '</p>';
    2097         }
    2098 
    2099 
    2100         /**
    2101          * Render hero/background display field.
    2102          *
    2103          * @return void
    2104          */
    2105         public function render_bg_display_field() {
    2106                 $settings = $this->get_settings();
    2107                 $options  = array(
    2108                         'below'        => __( 'Below image / section', 'oznaczai-image-labeling' ),
    2109                         'bar'          => __( 'Subtle bar at the bottom of the hero', 'oznaczai-image-labeling' ),
    2110                         'bottom-left'  => __( 'Bottom left in hero', 'oznaczai-image-labeling' ),
    2111                         'bottom-right' => __( 'Bottom right in hero', 'oznaczai-image-labeling' ),
    2112                 );
    2113 
    2114                 echo '<select name="' . esc_attr( self::OPTION_SETTINGS ) . '[bg_display]">';
    2115                 foreach ( $options as $value => $label ) {
    2116                         printf(
    2117                                 '<option value="%1$s"%2$s>%3$s</option>',
    2118                                 esc_attr( $value ),
    2119                                 selected( $settings['bg_display'], $value, false ),
    2120                                 esc_html( $label )
    2121                         );
    2122                 }
    2123                 echo '</select>';
    2124                 echo '<p class="description">' . esc_html__( 'For a custom section background, add the CSS class oznaczai-bg-generated, oznaczai-bg-modified, or oznaczai-bg-visualization. The Below image / section option is the safest choice for heroes with their own text.', 'oznaczai-image-labeling' ) . '</p>';
    2125         }
    2126 
    2127 
    2128         /**
    2129          * Render automatic background detection field.
    2130          *
    2131          * @return void
    2132          */
    2133         public function render_auto_backgrounds_field() {
    2134                 $settings = $this->get_settings();
    2135                 printf(
    2136                         '<label><input type="checkbox" name="%1$s[auto_backgrounds]" value="1"%2$s> %3$s</label>',
    2137                         esc_attr( self::OPTION_SETTINGS ),
    2138                         checked( ! empty( $settings['auto_backgrounds'] ), true, false ),
    2139                         esc_html__( 'Automatically detect labeled images in hero sections, section backgrounds, and Elementor sliders', 'oznaczai-image-labeling' )
    2140                 );
    2141                 echo '<p class="description">' . esc_html__( 'Mark the file in the Media Library. No CSS classes or shortcode are required for supported layouts.', 'oznaczai-image-labeling' ) . '</p>';
    2142         }
    2143 
    2144         /**
    2145          * Render upload metadata detection setting.
    2146          *
    2147          * @return void
    2148          */
    2149         public function render_detect_metadata_field() {
    2150                 $settings = $this->get_settings();
    2151                 printf(
    2152                         '<label><input type="checkbox" name="%1$s[detect_metadata]" value="1"%2$s> %3$s</label>',
    2153                         esc_attr( self::OPTION_SETTINGS ),
    2154                         checked( ! empty( $settings['detect_metadata'] ), true, false ),
    2155                         esc_html__( 'Check newly uploaded images for AI-related IPTC/XMP, Content Credentials markers, and common generator signatures', 'oznaczai-image-labeling' )
    2156                 );
    2157                 echo '<p class="description">' . esc_html__( 'Detection only creates a review hint. It never labels an image automatically and does not validate C2PA signatures.', 'oznaczai-image-labeling' ) . '</p>';
    2158         }
    2159 
    2160         /**
    2161          * Render Schema.org setting.
    2162          *
    2163          * @return void
    2164          */
    2165         public function render_schema_markup_field() {
    2166                 $settings = $this->get_settings();
    2167                 printf(
    2168                         '<label><input type="checkbox" name="%1$s[schema_markup]" value="1"%2$s> %3$s</label>',
    2169                         esc_attr( self::OPTION_SETTINGS ),
    2170                         checked( ! empty( $settings['schema_markup'] ), true, false ),
    2171                         esc_html__( 'Add Schema.org digitalSourceType JSON-LD for labeled images found on the page', 'oznaczai-image-labeling' )
    2172                 );
    2173                 echo '<p class="description">' . esc_html__( 'This is machine-readable publisher metadata. It is separate from Content Credentials and does not provide or guarantee legal compliance.', 'oznaczai-image-labeling' ) . '</p>';
    21742022        }
    21752023
    … …  
    24582306                                                                        <?php endforeach; ?>
    24592307                                                                </div>
     2308                                                                <div class="oznaczai-symbol-row">
     2309                                                                        <label class="oznaczai-field">
     2310                                                                                <span><?php esc_html_e( 'Label symbol', 'oznaczai-image-labeling' ); ?></span>
     2311                                                                                <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[icon_variant]" data-oznaczai-icon-variant>
     2312                                                                                        <?php foreach ( self::get_icon_variants() as $variant_key => $variant ) : ?>
     2313                                                                                                <option value="<?php echo esc_attr( $variant_key ); ?>" <?php selected( $this->sanitize_icon_variant( $settings['icon_variant'] ), $variant_key ); ?>><?php echo esc_html( $variant['label'] ); ?></option>
     2314                                                                                        <?php endforeach; ?>
     2315                                                                                </select>
     2316                                                                        </label>
     2317                                                                        <p class="description">
     2318                                                                                <?php esc_html_e( 'The OznaczAI symbol is the default. The EU options use the official European Commission icons for labelling AI-generated content. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning.', 'oznaczai-image-labeling' ); ?>
     2319                                                                        </p>
     2320                                                                        <?php $this->render_toggle_control( 'icon_show_text', __( 'Show text next to the symbol', 'oznaczai-image-labeling' ), __( 'Turn this off to show only the symbol when it already contains its own wording.', 'oznaczai-image-labeling' ), $settings ); ?>
     2321                                                                </div>
    24602322                                                                <div class="oznaczai-advanced-grid">
    24612323                                                                        <?php $this->render_toggle_control( 'show_icon', __( 'Show AI icon', 'oznaczai-image-labeling' ), __( 'Display the small AI mark next to the label text.', 'oznaczai-image-labeling' ), $settings ); ?>
    24622324                                                                        <?php $this->render_toggle_control( 'schema_markup', __( 'Schema.org JSON-LD', 'oznaczai-image-labeling' ), __( 'Add digitalSourceType metadata for labeled images found on the page.', 'oznaczai-image-labeling' ), $settings ); ?>
    24632325                                                                </div>
     2326                                                        </section>
     2327
     2328                                                        <section class="oznaczai-card" id="oznaczai-transparency">
     2329                                                                <div class="oznaczai-card-head">
     2330                                                                        <h2><?php esc_html_e( '7. AI transparency legend', 'oznaczai-image-labeling' ); ?></h2>
     2331                                                                        <span aria-hidden="true">i</span>
     2332                                                                </div>
     2333                                                                <p class="oznaczai-card-intro">
     2334                                                                        <?php esc_html_e( 'Place this shortcode on any page to explain what your AI labels mean. It follows your current label texts and symbol automatically.', 'oznaczai-image-labeling' ); ?>
     2335                                                                </p>
     2336                                                                <p><code class="oznaczai-shortcode">[oznaczai_transparency_legend]</code></p>
     2337                                                                <?php $transparency_page = $this->get_transparency_page(); ?>
     2338                                                                <?php if ( $transparency_page instanceof WP_Post ) : ?>
     2339                                                                        <p class="description">
     2340                                                                                <?php
     2341                                                                                printf(
     2342                                                                                        /* translators: %s: link to the existing page. */
     2343                                                                                        esc_html__( 'A page already exists: %s', 'oznaczai-image-labeling' ),
     2344                                                                                        '<a href="' . esc_url( (string) get_edit_post_link( $transparency_page->ID, 'url' ) ) . '">' . esc_html( get_the_title( $transparency_page ) ) . '</a>'
     2345                                                                                );
     2346                                                                                ?>
     2347                                                                        </p>
     2348                                                                <?php elseif ( current_user_can( 'publish_pages' ) ) : ?>
     2349                                                                        <p class="description"><?php esc_html_e( 'You can also create a draft page containing this legend. It is never published automatically.', 'oznaczai-image-labeling' ); ?></p>
     2350                                                                        <p>
     2351                                                                                <a class="button button-secondary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oznaczai_image_labeling_create_transparency_page' ), 'oznaczai_image_labeling_create_transparency_page' ) ); ?>">
     2352                                                                                        <?php esc_html_e( 'Create AI transparency page', 'oznaczai-image-labeling' ); ?>
     2353                                                                                </a>
     2354                                                                        </p>
     2355                                                                <?php endif; ?>
    24642356                                                        </section>
    24652357
    … …  
    25682460
    25692461        /**
    2570          * Render context appearance controls.
    2571          *
    2572          * @param string $context  Context key.
    2573          * @param string $label    Context label.
    2574          * @param array  $settings Current settings.
    2575          * @return void
    2576          */
    2577         private function render_context_appearance_controls( $context, $label, $settings ) {
    2578                 $prefix = 'context_' . $context;
    2579                 $positions = array(
    2580                         'top-left'     => __( 'Top left', 'oznaczai-image-labeling' ),
    2581                         'top-right'    => __( 'Top right', 'oznaczai-image-labeling' ),
    2582                         'bottom-left'  => __( 'Bottom left', 'oznaczai-image-labeling' ),
    2583                         'bottom-right' => __( 'Bottom right', 'oznaczai-image-labeling' ),
    2584                         'below'        => __( 'Below image / section', 'oznaczai-image-labeling' ),
    2585                         'bar'          => __( 'Subtle bar', 'oznaczai-image-labeling' ),
    2586                 );
    2587                 ?>
    2588                 <div class="oznaczai-context-style-card" data-oznaczai-context-style="<?php echo esc_attr( $context ); ?>">
    2589                         <h3><?php echo esc_html( $label ); ?></h3>
    2590                         <div class="oznaczai-mode-row">
    2591                                 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="global" <?php checked( $settings[ $prefix . '_mode' ], 'global' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use global settings', 'oznaczai-image-labeling' ); ?></label>
    2592                                 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="preset" <?php checked( $settings[ $prefix . '_mode' ], 'preset' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Use separate preset', 'oznaczai-image-labeling' ); ?></label>
    2593                                 <label><input type="radio" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_mode]" value="custom" <?php checked( $settings[ $prefix . '_mode' ], 'custom' ); ?> data-oznaczai-context-mode="<?php echo esc_attr( $context ); ?>"> <?php esc_html_e( 'Customize manually', 'oznaczai-image-labeling' ); ?></label>
    2594                         </div>
    2595                         <div data-oznaczai-context-preset="<?php echo esc_attr( $context ); ?>">
    2596                                 <label class="oznaczai-field">
    2597                                         <span><?php esc_html_e( 'Preset', 'oznaczai-image-labeling' ); ?></span>
    2598                                         <?php $this->render_preset_select( $prefix . '_preset', $settings[ $prefix . '_preset' ] ); ?>
    2599                                 </label>
    2600                         </div>
    2601                         <div class="oznaczai-style-grid" data-oznaczai-context-custom="<?php echo esc_attr( $context ); ?>">
    2602                                 <?php
    2603                                 $this->render_style_input( $prefix . '_text_color', __( 'Text color', 'oznaczai-image-labeling' ), 'color', $settings );
    2604                                 ?>
    2605                                 <div class="oznaczai-background-tools">
    2606                                         <p class="oznaczai-label"><?php esc_html_e( 'Background', 'oznaczai-image-labeling' ); ?></p>
    2607                                         <label class="oznaczai-checkline">
    2608                                                 <input type="checkbox" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_no_background]" value="1" <?php checked( ! empty( $settings[ $prefix . '_no_background' ] ) ); ?> data-oznaczai-context-input>
    2609                                                 <?php esc_html_e( 'No background', 'oznaczai-image-labeling' ); ?>
    2610                                         </label>
    2611                                 </div>
    2612                                 <?php
    2613                                 $this->render_style_input( $prefix . '_bg_color', __( 'Background color', 'oznaczai-image-labeling' ), 'color', $settings );
    2614                                 ?>
    2615                                 <label class="oznaczai-field oznaczai-range-field">
    2616                                         <span><?php esc_html_e( 'Background opacity', 'oznaczai-image-labeling' ); ?></span>
    2617                                         <input type="range" min="0" max="100" name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_bg_opacity]" value="<?php echo esc_attr( $settings[ $prefix . '_bg_opacity' ] ); ?>" data-oznaczai-context-input>
    2618                                         <output><?php echo esc_html( absint( $settings[ $prefix . '_bg_opacity' ] ) ); ?>%</output>
    2619                                 </label>
    2620                                 <?php
    2621                                 $this->render_style_input( $prefix . '_border_color', __( 'Border color', 'oznaczai-image-labeling' ), 'color', $settings );
    2622                                 $this->render_style_input( $prefix . '_border_width', __( 'Border width', 'oznaczai-image-labeling' ), 'number', $settings, 0, 6 );
    2623                                 $this->render_style_input( $prefix . '_radius', __( 'Radius', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );
    2624                                 $this->render_style_input( $prefix . '_font_size', __( 'Font size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 18 );
    2625                                 $this->render_style_input( $prefix . '_font_weight', __( 'Font weight', 'oznaczai-image-labeling' ), 'number', $settings, 300, 800 );
    2626                                 $this->render_style_input( $prefix . '_padding_x', __( 'Horizontal padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 24 );
    2627                                 $this->render_style_input( $prefix . '_padding_y', __( 'Vertical padding', 'oznaczai-image-labeling' ), 'number', $settings, 0, 16 );
    2628                                 $this->render_style_input( $prefix . '_icon_color', __( 'AI icon color', 'oznaczai-image-labeling' ), 'color', $settings );
    2629                                 $this->render_style_input( $prefix . '_icon_size', __( 'AI icon size', 'oznaczai-image-labeling' ), 'number', $settings, 8, 24 );
    2630                                 ?>
    2631                                 <?php $this->render_toggle_control( $prefix . '_shadow', __( 'Subtle shadow', 'oznaczai-image-labeling' ), __( 'Add a restrained shadow to this context label.', 'oznaczai-image-labeling' ), $settings ); ?>
    2632                                 <?php if ( in_array( $context, array( 'background', 'slider' ), true ) ) : ?>
    2633                                         <label class="oznaczai-field">
    2634                                                 <span><?php esc_html_e( 'Label position', 'oznaczai-image-labeling' ); ?></span>
    2635                                                 <select name="<?php echo esc_attr( self::OPTION_SETTINGS ); ?>[<?php echo esc_attr( $prefix ); ?>_position]" data-oznaczai-context-input>
    2636                                                         <?php foreach ( $positions as $value => $position_label ) : ?>
    2637                                                                 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings[ $prefix . '_position' ], $value ); ?>><?php echo esc_html( $position_label ); ?></option>
    2638                                                         <?php endforeach; ?>
    2639                                                 </select>
    2640                                         </label>
    2641                                 <?php endif; ?>
    2642                         </div>
    2643                 </div>
    2644                 <?php
    2645         }
    2646 
    2647         /**
    26482462         * Render a compact style input.
    26492463         *
    … …  
    27642578
    27652579        /**
     2580         * Explanations shown in the transparency legend.
     2581         *
     2582         * These describe what the site owner stated about an image. They are not
     2583         * legal statements and must not be worded as any kind of compliance claim.
     2584         *
     2585         * @return array<string,string>
     2586         */
     2587        private function get_legend_descriptions() {
     2588                return array(
     2589                        'generated'     => __( 'The site owner has marked this image as generated using AI.', 'oznaczai-image-labeling' ),
     2590                        'modified'      => __( 'The site owner has marked this image as modified using AI.', 'oznaczai-image-labeling' ),
     2591                        'visualization' => __( 'The site owner has marked this image as a visualization created using AI.', 'oznaczai-image-labeling' ),
     2592                );
     2593        }
     2594
     2595        /**
     2596         * Render the symbol used for a status inside the legend.
     2597         *
     2598         * @param string $status  Image status.
     2599         * @param string $variant Active symbol variant.
     2600         * @return string
     2601         */
     2602        private function render_legend_symbol( $status, $variant ) {
     2603                $variants = self::get_icon_variants();
     2604                $symbols  = isset( $variants[ $variant ]['symbols'] ) ? $variants[ $variant ]['symbols'] : array();
     2605                $symbol   = isset( $symbols[ $status ] ) ? $symbols[ $status ] : array();
     2606
     2607                if ( ! empty( $symbol['onLight'] ) ) {
     2608                        return sprintf(
     2609                                '<img class="oznaczai-legend__symbol oznaczai-legend__symbol--image" src="%1$s" alt="" width="%2$d" height="20" loading="lazy" decoding="async">',
     2610                                esc_url( $symbol['onLight'] ),
     2611                                absint( round( 20 * ( isset( $symbol['ratio'] ) ? (float) $symbol['ratio'] : 1 ) ) )
     2612                        );
     2613                }
     2614
     2615                return sprintf(
     2616                        '<span class="oznaczai-legend__symbol" aria-hidden="true">%s</span>',
     2617                        esc_html( isset( $symbol['text'] ) ? $symbol['text'] : 'AI' )
     2618                );
     2619        }
     2620
     2621        /**
     2622         * Render the "AI transparency" legend.
     2623         *
     2624         * The legend is built from the statuses, label texts and symbol variant that
     2625         * are actually in use, so it never drifts from what visitors see on images.
     2626         *
     2627         * @param array $atts Shortcode attributes.
     2628         * @return string
     2629         */
     2630        public function render_transparency_legend_shortcode( $atts ) {
     2631                $atts = shortcode_atts(
     2632                        array(
     2633                                'title' => '',
     2634                        ),
     2635                        $atts,
     2636                        'oznaczai_transparency_legend'
     2637                );
     2638
     2639                $settings     = $this->get_settings();
     2640                $variant      = $this->sanitize_icon_variant( $settings['icon_variant'] );
     2641                $descriptions = $this->get_legend_descriptions();
     2642                $title        = $this->sanitize_label( $atts['title'] );
     2643                $output       = '<div class="oznaczai-legend">';
     2644
     2645                if ( '' !== $title ) {
     2646                        $output .= '<h2 class="oznaczai-legend__title">' . esc_html( $title ) . '</h2>';
     2647                }
     2648
     2649                $output .= '<ul class="oznaczai-legend__list">';
     2650
     2651                foreach ( array( 'generated', 'modified', 'visualization' ) as $status ) {
     2652                        $output .= sprintf(
     2653                                '<li class="oznaczai-legend__item oznaczai-legend__item--%1$s">%2$s<span class="oznaczai-legend__body"><span class="oznaczai-legend__name">%3$s</span><span class="oznaczai-legend__description">%4$s</span></span></li>',
     2654                                esc_attr( $status ),
     2655                                $this->render_legend_symbol( $status, $variant ),
     2656                                esc_html( $this->get_configured_label( $status ) ),
     2657                                esc_html( isset( $descriptions[ $status ] ) ? $descriptions[ $status ] : '' )
     2658                        );
     2659                }
     2660
     2661                $output .= '</ul>';
     2662                $output .= '<p class="oznaczai-legend__note">' . esc_html__( 'Labels are applied by the site owner. Not every image created or edited with AI carries a label, and these markings do not constitute a legal assessment.', 'oznaczai-image-labeling' ) . '</p>';
     2663                $output .= '</div>';
     2664
     2665                return $output;
     2666        }
     2667
     2668        /**
     2669         * Return the existing transparency page, if one is still usable.
     2670         *
     2671         * @return WP_Post|null
     2672         */
     2673        private function get_transparency_page() {
     2674                $page_id = absint( get_option( self::OPTION_TRANSPARENCY_PAGE, 0 ) );
     2675                if ( ! $page_id ) {
     2676                        return null;
     2677                }
     2678
     2679                $page = get_post( $page_id );
     2680                if ( ! $page instanceof WP_Post || 'page' !== $page->post_type || 'trash' === $page->post_status ) {
     2681                        return null;
     2682                }
     2683
     2684                return $page;
     2685        }
     2686
     2687        /**
     2688         * Create a draft "AI transparency" page containing the legend shortcode.
     2689         *
     2690         * The page is only ever created on an explicit administrator action and is
     2691         * always left as a draft, so nothing is published without review.
     2692         *
     2693         * @return void
     2694         */
     2695        public function handle_create_transparency_page() {
     2696                if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'publish_pages' ) ) {
     2697                        wp_die(
     2698                                esc_html__( 'You do not have permission to create this page.', 'oznaczai-image-labeling' ),
     2699                                '',
     2700                                array( 'response' => 403 )
     2701                        );
     2702                }
     2703
     2704                check_admin_referer( 'oznaczai_image_labeling_create_transparency_page' );
     2705
     2706                $settings_url = admin_url( 'admin.php?page=oznaczai-image-labeling' );
     2707                $existing     = $this->get_transparency_page();
     2708
     2709                if ( $existing instanceof WP_Post ) {
     2710                        wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'exists', $settings_url ) );
     2711                        exit;
     2712                }
     2713
     2714                $page_id = wp_insert_post(
     2715                        array(
     2716                                'post_title'   => __( 'AI transparency', 'oznaczai-image-labeling' ),
     2717                                'post_content' => '[oznaczai_transparency_legend]',
     2718                                'post_status'  => 'draft',
     2719                                'post_type'    => 'page',
     2720                        ),
     2721                        true
     2722                );
     2723
     2724                if ( is_wp_error( $page_id ) || ! $page_id ) {
     2725                        wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'error', $settings_url ) );
     2726                        exit;
     2727                }
     2728
     2729                update_option( self::OPTION_TRANSPARENCY_PAGE, absint( $page_id ), false );
     2730
     2731                wp_safe_redirect( add_query_arg( 'oznaczai_image_labeling_page', 'created', $settings_url ) );
     2732                exit;
     2733        }
     2734
     2735        /**
     2736         * Show the result of the transparency page action.
     2737         *
     2738         * @return void
     2739         */
     2740        public function show_transparency_page_notice() {
     2741                // The notice belongs to the OznaczAI settings screen, which is where the
     2742                // action redirects back to. Restricting it here stops the query parameter
     2743                // from surfacing the notice on unrelated wp-admin screens.
     2744                $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     2745                if ( ! $screen || 'toplevel_page_oznaczai-image-labeling' !== $screen->id ) {
     2746                        return;
     2747                }
     2748
     2749                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data.
     2750                if ( ! isset( $_GET['oznaczai_image_labeling_page'] ) || ! current_user_can( 'manage_options' ) ) {
     2751                        return;
     2752                }
     2753
     2754                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag; value is sanitized, whitelisted, and does not mutate data.
     2755                $result = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_page'] ) );
     2756                $page   = $this->get_transparency_page();
     2757
     2758                if ( 'error' === $result ) {
     2759                        printf(
     2760                                '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
     2761                                esc_html__( 'The AI transparency page could not be created.', 'oznaczai-image-labeling' )
     2762                        );
     2763                        return;
     2764                }
     2765
     2766                if ( ! in_array( $result, array( 'created', 'exists' ), true ) || ! $page instanceof WP_Post ) {
     2767                        return;
     2768                }
     2769
     2770                $message = 'created' === $result
     2771                        ? __( 'Draft page created. Review the wording and publish it when you are ready.', 'oznaczai-image-labeling' )
     2772                        : __( 'The AI transparency page already exists.', 'oznaczai-image-labeling' );
     2773
     2774                printf(
     2775                        '<div class="notice notice-success is-dismissible"><p>%1$s <a href="%2$s">%3$s</a></p></div>',
     2776                        esc_html( $message ),
     2777                        esc_url( (string) get_edit_post_link( $page->ID, 'url' ) ),
     2778                        esc_html__( 'Edit the page', 'oznaczai-image-labeling' )
     2779                );
     2780        }
     2781
     2782        /**
    27662783         * Add media list column.
    27672784         *
    … …  
    28362853
    28372854        /**
     2855         * Available Media Library filter choices.
     2856         *
     2857         * @return array<string,string>
     2858         */
     2859        private function get_media_filter_choices() {
     2860                return array(
     2861                        'generated'     => __( 'AI label: AI-generated', 'oznaczai-image-labeling' ),
     2862                        'modified'      => __( 'AI label: AI-modified', 'oznaczai-image-labeling' ),
     2863                        'visualization' => __( 'AI label: AI visualization', 'oznaczai-image-labeling' ),
     2864                        'unlabeled'     => __( 'AI label: no label', 'oznaczai-image-labeling' ),
     2865                        'hint'          => __( 'AI label: metadata hint found', 'oznaczai-image-labeling' ),
     2866                );
     2867        }
     2868
     2869        /**
     2870         * Read the requested Media Library filter.
     2871         *
     2872         * @return string Empty string when no supported filter is active.
     2873         */
     2874        private function get_requested_media_filter() {
     2875                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters.
     2876                if ( ! isset( $_GET['oznaczai_image_labeling_filter'] ) ) {
     2877                        return '';
     2878                }
     2879
     2880                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list table filter, mirrors the core media type and date filters.
     2881                $filter = sanitize_key( wp_unslash( $_GET['oznaczai_image_labeling_filter'] ) );
     2882
     2883                return array_key_exists( $filter, $this->get_media_filter_choices() ) ? $filter : '';
     2884        }
     2885
     2886        /**
     2887         * Render the OznaczAI filter next to the native Media Library filters.
     2888         *
     2889         * @param string $post_type Current list table post type.
     2890         * @param string $which     Tablenav position.
     2891         * @return void
     2892         */
     2893        public function render_media_library_filter( $post_type, $which ) {
     2894                global $pagenow;
     2895
     2896                if ( 'attachment' !== $post_type || 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) {
     2897                        return;
     2898                }
     2899
     2900                // The media list table renders this action twice; only the top bar carries
     2901                // the native filter controls and the Filter submit button.
     2902                if ( 'bar' !== $which ) {
     2903                        return;
     2904                }
     2905
     2906                $current = $this->get_requested_media_filter();
     2907
     2908                echo '<label class="screen-reader-text" for="oznaczai-image-labeling-filter">';
     2909                esc_html_e( 'Filter by AI label', 'oznaczai-image-labeling' );
     2910                echo '</label>';
     2911                echo '<select name="oznaczai_image_labeling_filter" id="oznaczai-image-labeling-filter">';
     2912                printf(
     2913                        '<option value=""%1$s>%2$s</option>',
     2914                        selected( $current, '', false ),
     2915                        esc_html__( 'All AI labels', 'oznaczai-image-labeling' )
     2916                );
     2917                foreach ( $this->get_media_filter_choices() as $value => $label ) {
     2918                        printf(
     2919                                '<option value="%1$s"%2$s>%3$s</option>',
     2920                                esc_attr( $value ),
     2921                                selected( $current, $value, false ),
     2922                                esc_html( $label )
     2923                        );
     2924                }
     2925                echo '</select>';
     2926        }
     2927
     2928        /**
     2929         * Apply the OznaczAI filter to the Media Library list query.
     2930         *
     2931         * Filtering happens in the WordPress query so large libraries are never
     2932         * transferred to the browser.
     2933         *
     2934         * @param WP_Query $query Current query.
     2935         * @return void
     2936         */
     2937        public function filter_media_library_query( $query ) {
     2938                global $pagenow;
     2939
     2940                if ( ! is_admin() || ! $query instanceof WP_Query || ! $query->is_main_query() ) {
     2941                        return;
     2942                }
     2943
     2944                if ( 'upload.php' !== $pagenow || ! current_user_can( 'upload_files' ) ) {
     2945                        return;
     2946                }
     2947
     2948                $filter = $this->get_requested_media_filter();
     2949                if ( '' === $filter ) {
     2950                        return;
     2951                }
     2952
     2953                $meta_query = $query->get( 'meta_query' );
     2954                $meta_query = is_array( $meta_query ) ? $meta_query : array();
     2955
     2956                if ( 'hint' === $filter ) {
     2957                        $meta_query[] = array(
     2958                                'key'     => self::META_DETECTION_STATUS,
     2959                                'compare' => 'EXISTS',
     2960                        );
     2961                } elseif ( 'unlabeled' === $filter ) {
     2962                        // Current releases delete the meta row when a label is removed, but an
     2963                        // installation upgraded from an older version can still hold an empty
     2964                        // or explicit 'none' value, so both shapes count as unlabeled.
     2965                        $meta_query[] = array(
     2966                                'relation' => 'OR',
     2967                                array(
     2968                                        'key'     => self::META_STATUS,
     2969                                        'compare' => 'NOT EXISTS',
     2970                                ),
     2971                                array(
     2972                                        'key'     => self::META_STATUS,
     2973                                        'value'   => array( '', 'none' ),
     2974                                        'compare' => 'IN',
     2975                                ),
     2976                        );
     2977                } else {
     2978                        $meta_query[] = array(
     2979                                'key'     => self::META_STATUS,
     2980                                'value'   => $filter,
     2981                                'compare' => '=',
     2982                        );
     2983                }
     2984
     2985                $query->set( 'meta_query', $meta_query );
     2986
     2987                // OznaczAI only labels images. Without this, every PDF and video would
     2988                // appear under "no label". An explicit media type choice still wins.
     2989                if ( in_array( $filter, array( 'unlabeled', 'hint' ), true ) && ! $query->get( 'post_mime_type' ) ) {
     2990                        $query->set( 'post_mime_type', 'image' );
     2991                }
     2992        }
     2993
     2994        /**
    28382995         * Save status changed directly in the Media list.
    28392996         *
    … …  
    29333090
    29343091                        unset( $this->status_cache[ $post_id ], $this->label_cache[ $post_id ] );
    2935                         $this->update_labeled_media_index( $post_id, $status );
     3092                        $this->update_labeled_media_index( $post_id, $status, true );
    29363093                        ++$count;
    29373094                }
    29383095
    29393096                if ( $count > 0 ) {
     3097                        $this->persist_labeled_media_index();
    29403098                        $this->clear_media_map_cache();
    29413099                        $this->purge_page_cache();
  • oznaczai-image-labeling/trunk/oznaczai.php

    r3641997 r3705284  
    33 * Plugin Name: OznaczAI – AI Image Disclosure for EU AI Act
    44 * Description: Adds visible labels to AI-generated and AI-modified images to support transparency disclosures, including EU AI Act use cases.
    5  * Version:     1.6.24
     5 * Version:     1.7.0
    66 * Author:      Golden Pixel
    77 * License:     GPL-2.0-or-later
    … …  
    99 * Text Domain: oznaczai-image-labeling
    1010 * Requires at least: 6.2
    11  * Tested up to: 7.0
     11 * Tested up to: 7.1
    1212 * Requires PHP: 7.4
    1313 */
    … …  
    3232}
    3333
    34 define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1.6.24' );
     34define( 'OZNACZAI_IMAGE_LABELING_VERSION', '1.7.0' );
    3535define( 'OZNACZAI_IMAGE_LABELING_FILE', __FILE__ );
    3636define( 'OZNACZAI_IMAGE_LABELING_DIR', plugin_dir_path( __FILE__ ) );
  • oznaczai-image-labeling/trunk/readme.txt

    r3641997 r3705284  
    33Tags: eu ai act, ai disclosure, ai transparency, deepfake, image labeling
    44Requires at least: 6.2
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.6.24
     7Stable tag: 1.7.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232* No label
    3333
    34 
    35 == Screenshots ==
    36 
    37 1. Label AI-generated and AI-modified images directly from the WordPress Media Library.
    38 2. Configure label presets, custom styling, hero and slider labels, and live preview.
    39 3. Visible AI disclosure labels on a real website, including hero images and images inside page content.
    40 4. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens.
    41 
    42 
    4334= Main features =
    4435
    4536* Quick status selection in the Media Library list view
    4637* Automatic saving without opening each attachment
     38* Filter the Media Library by AI label, including images with no label
    4739* Bulk labeling for multiple images
    4840* Custom label wording
    … …  
    7365The Caption below image preset places the disclosure outside the image so it does not cover visual content.
    7466
     67= Label symbol =
     68
     69The label symbol is separate from the label preset, so any symbol can be combined with any preset.
     70
     71* OznaczAI symbol (default)
     72* EU AI icon
     73* EU AI icons per label type
     74
     75The EU options use the official European Commission icons for labelling AI-generated content. AI-generated uses the Fully AI-Generated icon and AI-modified uses the Partially AI-Modified icon. AI visualization always uses the neutral EU AI icon, because no official variant carries that meaning.
     76
     77Choosing an EU icon does not change what your labels mean and is not a statement about legal compliance. Existing installations keep the OznaczAI symbol until the setting is changed.
     78
     79= AI transparency legend =
     80
     81The shortcode below outputs a short, neutral legend that explains the labels used on your site. It follows your current label texts and symbol automatically.
     82
     83[oznaczai_transparency_legend]
     84
     85You can optionally create a draft page containing this legend from the plugin settings screen. The page is never published automatically.
     86
    7587== Installation ==
    7688
    … …  
    117129No WordPress plugin can provide or guarantee complete legal compliance. Site owners should assess their own content, role, audience, and applicable legal requirements.
    118130
     131== Third-party assets ==
     132
     133The optional EU label symbols are the official icons for labelling AI-generated content published by the European Commission at https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content
     134
     135The published terms state that the icons "are made publicly available for everyone to use freely, without the need for attribution to the Commission or the AI Office". The files bundled with OznaczAI are unmodified copies of the official SVG assets; only their file names were changed. See assets/icons/eu/README.txt in the plugin folder for the recorded source and terms.
     136
     137OznaczAI is not affiliated with, endorsed by, or approved by the European Commission or the AI Office. Displaying an icon does not establish that any legal obligation applies to an image or that it has been met.
     138
    119139== Privacy ==
    120140
    … …  
    143163It automatically detects many common WordPress and Elementor implementations, including Slider Revolution 6/7 image layers and slide backgrounds. Custom themes or other third-party widgets may require one of the fallback CSS classes or the background label shortcode.
    144164
     165== Screenshots ==
     166
     1671. Label AI-generated and AI-modified images directly from the WordPress Media Library.
     1682. Configure label presets, custom styling, hero and slider labels, and live preview.
     1693. Visible AI disclosure labels on a real website, including hero images and images inside page content.
     1704. Responsive AI labels automatically adapt between full text and a compact AI symbol on smaller screens.
     171
    145172== Changelog ==
     173
     174= 1.7.0 =
     175* Fixed a feedback loop where the page observer reacted to the plugin's own DOM updates and kept re-scanning the page after it had settled.
     176* Labeled images are no longer reprocessed on every scan, so contrast sampling now runs once per image instead of repeatedly.
     177* Added a per-slide cache for Slider Revolution detection and stopped re-measuring hero, banner and Elementor elements that were already checked.
     178* Narrowed the page observer and made it scan only the changed part of the page. Dynamically added Elementor content, lazy-loaded backgrounds and Slider Revolution 6/7 are still detected.
     179* Stopped rebuilding the labeled media cache after uploads and thumbnail regeneration of media that carries no label.
     180* Removed a database write that could happen while a public page was being rendered.
     181* Bulk labeling now writes the labeled media index once instead of once per image.
     182* Reduced the size of the labeled media data passed to the browser by removing duplicated entries.
     183* The legacy storage migration now runs only on install and version change instead of on every request.
     184* Fixed a migration from very old versions that could store a label preset that no longer exists.
     185* Added an AI label filter to the Media Library list view, including images with no label and images with a metadata hint.
     186* Added the [oznaczai_transparency_legend] shortcode, and an optional action that creates a draft page containing it.
     187* Added optional official European Commission icons for labelling AI-generated content as an alternative label symbol. The OznaczAI symbol remains the default and existing labels are unchanged.
     188* Removed settings screen code that was no longer used by the current panel.
    146189
    147190= 1.6.24 =
  • oznaczai-image-labeling/trunk/uninstall.php

    r3641997 r3705284  
    1414delete_option( 'oznaczai_image_labeling_labeled_media_ids' );
    1515delete_option( 'oznaczai_image_labeling_version' );
     16
     17/*
     18 * Only the stored reference is removed. Any page created through the
     19 * "AI transparency" action is ordinary site content that the administrator
     20 * owns and may have edited or published, so it is never deleted here.
     21 */
     22delete_option( 'oznaczai_image_labeling_transparency_page_id' );
     23
    1624delete_transient( 'oznaczai_image_labeling_media_map_v1' );
Note: See TracChangeset for help on using the changeset viewer.