~/home of geeks

Tausend und eine Richtung

· 1396 Wörter · 7 Minute(n) Lesedauer

Neulich musste ich Texte mit verschiedenen Laufrichtungen kombinieren. Im konkreten Fall waren dies Beschreibungen, die aus arabischem (Laufrichtung rechts nach links) und deutschem (Laufrichtung links nach rechts) Text bestanden.

Ist ein Text einheitlich in einer Laufrichtung geschrieben, so kann man diesen einfach fix als Variable oder Text verwenden:

final String arabic = "عندما يخدع لاجئ لاجئين آخرين";

Der obere String ist arabisch, wo die Laufrichtung (=Leserichtung) von rechts nach links ist. arabic.charAt(0) würde übrigens den “c”-ähnlichen Buchstaben (ع) ganz rechts liefern. Es ist auch ganz witzig, wie der Cursor sich in Eclipse innerhalb des Strings nicht nach rechts, sondern nach links bewegt, wenn man die Cursor nach Rechts Taste benutzt.

Problematisch wird es, wenn man die verschiedenen Laufrichtungen kombinieren möchte.

Das Zusammenfügen des oberen arabischen Strings mit " | 01 Hallo Welt!" führt zu einer ganz komischen Ausgabe:

public class Mix {

    public static void main(final String[] args) {
        final String arabic = "عندما يخدع لاجئ لاجئين آخرين";
        final String latin = " | 01 Hallo Welt!";
        final String mix = arabic + latin;

        System.out.println(arabic);
        printHex(arabic);
        System.out.println(latin);
        printHex(latin);
        System.out.println(mix);
        printHex(mix);
    }
}
عندما يخدع لاجئ لاجئين آخرين
D8 B9 D9 86 D8 AF D9 85 D8 A7 20 D9 8A D8 AE D8 AF D8 B9 20 D9 84 D8 A7 D8 AC D8 A6 20 D9 84 D8 A7 D8 AC D8 A6 D9 8A D9 86 20 D8 A2 D8 AE D8 B1 D9 8A D9 86 
 | 01 Hallo Welt!
20 7C 20 30 31 20 48 61 6C 6C 6F 20 57 65 6C 74 21 
عندما يخدع لاجئ لاجئين آخرين | 01 Hallo Welt!
D8 B9 D9 86 D8 AF D9 85 D8 A7 20 D9 8A D8 AE D8 AF D8 B9 20 D9 84 D8 A7 D8 AC D8 A6 20 D9 84 D8 A7 D8 AC D8 A6 D9 8A D9 86 20 D8 A2 D8 AE D8 B1 D9 8A D9 86 20 7C 20 30 31 20 48 61 6C 6C 6F 20 57 65 6C 74 21

In der letzten Zeile sind Zahl und Pipe-Symbol nach links gewandert, der restliche Text aber rechts. Schaut man sich den String im Binärkode an, so erkennt man, dass die Texte richtig zusammengefügt wurden. Das D8 B9 ist der “c” ähnliche Buchstabe im arabischen Text ganz rechts.

Bei der Ausgabe wird nun interpretiert, dass die Zahl und das Pipe-Symbol noch zur Laufrichtung des arabischen Textes gehören, während die lateinischen Buchstaben danach nicht mehr dazu gehören.

Um den Text so anzuzeigen, wie wir ihn auch erwarten, muss man die Laufrichtung explizit mit entsprechenden Steuersymbolen steuern. Das Ganze ist nicht ganz trivial. Randall Munroe (aka XKCD) widmete dem Thema sogar ein Comic, und diverse Autoren schrieben hierzu.

rtl.png by xkcd
rtl.png by xkcd

Mithilfe der Steuerzeichen kann man nun sogar lateinische Buchstaben von rechts nach links laufen lassen. Für meine Zwecke konnte ich damit festlegen, dass bis auf den arabischen Text, alles andere nach links laufend sein soll. Es wird also sichergestellt, dass beim Hinzufügen von Text mit Laufrichtung rechts nach links die Laufrichtung für den Gesamtstring bei links nach rechts bleibt.

Guckt man sich die Ausgabe für den MixedDirectionStringBuilder genauer an, so sieht man, dass die Steuerzeichen an der richtigen Stelle (vor den Zahlen aus dem lateinischen Block) das fließverhalten korrigieren.

‭‫عندما يخدع لاجئ لاجئين آخرين‬ | 01 Hallo Welt!
E2 80 AD E2 80 AB D8 B9 D9 86 D8 AF D9 85 D8 A7 20 D9 8A D8 AE D8 AF D8 B9 20 D9 84 D8 A7 D8 AC D8 A6 20 D9 84 D8 A7 D8 AC D8 A6 D9 8A D9 86 20 D8 A2 D8 AE D8 B1 D9 8A D9 86 E2 80 AC 20 7C 20 30 31 20 48 61 6C 6C 6F 20 57 65 6C 74 21
import java.text.Bidi;

/**
 * Ein Stringbuilder, der jeden hinzugefügten Textblock auf die Textrichtung prüft und anschließend die Richtung "links nach rechts"
 * festlegt.<br/>
 * Wichtig bei gemischten arabisch-lateinischen Texten.<br/>
 * Die Laufrichtung wird per Unicode-Steuerzeichen gesteuert. Ist der Text ohne Laufrichtungsänderung (also nur links nach rechts),
 * so wird auch kein Steuerzeichen benutzt. Sonst wird als erstes Zeichen global die Laufrichtung links nach rechts gesetzt. 
 * Jegliche Teiltexte mit Laufrichtung rechts nach links werden mit zwei Steuerzeichen umschlossen, welche sicherstellen, dass die 
 * folgenden Teiltexte mit normaler (links nach rechts) Laufrichtung auch so behandelt werden.
 * <p>
 * Siehe auch {@link https://stackoverflow.com/questions/6177294/string-concatenation-containing-arabic-and-western-characters#_=_} 
 * und {@link https://en.wikipedia.org/wiki/Universal_Character_Set_characters#Bidirectional_General_Formatting}
 * </p>
 * <p>
 * Diese Klasse ist NICHT Threadsafe.
 * </p>
 */
public class MixedDirectionStringBuilder {

    /**
     * Steuerzeichen global Laufrichtung links nach rechts.
     */
    public static final char LEFT_TO_RIGHT_OVERRIDE = '\\u202D';

    /**
     * Steuerzeichen Einbettung eines Teiltextes mit Laufrichtung rechts nach links in einem Text,
     * der global die Laufrichtung links nach rechts hat.
     */
    public static final char RIGHT_TO_LEFT_EMBEDDING = '\\u202B';

    /**
     * Zurückwechsel zur globalen Laufrichtung, nachdem ein Teiltext mit umgekehrter Laufrichtung eingefügt wurde.
     */
    public static final char POP_DIRECTIONAL_FORMATTING = '\\u202C';

    /**
     * Unsere Texte sind meist etwas länger.
     */
    public static final int DEFAULT_INITIAL_CAPACITY = 64;

    /**
     * Der Zielbuffer (delegate)
     */
    private final StringBuilder buffer;

    /**
     * Erzeugt einen StringBuilder mit 64 Zeichen initial Kapazität.<br/>
     * Als Laufrichtung ist links nach rechts gewählt.
     */
    public MixedDirectionStringBuilder() {
        this(DEFAULT_INITIAL_CAPACITY);
    }

    /**
     * Erzeugt einen StringBuilder mit dem angegebenen String.<br/>
     * Als Laufrichtung ist links nach rechts gewählt.
     * 
     * @param string
     */
    public MixedDirectionStringBuilder(final String string) {
        this(string.length() < DEFAULT_INITIAL_CAPACITY ? DEFAULT_INITIAL_CAPACITY : string.length());
        append(string);
    }

    /**
     * Erzeugt einen StringBuilder mit der angegebenen initial Kapazität.<br/>
     * Als Laufrichtung ist links nach rechts gewählt.
     * 
     * @param string
     */
    public MixedDirectionStringBuilder(final int initialCapacity) {
        buffer = new StringBuilder(initialCapacity);
    }

    /**
     * Fügt den angegebenen Text hinzu.<br/>
     * Es wird sichergestellt, dass beim Hinzufügen von Text mit Laufrichtung rechts nach links
     * die Laufrichtung für den Gesamtstring bei links nach rechts bleibt.
     * 
     * @param s
     * @return
     */
    public MixedDirectionStringBuilder append(final String s) {
        final boolean hasRightToLeft = containsRightToLeft(s);
        if (hasRightToLeft) {
            // Da nun ein rechts nach links Text kommt, sicherstellen, dass
            // der Gesamtstring auch global links nach rechts eingestellt ist.
            checkAndSetGlobalLeftToRight();

            // Es folgt ein Einschub mit rechts nach links
            buffer.append(RIGHT_TO_LEFT_EMBEDDING);
        }
        buffer.append(s);
        if (hasRightToLeft) {
            // Ende Einschub
            buffer.append(POP_DIRECTIONAL_FORMATTING);
        }
        return this;
    }

    /**
     * Prüft, ob das erste Zeichen des Strings ein Steuerzeichen zum globalen Setzen von Laufrichtung
     * links nach rechts ist.
     * Falls nicht, muss es gesetz werden.
     * Diese Methode sollte aufgerufen werden, wenn ein String mit Laufrichtung rechts nach links eingefügt wird. Sonst benötigt der String
     * kein globales Laufrichtungssteuerzeichen.
     */
    private void checkAndSetGlobalLeftToRight() {
        if (buffer.length() > 0) {
            // Erstes Zeichen prüfen, falls kein Steuerzeichen, dann setzen.
            if (buffer.charAt(0) != LEFT_TO_RIGHT_OVERRIDE) {
                buffer.insert(0, LEFT_TO_RIGHT_OVERRIDE);
            }
        } else {
            // Erstes Zeichen gibt es noch nicht, also setzen
            buffer.append(LEFT_TO_RIGHT_OVERRIDE);
        }
    }

    /**
     * Länge des Strings.
     * 
     * @return
     */
    public int length() {
        return buffer.length();
    }

    @Override
    public String toString() {
        return buffer.toString();
    }

    public char[] toCharArray(){
        return toString().toCharArray();
    }
    
    /**
     * Prüft, ob der String eine Laufrichtung rechts anch links enthält.
     * 
     * @param s
     * @return
     */
    public static final boolean containsRightToLeft(final String s) {
        return Bidi.requiresBidi(s.toString().toCharArray(), 0, s.length());
    }
}
import org.fest.assertions.Assertions;
import org.junit.Test;

/**
 * Prüft die Laufrichtungssteuerzeichen für die eigene StringBuffer Klasse. <br>
 * 
 * @author Serhat Cinar
 */
public class TestMixedDirectionStringBuilder {

    /**
     * Gemischte Laufrichtung, Ergebnis sollte entsprechende Laufrichtungssteuerzeichen enthalten.
     */
    @Test
    public void testMixedDirectionString() {
        final String arabic = "عندما يخدع لاجئ لاجئين آخرين";
        final int arabicLength = arabic.length();
        final MixedDirectionStringBuilder builder = new MixedDirectionStringBuilder();
        builder.append(arabic);
        builder.append(" | ");
        builder.append("12345");
        builder.append(" dieser Teil hat eine andere Laufrichtung");
        final char[] c = builder.toCharArray();
        int i = 0;
        // String ist global links nach rechts (wird beim Erzeugen des Buffers festgelegt)
        Assertions.assertThat(c[i++]).isEqualTo(MixedDirectionStringBuilder.LEFT_TO_RIGHT_OVERRIDE);
        // Es folgt ein Einschub mit rechts nach links
        Assertions.assertThat(c[i++]).isEqualTo(MixedDirectionStringBuilder.RIGHT_TO_LEFT_EMBEDDING);
        Assertions.assertThat(c[i++]).isEqualTo('ع');
        i = arabicLength + 1;
        Assertions.assertThat(c[i++]).isEqualTo('ن');
        // Ende Einschub
        Assertions.assertThat(c[i++]).isEqualTo(MixedDirectionStringBuilder.POP_DIRECTIONAL_FORMATTING);
        Assertions.assertThat(c[i++]).isEqualTo(' ');
        Assertions.assertThat(c[i++]).isEqualTo('|');
        Assertions.assertThat(c[i++]).isEqualTo(' ');
        Assertions.assertThat(c[i++]).isEqualTo('1');
        // Keine weiteren Steuerzeichen mehr
        for (int x = i; x < c.length; x++) {
            Assertions.assertThat(c[x]).isNotEqualTo(MixedDirectionStringBuilder.LEFT_TO_RIGHT_OVERRIDE);
            Assertions.assertThat(c[x]).isNotEqualTo(MixedDirectionStringBuilder.RIGHT_TO_LEFT_EMBEDDING);
            Assertions.assertThat(c[x]).isNotEqualTo(MixedDirectionStringBuilder.POP_DIRECTIONAL_FORMATTING);
        }
    }

    /**
     * Ein Text ohne Laufrichtungsänderung und nur von links nach rechts: Sollte keine Steuerzeichen enthalten.
     */
    @Test
    public void testUnidirectionDefault() {
        final MixedDirectionStringBuilder builder = new MixedDirectionStringBuilder();
        builder.append("Ganz normaler Text ohne Laufrichtungsänderungen ");
        builder.append("und so");
        final char[] cs = builder.toCharArray();
        // Keine Steuerzeichen
        for (final char c : cs) {
            Assertions.assertThat(c).isNotEqualTo(MixedDirectionStringBuilder.LEFT_TO_RIGHT_OVERRIDE);
            Assertions.assertThat(c).isNotEqualTo(MixedDirectionStringBuilder.RIGHT_TO_LEFT_EMBEDDING);
            Assertions.assertThat(c).isNotEqualTo(MixedDirectionStringBuilder.POP_DIRECTIONAL_FORMATTING);
        }
    }
}