中安拓也のブログ

プログラミングについて書くブログ。 Twitterやってます @l08084

【Angular】サロゲートペア(絵文字など)でもカウントできるValidator/Directiveの作成

はじめに

絵文字を使ってもバグらずに動くValidatorとDirectiveを作成します。

サロゲートペアについて

サロゲートペアとは、2つの文字コードを使って表現される文字を指し、通常の方法では正しく文字数をカウントできません。サロゲートペアには絵文字や一部の漢字が含まれます。次の例を見てください。

// 例1. 普通の文字
'A'.length; // 1
// 例2. 絵文字
'😀'.length; // 2
// 例3. 特殊な絵文字
'🏴󠁧󠁢󠁷󠁬󠁳󠁿'.length; // 14

例2の絵文字はサロゲートペアのため、2文字としてカウントされているのが分かります。また、例3のウェールズ国旗のような特殊な絵文字は、複数の絵文字で構成されているため、1文字で14文字としてカウントされています。

環境

TypeScriptベースのフレームワークであるAngularを使用します。

  • Angular: 8.2.14
  • stringz: 2.1.0

$ ng versionの実行結果

Angular CLI: 8.3.29
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.29
@angular-devkit/build-angular     0.803.29
@angular-devkit/build-optimizer   0.803.29
@angular-devkit/build-webpack     0.803.29
@angular-devkit/core              8.3.29
@angular-devkit/schematics        8.3.29
@angular/cli                      8.3.29
@ngtools/webpack                  8.3.29
@schematics/angular               8.3.29
@schematics/update                0.803.29
rxjs                              6.4.0
typescript                        3.5.3
webpack                           4.39.2

絵文字を正確にカウントできるライブラリ

絵文字をカウントする処理をスクラッチで書くと大変なので下記のライブラリを使用します。

github.com

上記のライブラリを使えば、通常の処理では難しい絵文字を含む文字列のカウントや、文字列操作を使用することができます。

  • 本ライブラリの使用例
import { substring, length } from 'stringz';

length('Iñtërnâtiônàlizætiøn☃💩'); // 22
substring('Emojis 👍🏽 are 🍆 poison. 🌮s are bad.', 7, 14); // "👍🏽 are 🍆"

Validatorの作成

上記のライブラリstringzを使用して、サロゲートペア(絵文字など)を含んだ文字列の最小文字数、最大文字数のバリデーターを作成します。 いわゆるAngularの公式バリデーターであるValidators.minLength(), Validators.maxLength()の絵文字に対応したバージョンになります。

surrogate-pair.validator.ts

import { length } from 'stringz';

import { Injectable } from '@angular/core';
import { AbstractControl, ValidatorFn } from '@angular/forms';

@Injectable()
export class SurrogatePairValidator {

  /**
   * サロゲートペア文字(絵文字など)も含む最小文字数
   *
   * @static
   * @param {number} minLength
   * @returns {ValidatorFn}
   * @memberof SurrogatePairValidator
   */
  public static minLength(minLength: number): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } => {
      const value = (control.value || '') + '';
      if (value === '') {
        return null;
      }
      return length(value) >= minLength
        ? null
        : { minlength: { requiredLength: minLength } };
    };
  }

  /**
   * サロゲートペア文字(絵文字など)も含む最大文字数
   *
   * @static
   * @param {number} maxLength
   * @return {*}  {ValidatorFn}
   * @memberof StringValidator
   */
  public static maxLength(maxLength: number): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } => {
      const value = (control.value || '') + '';
      if (value === '') {
        return null;
      }
      return length(value) <= maxLength
        ? null
        : { maxlength: { requiredLength: maxLength } };
    };
  }
}

上記のバリデーターのテストコードをjasmine + Karmaで書きます。

surrogate-pair.validator.spec.ts

import { Component, OnInit } from '@angular/core';
/**
 * This is unit test code for SurrogatePairValidator using karma.
 */
import {
  FormBuilder,
  FormGroup,
  ReactiveFormsModule
} from '@angular/forms';
import { SurrogatePairValidator } from './surrogate-pair.validator';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

/**
 * Component that creates input field for test
 *
 * @export
 * @class RegisterComponent
 */
@Component({
  selector: 'app-register',
  template: `
    <form [formGroup]="form">
      <label>minLength</label>
      <input formControlName="minLength" />
      <label>maxLength</label>
      <input formControlName="maxLength" />
    </form>
  `
})
export class RegisterComponent implements OnInit {
  public form: FormGroup;

  constructor(private fb: FormBuilder) {}
  /**
   * initialize form
   *
   * @memberof RegisterComponent
   */
  public ngOnInit(): void {
    this.form = this.fb.group({
      minLength: [null, SurrogatePairValidator.minLength(5)],
      maxLength: [null, SurrogatePairValidator.maxLength(5)]
    });
  }
}

/**
 * Check if the method checks invalid_inputs and valid_inputs correctly
 *
 * @param {string} field_name
 * @param {RegisterComponent} component
 * @param {string[]} valid_inputs
 * @param {string[]} invalid_inputs
 */
function assertValues(
  field_name: string,
  component: RegisterComponent,
  valid_inputs: string[],
  invalid_inputs: string[]
): void {
  const control = component.form.controls[field_name];
  for (const invalid of invalid_inputs) {
    control.setValue(invalid);
    expect(control.valid).toBeFalsy();
  }
  for (const valid of valid_inputs) {
    control.setValue(valid);
    expect(control.valid).toBeTruthy();
  }
}

describe('SurrogatePairValidator', () => {
  let component: RegisterComponent;
  let fixture: ComponentFixture<RegisterComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ReactiveFormsModule, FormsModule],
      declarations: [RegisterComponent]
    });

    fixture = TestBed.createComponent(RegisterComponent);

    component = fixture.componentInstance;
    component.ngOnInit();
  });


  it('minLength', () => {
    const valid_inputs = ['12🐍4🏴󠁧󠁢󠁷󠁬󠁳󠁿', '1吉😀家😀🏴󠁧󠁢󠁷󠁬󠁳󠁿😱', '', null];
    const invalid_inputs = ['😀😀😀😀', '😀😀 😀', 'abce', '1234'];
    assertValues(
      'minLength',
      component,
      valid_inputs,
      invalid_inputs
    );
  });

  it('maxLength', () => {
    const valid_inputs = ['12🐍45', '1吉🏴󠁧󠁢󠁷󠁬󠁳󠁿😀', '', null];
    const invalid_inputs = ['😀😀😀😀😀😀', '😀😀 😀😀😀', 'abcedf', '123456'];
    assertValues(
      'maxLength',
      component,
      valid_inputs,
      invalid_inputs
    );
  });
});

上記のテストコードをng testコマンドで実行すると作成したバリデーターが正しく動くことがわかります。

f:id:l08084:20201206192241p:plain
絵文字の最小/最大文字数のバリデーターが正しく動いていることがわかる

Directiveの作成

続いて、input要素のmaxlength属性の絵文字対応バージョンを作成するために、指定文字数以上を入力したら、削除するようなDirectiveを作成します。

surrogate-pair-max-length.directive.ts

import { length, substring } from 'stringz';

import {
  Directive,
  ElementRef,
  HostListener,
  Input,
  Renderer2
} from '@angular/core';

/**
 * Surrogate pair max length directive.
 *
 * @export
 * @class SurrogatePairMaxLengthDirective
 */
@Directive({
  selector: '[appSurrogatePairMaxLength]'
})
export class SurrogatePairMaxLengthDirective {
  @Input() public appSurrogatePairMaxLength: number;

  constructor(private el: ElementRef, private renderer: Renderer2) {}

  /**
   * Handles input event.
   *
   * @param {*} event
   * @memberof SurrogatePairMaxLengthDirective
   */
  @HostListener('input', ['$event'])
  public onChange(event: any): void {
    if (length(event.target.value) > this.appSurrogatePairMaxLength) {
      this.renderer.setProperty(
        this.el.nativeElement,
        'value',
        substring(event.target.value, 0, this.appSurrogatePairMaxLength)
      );
    }
  }
}

下記のコードでinput要素に文字を入力するたびに、指定文字数よりも長い文字列が入力された場合は、Renderer2で余分な文字を削除した状態の文字列で置き換える、といった処理をしています。

  @HostListener('input', ['$event'])
  public onChange(event: any): void {
    if (length(event.target.value) > this.appSurrogatePairMaxLength) {
      this.renderer.setProperty(
        this.el.nativeElement,
        'value',
        substring(event.target.value, 0, this.appSurrogatePairMaxLength)
      );
    }
  }

上記のDirectiveを作成した後、下記のようなコードを書けば、3文字以上の文字を入力できないinput要素が完成します。

app.component.html

<input type="text" appSurrogatePairMaxLength="3">

f:id:l08084:20201206200119p:plain
3文字以上入力できない

参考サイト

GitHub - sallar/stringz: :100: Super fast unicode-aware string manipulation Javascript library

僕は、なぜ絵文字の長さが、直感に反するのか理解したい...!! - Qiita

A deep dive into Angular’s Renderer2.setValue method | by Konda Reddy Yaramala | JavaScript In Plain English | Medium

ionic2 - Ionic 3/Angular 2 - Renderer2.setValue() doesn't update the value of my input field - Stack Overflow