Nitrooos

Myśli programisty

Hyphenation - automatyczne dzielenie wyrazów na sylaby

nitrooos

Wykonując projekty dla klienta niemieckiego spotkałem się z problemem zgodnego z gramatyką dzielenia długich wyrazów na sylaby. Ma to znaczenie, ponieważ słowa takie jak “Datenschutzerklärung”, “Zusammenfassung” czy “Änderungswunsch” nie mieszczą się w jednej linii na urządzeniach mobilnych. Strona, która nie potrafi poprawnie wyświetlać takiego tekstu jest, w odczuciu użytkowników, przygotowana nieprofesjonalne, a rozwiązaniem tego problemu jest implementacja zgodnego z gramatyką dzielenia długich wyrazów na sylaby (ang. hyphenation). Zaczynajmy!

Zarys rozwiązania

Chcemy, aby rozwiązanie pozwalało włączyć mechanizm dla wybranych słów bądź zdań bezpośrednio w szablonie HTML strony, najlepiej z możliwością wskazania dodatkowych opcji, np. dzielenie na sylaby tylko N najdłuższych słów w zdaniu bądź dzielenie tylko wyrazów dłuższych niż M znaków. Mając na względzie te założenia oraz wybraną technologię (Angular), logicznie decydujemy się na użycie narzędzia pipe. Dzięki temu użycie tego mechanizmu będzie wyglądać np. tak:

    {{ 'Datenschutzerklärung' | hyphenate }}
    <!-- wyświetla Da•ten•schutz•er•klä•rung -->

gdzie • oznacza specjalną encję &shy­­ normalnie nie będącą wyświetlaną przez przeglądarki, ale definiującą punkty, w których może ona dokonać podziału na sylaby w razie potrzeby.

Aby to uzyskać skorzystamy z biblioteki hypher (https://github.com/bramstein/hypher), służącej właśnie do dzielenia słów zgodnie z gramatyką wybranego języka (ang. hyphenation).

Czym jest Pipe w “nowym” Angularze?

Zanim omówię implementację, krótka informacja nt. narzędzia pipe. Umożliwia ono transformację wartości wejściowej w zdefiniowany przez nas sposób, otrzymując nową wartość na wyjściu, gotową do wyświetlenia w szablonie. Aby stworzyć własny pipe wystarczy zadeklarować nową klasę implementującą interfejs PipeTransform z funkcją transform. W samym frameworku zdefiniowane są pewne standardowe transformacje, np. CurrencyPipe czy DatePipe, które można wykorzystywać w następujący sposób:

    {{ 1.23 | currency }}
    <!-- wyświetla '$1.23' -->

    {{ dateObj | date:'medium' }}
    <!-- wyświetla 'Jan 27, 2019, 10:40:42 PM' -->

Nasza implementacja również będzie wykorzystywała narzędzie pipe.

Programistyczne mięcho!

Instalacja niezbędnych paczek

Zaczynamy od instalacji biblioteki hypher wraz z potrzebnymi wzorcami językowymi (niemiecki w tym przykładzie):

    npm install hypher hyphenation.de

Szkielet klasy HyphenatePipe

Następnie tworzymy plik zawierający definicję naszego pipe’a, np. hyphenate.pipe.ts, w którym na początku importujemy bilbiotekę i wzorzec językowy:

import * as Hypher from 'hypher';
import * as german from 'hyphenation.de';

Zaraz za tym możemy rozpocząć definiowanie klasy:

/**
 * Hyphenates given text, based on Hypher library
 * @example
 *  'Finanzierungsanfrage' | hyphenate
 *  formats to: 'Fi-nan-zie-rungs-an-fra-ge'
 *  (with ­ entities in place of hyphens)
 */
@Pipe({name: 'hyphenate'})
export class HyphenatePipe implements PipeTransform {

Dekorator @Pipe mówi kompilatorowi jaki typ klasy chcemy zaimplementować. W nawiasie podajemy obiekt konfiguracyjny z kluczem name, oznaczającym nazwę, po której będziemy odwoływać się w szablonach do pipe’a. Ważne jest też zaznaczenie implementacji interfejsu PipeTransform, zawierającego wspomnianą metodę transform. Na samej górze dodałem przydatne komentarze w stylu jsdoc, o których napisałem osobny wpis na blogu :)

Następnie mamy sekcję inicjalizacyjną:

private hyphenator: Hypher = null;
private hyphenChar = '\u00AD';

constructor() {
  this.hyphenator = new Hypher(german);
}

Tu słowo wyjaśnienia: aby móc korzystać z biblioteki hypher, musimy stworzyć obiekt klasy Hypher z parametrem wzorca językowego. Stąd pole obiektu o nazwie hyphenator. Natomiast ‘\u00AD’ jest znakiem “miękkiego” łącznika (encja &shy­). Jest to znak niewyświetlany normalnie przez przeglądarkę, ale zaznaczający miejsce, w którym może ona dokonać podziału na sylaby w razie potrzeby.

/**
 * Hyphenates given text
 * @param {string} text Text to hyphenate
 * @param {HyphenateOptions} options
 *  Optional. Additional options can be specified here.
 */
transform(text: string, options: HyphenateOptions = {}): string {

Najważniejszą metodą klasy jest transform, która to posiada 2 parametry: value typu string, czyli wartość wejściową dla pipe’a, która będzie przekształcana oraz options typu HyphenateOptions, czyli opcje definiujące dodatkowe zachowanie.

Dodatkowe opcje pipe'a - interfejs HyphenateOptions

/**
 * @desc Options which can be given into hyphenate pipe
 * @prop {number} onlyNLongest Hyphenate only N longest words from given text
 * @prop {number} longerThan Hyphenate only words longer than N characters
 */
interface HyphenateOptions {
  onlyNLongest?: number;
  longerThan?: number;
}

Oznacza to, że nasz pipe będzie miał możliwość ograniczenia mechanizmu dzielenia na sylaby tylko do wyrazów dłuższych niż M znaków oraz dla tylko N najdłuższych wyrazów w zdaniu. Opcje te będzie można przekazywać do pipe’a za pomocą konstrukcji value | hyphenate:{ longerThan: M, onlyNLongest: N }.

Punkt kulminacyjny, czyli implementacja metody transform

transform(text: string, options: HyphenateOptions = {}): string {
  const words = text.split(/\s+/);
  const hyphenateNLongest = Math.min(
    words.length, options.onlyNLongest || words.length
  );
  const hyphenateLongerThan = options.longerThan || 0;
  const wordsToHyphenate = words
   .concat()
   .sort((word1, word2) => word2.length - word1.length)
   .slice(0, hyphenateNLongest)
   .filter(word => word.length > hyphenateLongerThan);
  return words
    .map(word => {
      if (wordsToHyphenate.indexOf(word) !== -1) {
        return this.hyphenator.hyphenate(word).join(this.hyphenChar);
      }
      return word;
    })
    .join(' ');
}

Ponieważ przekazanie dodatkowych opcji nie jest obowiązkowe, domyślnie dzielone na sylaby są wszystkie wyrazy. Osiągane jest to poprzez ustawienie opcja onlyNLongest na równą liczbie słów a longerThan na 0.

Poprzez użycie metody concat na tablicy words tworzymy jej kopię, którą następnie sortujemy. Jest to potrzebne, ponieważ operacja sortowania zmienia kolejność elementów oryginalnej tablicy. Następnie wybieramy z niej tylko N najdłuższych wyrazów a ostatecznie filtrujemy tak, aby na końcu znalazły się tylko wyrazy dłuższe niż M znaków. Sam proces dzielenia na sylaby (główna instrukcja return) polega na utworzeniu nowej tablicy na podstawie tablicy słów z wejścia (words). W kroku tym (operacja .map(word => …) sprawdzamy czy powinniśmy podzielić dane słowo (czy znajduje się w tablicy wordsToHyphenate). Jeśli tak to wywołujemy metodę hyphenate na obiekcie this.hyphenator (czyli obiekcie bibliotecznym), a otrzymaną tablicę sylab łączymy znakiem “miękkiego” łącznika (ang. soft hyphen, encja &shy­). Jeśli słowo nie powinno zostać podzielone to zwracamy je bez transformacji (return word). Ostatnim krokiem jest połączenie tablicy słów na pomocą spacji (.join(‘ ‘)).

Podsumowanie

Myślę, że przedstawione przeze mnie rozwiązanie jest całkiem eleganckie i również Wam przypadnie do gustu. Tym bardziej, że sytuacje, w których może się przydać, z mojego doświadczenia, nie są wcale takie rzadkie. Wbudowana w Angular możliwość definiowania własnych pipe’ów sprawdza się tutaj idealnie. Dodatkowe, opcjonalne parametry, które możemy przekazać pozwalają na stworzenie rozwiązania elastycznego i rozszerzalnego. Dzięki, że dobrnęliście aż do końca, miłego dnia wszystkim!