{ label, risk, source }. * Tenu court et sourcé (ne rien inventer) ; extensible depuis docs/regulatory/. * * @return array */ public static function lexicon(): array { $forbidden = self::RISK_FORBIDDEN; $generic = self::RISK_GENERIC; return array( // R.541-230 — interdits sans seuil. 'biodegradable' => array( 'label' => 'biodégradable', 'risk' => $forbidden, 'source' => 'R.541-230', ), 'respectueux de l environnement' => array( 'label' => 'respectueux de l’environnement', 'risk' => $forbidden, 'source' => 'R.541-230', ), 'respectueux de la nature' => array( 'label' => 'respectueux de la nature', 'risk' => $forbidden, 'source' => 'R.541-230', ), // EmpCo / DGCCRF — allégations génériques à étayer. 'ecologique' => array( 'label' => 'écologique', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'eco responsable' => array( 'label' => 'éco-responsable', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'ecoresponsable' => array( 'label' => 'écoresponsable', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'neutre en carbone' => array( 'label' => 'neutre en carbone', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'climatiquement neutre' => array( 'label' => 'climatiquement neutre', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'zero carbone' => array( 'label' => 'zéro carbone', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'bon pour la planete' => array( 'label' => 'bon pour la planète', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), 'produit vert' => array( 'label' => 'produit vert', 'risk' => $generic, 'source' => 'EmpCo 2024/825', ), ); } /** * Normalise un texte pour la recherche : minuscule + accents retirés + toute ponctuation * (et tout caractère hors [a-z0-9]) réduite à un espace + espaces compactés. La ponctuation * devient un séparateur de mot : « écologique. » se détecte, sans retomber sur un matching * substring (QR-02). * * @param string $text Texte brut. */ public static function normalize( string $text ): string { $text = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); $from = array( 'à', 'â', 'ä', 'á', 'é', 'è', 'ê', 'ë', 'î', 'ï', 'í', 'ô', 'ö', 'ó', 'ù', 'û', 'ü', 'ú', 'ç' ); $to = array( 'a', 'a', 'a', 'a', 'e', 'e', 'e', 'e', 'i', 'i', 'i', 'o', 'o', 'o', 'u', 'u', 'u', 'u', 'c' ); $text = str_replace( $from, $to, $text ); // Mode octets (pas de flag /u) : robuste aux encodages invalides ; tout octet hors [a-z0-9] // (ponctuation, apostrophes, tirets, multioctets restants) devient un séparateur. $text = (string) preg_replace( '/[^a-z0-9]+/', ' ', $text ); return trim( $text ); } /** * Repère les allégations à risque d'un texte (titre + description d'un produit). * * @param string $text Texte à analyser. * @return array Allégations repérées (dédupliquées). */ public static function scan_text( string $text ): array { if ( '' === trim( $text ) ) { return array(); } $haystack = ' ' . self::normalize( $text ) . ' '; $hits = array(); foreach ( self::lexicon() as $needle => $entry ) { // Frontière de mot STRICTE (haystack normalisé + bordé d'espaces) : « gynécologique » // ne matche pas « écologique » (QR-02) ; la ponctuation est déjà réduite en séparateurs. if ( false !== strpos( $haystack, ' ' . $needle . ' ' ) ) { $hits[ $entry['label'] ] = $entry; } } return array_values( $hits ); } /** * Vrai si le texte contient au moins une allégation à risque. * * @param string $text Texte à analyser. */ public static function has_risk( string $text ): bool { return array() !== self::scan_text( $text ); } }