Pourquoi les caractères emoji comme 👩‍👩‍👧‍👦 sont-ils traités si étrangement dans les chaînes Swift?


540

Le caractère 👩‍👩‍👧‍👦 (famille avec deux femmes, une fille et un garçon) est codé comme tel:

U+1F469 WOMAN,
‍U+200D ZWJ,
U+1F469 WOMAN,
U+200D ZWJ,
U+1F467 GIRL,
U+200D ZWJ,
U+1F466 BOY

Il est donc codé de façon très intéressante; la cible parfaite pour un test unitaire. Cependant, Swift ne semble pas savoir comment le traiter. Voici ce que je veux dire:

"👩‍👩‍👧‍👦".contains("👩‍👩‍👧‍👦") // true
"👩‍👩‍👧‍👦".contains("👩") // false
"👩‍👩‍👧‍👦".contains("\u{200D}") // false
"👩‍👩‍👧‍👦".contains("👧") // false
"👩‍👩‍👧‍👦".contains("👦") // true

Donc, Swift dit qu'il se contient (bon) et un garçon (bon!). Mais il dit ensuite qu'il ne contient ni femme, ni fille, ni menuisier de largeur nulle. Qu'est-ce qu'il se passe ici? Pourquoi Swift sait-il qu'il contient un garçon mais pas une femme ou une fille? Je pouvais comprendre s'il le traitait comme un seul personnage et ne reconnaissait qu'il le contenant lui-même, mais le fait qu'il ait un sous-composant et aucun autre me déroute.

Cela ne change pas si j'utilise quelque chose comme "👩".characters.first!.


Encore plus déroutant:

let manual = "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
Array(manual.characters) // ["👩‍", "👩‍", "👧‍", "👦"]

Même si j'y ai placé les ZWJ, ils ne sont pas reflétés dans le tableau de caractères. Ce qui a suivi était un peu révélateur:

manual.contains("👩") // false
manual.contains("👧") // false
manual.contains("👦") // true

J'ai donc le même comportement avec le tableau de caractères ... ce qui est extrêmement ennuyeux, car je sais à quoi ressemble le tableau.

Cela ne change pas non plus si j'utilise quelque chose comme "👩".characters.first!.



1
Les commentaires ne sont pas pour une discussion approfondie; cette conversation a été déplacée vers le chat .
Martijn Pieters

1
Corrigé dans Swift 4. "👩‍👩‍👧‍👦".contains("\u{200D}")renvoie toujours false, je ne sais pas si c'est un bug ou une fonctionnalité.
Kevin

4
Oui. Unicode a ruiné le texte. Il a transformé le texte brut en un langage de balisage.
Boann

6
@Boann oui et non ... beaucoup de ces changements ont été apportés pour faire en / décodage des choses comme Hangul Jamo (255 codepoints) pas un cauchemar absolu comme c'était le cas pour Kanji (13,108 codepoints) et les idéogrammes chinois (199,528 codepoints). Bien sûr, c'est plus compliqué et intéressant que la longueur d'un commentaire SO pourrait le permettre, alors je vous encourage à le vérifier vous-même: D
Ben Leggiero

Réponses:


402

Cela a à voir avec le fonctionnement du Stringtype dans Swift et le fonctionnement de la contains(_:)méthode.

Le «👩‍👩‍👧‍👦» est ce que l'on appelle une séquence d'emoji, qui est rendue sous la forme d'un caractère visible dans une chaîne. La séquence est composée d' Characterobjets, et en même temps elle est constituée d' UnicodeScalarobjets.

Si vous vérifiez le nombre de caractères de la chaîne, vous verrez qu'elle est composée de quatre caractères, tandis que si vous vérifiez le nombre scalaire unicode, cela vous montrera un résultat différent:

print("👩‍👩‍👧‍👦".characters.count)     // 4
print("👩‍👩‍👧‍👦".unicodeScalars.count) // 7

Maintenant, si vous analysez les caractères et les imprimez, vous verrez ce qui semble être des caractères normaux, mais en fait, les trois premiers caractères contiennent à la fois un emoji et un jointeur de largeur nulle dans leur UnicodeScalarView:

for char in "👩‍👩‍👧‍👦".characters {
    print(char)

    let scalars = String(char).unicodeScalars.map({ String($0.value, radix: 16) })
    print(scalars)
}

// 👩‍
// ["1f469", "200d"]
// 👩‍
// ["1f469", "200d"]
// 👧‍
// ["1f467", "200d"]
// 👦
// ["1f466"]

Comme vous pouvez le voir, seul le dernier caractère ne contient pas de jointeur de largeur nulle, donc lorsque vous utilisez la contains(_:)méthode, cela fonctionne comme vous vous en doutez. Étant donné que vous ne comparez pas avec des emoji contenant des menuisiers de largeur nulle, la méthode ne trouvera pas de correspondance pour tout sauf le dernier caractère.

Pour développer cela, si vous créez un Stringqui est composé d'un caractère emoji se terminant par un jointeur de largeur nulle et que vous le passez à la contains(_:)méthode, il sera également évalué false. Cela a à voir avec le fait d' contains(_:)être exactement le même que range(of:) != nil, qui essaie de trouver une correspondance exacte avec l'argument donné. Étant donné que les caractères se terminant par un jointeur de largeur nulle forment une séquence incomplète, la méthode essaie de trouver une correspondance pour l'argument tout en combinant les caractères se terminant par un jointeur de largeur nulle en une séquence complète. Cela signifie que la méthode ne trouvera jamais de correspondance si:

  1. l'argument se termine par un jointeur de largeur nulle, et
  2. la chaîne à analyser ne contient pas de séquence incomplète (c'est-à-dire se terminant par un jointeur de largeur nulle et non suivi d'un caractère compatible).

Démontrer:

let s = "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}" // 👩‍👩‍👧‍👦

s.range(of: "\u{1f469}\u{200d}") != nil                            // false
s.range(of: "\u{1f469}\u{200d}\u{1f469}") != nil                   // false

Cependant, puisque la comparaison ne regarde que vers l'avenir, vous pouvez trouver plusieurs autres séquences complètes dans la chaîne en travaillant à l'envers:

s.range(of: "\u{1f466}") != nil                                    // true
s.range(of: "\u{1f467}\u{200d}\u{1f466}") != nil                   // true
s.range(of: "\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}") != nil  // true

// Same as the above:
s.contains("\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}")          // true

La solution la plus simple serait de fournir une option de comparaison spécifique à la range(of:options:range:locale:)méthode. L'option String.CompareOptions.literaleffectue la comparaison sur une équivalence exacte caractère par caractère . En guise de remarque, ce que l'on entend par caractère ici n'est pas le Swift Character, mais la représentation UTF-16 de l'instance et de la chaîne de comparaison.Cependant, comme Stringcela ne permet pas un UTF-16 malformé, cela revient essentiellement à comparer le scalaire Unicode représentation.

Ici, j'ai surchargé la Foundationméthode, donc si vous avez besoin de l'original, renommez celui-ci ou quelque chose:

extension String {
    func contains(_ string: String) -> Bool {
        return self.range(of: string, options: String.CompareOptions.literal) != nil
    }
}

Maintenant, la méthode fonctionne comme elle le devrait avec chaque caractère, même avec des séquences incomplètes:

s.contains("👩")          // true
s.contains("👩\u{200d}")  // true
s.contains("\u{200d}")    // true

47
@MartinR Selon l'UTR29 actuel (Unicode 9.0), il s'agit d' un cluster de graphèmes étendu ( règles GB10 et GB11 ), mais Swift utilise clairement une version plus ancienne. Apparemment, cela est un objectif de la version 4 du langage , donc ce comportement changera à l'avenir.
Michael Homer

9
@MichaelHomer: Apparemment, cela a été corrigé, est "👩‍👩‍👧‍👦".countévalué 1avec la version bêta actuelle de Xcode 9 et Swift 4.
Martin R

5
Sensationnel. C'est excellent. Mais maintenant, je deviens nostalgique de l'ancien temps où le pire problème que j'ai eu avec les chaînes est de savoir si elles utilisent des encodages de style C ou Pascal.
Owen Godfrey

2
Je comprends pourquoi la norme Unicode peut avoir besoin de prendre en charge cela, mais l'homme, c'est un gâchis trop ingénieux, si quelque chose: /
Réinstallez Monica

110

Le premier problème est que vous vous connectez à Foundation avec contains(Swift Stringn'est pas un Collection), c'est donc un NSStringcomportement qui, je ne crois pas, gère les Emoji composés aussi puissamment que Swift. Cela dit, Swift est en train d'implémenter Unicode 8 en ce moment, qui nécessitait également une révision autour de cette situation dans Unicode 10 (donc cela peut tout changer quand ils implémentent Unicode 10; je n'ai pas cherché à savoir si cela le fera ou non).

Pour simplifier les choses, débarrassons-nous de Foundation et utilisons Swift, qui fournit des vues plus explicites. Commençons par les personnages:

"👩‍👩‍👧‍👦".characters.forEach { print($0) }
👩‍
👩‍
👧‍
👦

D'ACCORD. Voilà ce que nous attendions. Mais c'est un mensonge. Voyons ce que sont vraiment ces personnages.

"👩‍👩‍👧‍👦".characters.forEach { print(String($0).unicodeScalars.map{$0}) }
["\u{0001F469}", "\u{200D}"]
["\u{0001F469}", "\u{200D}"]
["\u{0001F467}", "\u{200D}"]
["\u{0001F466}"]

Ah… Alors c'est ça ["👩ZWJ", "👩ZWJ", "👧ZWJ", "👦"]. Cela rend tout un peu plus clair. 👩 n'est pas membre de cette liste (c'est "👩ZWJ"), mais 👦 est membre.

Le problème est qu'il Characters'agit d'un "grappe de graphèmes", qui compose les choses ensemble (comme attacher le ZWJ). Ce que vous cherchez vraiment, c'est un scalaire unicode. Et cela fonctionne exactement comme vous vous y attendez:

"👩‍👩‍👧‍👦".unicodeScalars.contains("👩") // true
"👩‍👩‍👧‍👦".unicodeScalars.contains("\u{200D}") // true
"👩‍👩‍👧‍👦".unicodeScalars.contains("👧") // true
"👩‍👩‍👧‍👦".unicodeScalars.contains("👦") // true

Et bien sûr, nous pouvons également rechercher le personnage réel qui s'y trouve:

"👩‍👩‍👧‍👦".characters.contains("👩\u{200D}") // true

(Cela reproduit fortement les points de Ben Leggiero. J'ai posté cela avant de remarquer qu'il avait répondu. Partir au cas où cela serait plus clair pour quiconque.)


Que signifie- ZWJt-il?
LinusGeffarth

2
Menuisier Zero Width
Rob Napier

@RobNapier dans Swift 4 Stringaurait été reconverti en type de collection. Cela affecte-t-il votre réponse?
Ben Leggiero

Non. Cela a juste changé des choses comme la souscription. Cela n'a pas changé le fonctionnement des personnages.
Rob Napier

75

Il semble que Swift considère a ZWJcomme un cluster de graphèmes étendu avec le caractère qui le précède immédiatement. Nous pouvons le voir lors du mappage du tableau de caractères avec leur unicodeScalars:

Array(manual.characters).map { $0.description.unicodeScalars }

Cela imprime les éléments suivants de LLDB:

4 elements
  ▿ 0 : StringUnicodeScalarView("👩‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"1 : StringUnicodeScalarView("👩‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"2 : StringUnicodeScalarView("👧‍")
    - 0 : "\u{0001F467}"
    - 1 : "\u{200D}"3 : StringUnicodeScalarView("👦")
    - 0 : "\u{0001F466}"

De plus, .containsregroupe les grappes de graphèmes étendues en un seul caractère. Par exemple, en prenant les caractères Hangul , , et (qui se combinent pour rendre le mot coréen pour « un »: 한):

"\u{1112}\u{1161}\u{11AB}".contains("\u{1112}") // false

Cela n'a pas pu être trouvé car les trois points de code sont regroupés en un seul cluster qui agit comme un seul caractère. De même, \u{1F469}\u{200D}( WOMAN ZWJ) est un cluster, qui agit comme un seul caractère.


19

Les autres réponses discutent de ce que fait Swift, mais n'abordent pas en détail pourquoi.

Vous attendez-vous à ce que «Å» soit égal à «Å»? Je suppose que vous le feriez.

L'un d'eux est une lettre avec un combinateur, l'autre est un seul caractère composé. Vous pouvez ajouter de nombreux combineurs différents à un personnage de base, et un humain le considérerait toujours comme un seul personnage. Pour faire face à ce type de divergence, le concept de graphème a été créé pour représenter ce qu'un humain considérerait comme un personnage, quels que soient les points de code utilisés.

Depuis des années, les services de messagerie texte combinent des caractères en emoji graphiques :) →  🙂. Ainsi, divers emoji ont été ajoutés à Unicode.
Ces services ont également commencé à combiner des emoji ensemble en emoji composite.
Il n'y a bien sûr aucun moyen raisonnable de coder toutes les combinaisons possibles en points de code individuels, donc Le Consortium Unicode a décidé d'étendre le concept de graphèmes pour englober ces caractères composites.

Ce que cela se résume à "👩‍👩‍👧‍👦"doit être considéré comme un "cluster de graphe" si vous essayez de travailler avec lui au niveau du graphe, comme Swift le fait par défaut.

Si vous souhaitez vérifier si elle "👦"en fait partie, vous devez descendre à un niveau inférieur.


Je ne connais pas la syntaxe Swift alors voici quelques Perl 6 qui ont un niveau de support similaire pour Unicode.
(Perl 6 prend en charge Unicode version 9, il peut donc y avoir des écarts)

say "\c[family: woman woman girl boy]" eq "👩‍👩‍👧‍👦"; # True

# .contains is a Str method only, in Perl 6
say "👩‍👩‍👧‍👦".contains("👩‍👩‍👧‍👦")    # True
say "👩‍👩‍👧‍👦".contains("👦");        # False
say "👩‍👩‍👧‍👦".contains("\x[200D]");  # False

# comb with no arguments splits a Str into graphemes
my @graphemes = "👩‍👩‍👧‍👦".comb;
say @graphemes.elems;                # 1

Descendons d'un niveau

# look at it as a list of NFC codepoints
my @components := "👩‍👩‍👧‍👦".NFC;
say @components.elems;                     # 7

say @components.grep("👦".ord).Bool;       # True
say @components.grep("\x[200D]".ord).Bool; # True
say @components.grep(0x200D).Bool;         # True

Descendre à ce niveau peut cependant rendre certaines choses plus difficiles.

my @match = "👩‍👩‍👧‍👦".ords;
my $l = @match.elems;
say @components.rotor( $l => 1-$l ).grep(@match).Bool; # True

Je suppose que .containsdans Swift, c'est plus facile, mais cela ne signifie pas qu'il n'y a pas d'autres choses qui deviennent plus difficiles.

Travailler à ce niveau facilite par exemple le fractionnement accidentel d'une chaîne au milieu d'un caractère composite.


Ce que vous demandez par inadvertance, c'est pourquoi cette représentation de niveau supérieur ne fonctionne pas comme une représentation de niveau inférieur. La réponse est bien sûr, ce n'est pas censé le faire.

Si vous vous demandez « pourquoi cela doit-il être si compliqué », la réponse est bien sûr «les humains ».


4
Vous m'avez perdu sur votre dernière ligne d'exemple; que faire rotoret grepfaire ici? Et c'est quoi 1-$l?
Ben Leggiero

4
Le terme "graphème" a au moins 50 ans. Unicode l'a introduit dans la norme car ils avaient déjà utilisé le terme "caractère" pour signifier quelque chose de tout à fait différent de ce que l'on considère habituellement comme un caractère. Je peux lire ce que vous avez écrit comme étant cohérent avec cela, mais soupçonner que d'autres pourraient avoir une mauvaise impression, d'où ce commentaire (j'espère clarifier).
raiph

2
@BenLeggiero En premier lieu , rotor. Le code say (1,2,3,4,5,6).rotor(3)cède ((1 2 3) (4 5 6)). C'est une liste de listes, chaque longueur 3. say (1,2,3,4,5,6).rotor(3=>-2)donne le même sauf que la deuxième sous-liste commence par 2plutôt que 4, la troisième avec 3, et ainsi de suite, cédant ((1 2 3) (2 3 4) (3 4 5) (4 5 6)). Si @matchcontient "👩‍👩‍👧‍👦".ordsalors le code de @ Brad crée une seule sous-liste, donc le =>1-$lbit n'est pas pertinent (inutilisé). Elle n'est pertinente que si elle @matchest plus courte que @components.
raiph

1
grepessaie de faire correspondre chaque élément dans son invocant (dans ce cas, une liste de sous-listes de @components). Il essaie de faire correspondre chaque élément à son argument de correspondance (dans ce cas, @match). Le .Boolretourne ensuite Truessi le grepproduit au moins une correspondance.
raiph

18

Mise à jour Swift 4.0

La chaîne a reçu de nombreuses révisions dans la mise à jour de Swift 4, comme indiqué dans SE-0163 . Deux emoji sont utilisés pour cette démo représentant deux structures différentes. Les deux sont combinés avec une séquence d'emoji.

👍🏽est la combinaison de deux emoji, 👍et🏽

👩‍👩‍👧‍👦est la combinaison de quatre emoji, avec un menuisier de largeur nulle connecté. Le format est👩‍joiner👩‍joiner👧‍joiner👦

1. Nombre

Dans Swift 4.0, les emoji sont comptés comme grappe de graphèmes. Chaque emoji est compté comme 1. La countpropriété est également directement disponible pour la chaîne. Vous pouvez donc l'appeler directement comme ça.

"👍🏽".count  // 1. Not available on swift 3
"👩‍👩‍👧‍👦".count  // 1. Not available on swift 3

Le tableau de caractères d'une chaîne est également compté comme des grappes de graphèmes dans Swift 4.0, donc les deux codes suivants s'impriment 1. Ces deux emoji sont des exemples de séquences d'emoji, où plusieurs emoji sont combinés avec ou sans jointure de largeur nulle \u{200d}entre eux. Dans swift 3.0, le tableau de caractères d'une telle chaîne sépare chaque emoji et donne un tableau avec plusieurs éléments (emoji). Le menuisier est ignoré dans ce processus. Cependant, dans Swift 4.0, le tableau de caractères voit tous les emoji comme une seule pièce. Ainsi, tout emoji sera toujours égal à 1.

"👍🏽".characters.count  // 1. In swift 3, this prints 2
"👩‍👩‍👧‍👦".characters.count  // 1. In swift 3, this prints 4

unicodeScalars reste inchangé dans Swift 4. Il fournit les caractères Unicode uniques dans la chaîne donnée.

"👍🏽".unicodeScalars.count  // 2. Combination of two emoji
"👩‍👩‍👧‍👦".unicodeScalars.count  // 7. Combination of four emoji with joiner between them

2. Contient

Dans Swift 4.0, la containsméthode ignore le menuisier de largeur nulle dans les emoji. Ainsi, il renvoie vrai pour l'un des quatre composants emoji de "👩‍👩‍👧‍👦", et renvoie faux si vous recherchez le menuisier. Cependant, dans Swift 3.0, le menuisier n'est pas ignoré et est combiné avec les emoji en face de lui. Donc, lorsque vous vérifiez si "👩‍👩‍👧‍👦"contient les trois premiers emoji composants, le résultat sera faux

"👍🏽".contains("👍")       // true
"👍🏽".contains("🏽")        // true
"👩‍👩‍👧‍👦".contains("👩‍👩‍👧‍👦")       // true
"👩‍👩‍👧‍👦".contains("👩")       // true. In swift 3, this prints false
"👩‍👩‍👧‍👦".contains("\u{200D}") // false
"👩‍👩‍👧‍👦".contains("👧")       // true. In swift 3, this prints false
"👩‍👩‍👧‍👦".contains("👦")       // true

0

Les émojis, tout comme la norme unicode, sont trompeusement compliqués. Les tons de peau, les sexes, les emplois, les groupes de personnes, les séquences de jointure de largeur nulle, les drapeaux (unicode à 2 caractères) et d'autres complications peuvent rendre l'analyse des emoji désordonnée. Un arbre de Noël, une tranche de pizza ou un tas de merde peuvent tous être représentés avec un seul point de code Unicode. Sans oublier que lorsque de nouveaux emojis sont introduits, il y a un délai entre le support iOS et la sortie des emoji. Cela et le fait que différentes versions d'iOS prennent en charge différentes versions de la norme unicode.

TL; DR. J'ai travaillé sur ces fonctionnalités et ouvert une bibliothèque dont je suis l'auteur pour JKEmoji pour aider à analyser les chaînes avec des emojis. Cela rend l'analyse aussi simple que:

print("I love these emojis 👩‍👩‍👧‍👦💪🏾🧥👧🏿🌈".emojiCount)

5

Il le fait en actualisant régulièrement une base de données locale de tous les emojis reconnus à partir de la dernière version unicode ( 12.0 récemment) et en les croisant avec ce qui est reconnu comme un emoji valide dans la version du système d'exploitation en cours en regardant la représentation bitmap de un caractère emoji non reconnu.

REMARQUE

Une réponse précédente a été supprimée pour la publicité de ma bibliothèque sans indiquer clairement que je suis l'auteur. Je le reconnais encore.


2
Bien que je sois impressionné par votre bibliothèque et que je vois comment elle est généralement liée au sujet traité, je ne vois pas en quoi cela est directement lié à la question
Ben Leggiero
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.