Repository: yamatoiizuka/palt-typesetting (ts)

Issue:
WrapChars で、char の配列も受け取れるようにする
複数のグリフをひとまとめに設定できるよう、char で配列を受け取れるようにする。

```javascript
wrapChars: [
  {
    char: ['(', ')'],
    label: 'blacket'
  }
]
```

※ length 2 以上の配列では、label がないとエラーとする。

Pull request description:
WrapChars で、char の配列も受け取れるようにする
fix #338 

Patch:
diff --git a/README.md b/README.md
index e3790c7..99425e1 100644
--- a/README.md
+++ b/README.md
@@ -145,6 +145,13 @@ const options = {
       // output: <span class="typesetting-char-」">」</span>
       // label が未指定の場合は、char の値がそのまま label として使用されます。
     },
+    {
+      char: ['(', ')'],
+      label: 'parenthesis',
+      // output: <span class="typesetting-char-parenthesis">（）</span>
+      // 配列で複数の文字をまとめて指定できます。
+      // 配列の長さが2以上の場合は label の指定が必須です。
+    },
   ],
 
   // 罫線などの分離禁則文字を `.typesetting-no-breaks` でラップし、文字間を 0 に設定します。
@@ -215,27 +222,27 @@ console.log('output: ' + typesetter.render(srcHtml))
 
 ### オプション
 
-| オプション名             | 説明                                                                                                                                                                                                                                                     | オプションの型                                           | デフォルト値 |
-| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
-| `useWordBreak`           | 単語や助詞など、単語区切りでの改行を行います。                                                                                                                                                                                                           | `boolean`                                                | `true`       |
-| `wrapLatin`              | 英数を `span.typesetting-latin` でラップします。                                                                                                                                                                                                         | `boolean`                                                | `true`       |
-| `wrapChars`              | 特例文字の設定です。指定された文字 `char` をラッピングし、`<span>` タグで囲みます。ラッピングされた文字には、`typesetting-char-[label]` の形式で CSS クラスが付与されます。<br>※ `label` が未指定の場合は、`char` の値がそのままクラス名に使用されます。 | `{char: string, label?: string}[]`                       | `[]`         |
-| `noSpaceBetweenNoBreaks` | 罫線などの分離禁則文字を `span.typesetting-no-breaks` でラップし、文字間を 0 に設定します。                                                                                                                                                              | `boolean`                                                | `true`       |
-| `insertThinSpaces`       | 四分アキスペースを自動で挿入します。                                                                                                                                                                                                                     | `boolean`                                                | `true`       |
-| `thinSpaceWidth`         | 四分アキスペースの幅を設定します。<br>※四分アキは本来 0.25em ですが、視覚上の補正としてデフォルト値を 0.2em に設定しています。                                                                                                                           | `string`                                                 | `'0.2em'`    |
-| `kerningRules`           | 特定の文字間のカーニングルールを設定します。                                                                                                                                                                                                             | `{between: [string, string], value: string \| number}[]` | `[]`         |
+| オプション名             | 説明                                                                                                                                                                                                                                                                                             | オプションの型                                           | デフォルト値 |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | ------------ |
+| `useWordBreak`           | 単語や助詞など、単語区切りでの改行を行います。                                                                                                                                                                                                                                                   | `boolean`                                                | `true`       |
+| `wrapLatin`              | 英数を `span.typesetting-latin` でラップします。                                                                                                                                                                                                                                                 | `boolean`                                                | `true`       |
+| `wrapChars`              | 特例文字の設定です。指定された文字 `char` をラッピングし、`<span>` タグで囲みます。ラッピングされた文字には、`span.typesetting-char-[label]` の形式で CSS クラスが付与されます。<br>※ 指定が 1 文字の場合は、`label` を省略できます。その際、`char` の値がそのままクラス名の一部に使用されます。 | `{char: string \| string[], label?: string}[]`           | `[]`         |
+| `noSpaceBetweenNoBreaks` | 罫線などの分離禁則文字を `span.typesetting-no-breaks` でラップし、文字間を 0 に設定します。                                                                                                                                                                                                      | `boolean`                                                | `true`       |
+| `insertThinSpaces`       | 四分アキスペースを自動で挿入します。                                                                                                                                                                                                                                                             | `boolean`                                                | `true`       |
+| `thinSpaceWidth`         | 四分アキスペースの幅を設定します。<br>※四分アキは本来 0.25em ですが、視覚上の補正としてデフォルト値を 0.2em に設定しています。                                                                                                                                                                   | `string`                                                 | `'0.2em'`    |
+| `kerningRules`           | 特定の文字間のカーニングルールを設定します。                                                                                                                                                                                                                                                     | `{between: [string, string], value: string \| number}[]` | `[]`         |
 
 ### 生成される CSS クラス
 
-| CSS クラス名                | 説明                                                                                                                           |
-| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
-| `.typesetting-wrapper`      | HTML 文字列中のテキストコンテンツをラップします。<br>オプションの指定に関わらず、常に生成されます。                            |
-| `.typesetting-word-break`   | HTML 文字列中のテキストコンテンツをラップします。<br>`useWordBreak: true` のときに生成されます。                               |
-| `.typesetting-latin`        | テキストコンテンツ中の英数をラップします。<br>`wrapLatin: true` のときに生成されます。                                         |
-| `.typesetting-char-[label]` | `wrapChars` で指定された特例文字に付与されるクラスです。<br>※ `label` が未指定の場合、`char` の値を `label` として使用します。 |
-| `.typesetting-no-breaks`    | テキストコンテンツ中の分離禁則文字をラップします。<br>`noSpaceBetweenNoBreaks: true` のときに生成されます。                    |
-| `.typesetting-thin-space`   | 挿入される四分アキスペースエレメントの CSS クラスです。<br>`insertThinSpaces: true` のときに生成されます。                     |
-| `.typesetting-kerning`      | 挿入されるカーニングエレメントの CSS クラスです。<br>有効な `kerningRules` が存在するときに生成されます。                      |
+| CSS クラス名                | 説明                                                                                                               |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `.typesetting-wrapper`      | HTML 文字列中のテキストコンテンツをラップします。<br>オプションの指定に関わらず、常に生成されます。                |
+| `.typesetting-word-break`   | HTML 文字列中のテキストコンテンツをラップします。<br>`useWordBreak: true` のときに生成されます。                   |
+| `.typesetting-latin`        | テキストコンテンツ中の英数をラップします。<br>`wrapLatin: true` のときに生成されます。                             |
+| `.typesetting-char-[label]` | `wrapChars` で指定された特例文字に付与される CSS クラスです。<br>指定した `label` がクラス名の一部に使用されます。 |
+| `.typesetting-no-breaks`    | テキストコンテンツ中の分離禁則文字をラップします。<br>`noSpaceBetweenNoBreaks: true` のときに生成されます。        |
+| `.typesetting-thin-space`   | 挿入される四分アキスペースエレメントの CSS クラスです。<br>`insertThinSpaces: true` のときに生成されます。         |
+| `.typesetting-kerning`      | 挿入されるカーニングエレメントの CSS クラスです。<br>有効な `kerningRules` が存在するときに生成されます。          |
 
 ---
 
@@ -255,6 +262,10 @@ const chars: WrapChar[] = [
     char: 'あ',
     label: 'hira-a',
   },
+  {
+    char: ['(', ')'],
+    label: 'parenthesis',
+  },
 ]
 
 const kerning: KerningRule[] = [
diff --git a/demo/src/index.ts b/demo/src/index.ts
index 72cf0b4..5c9a741 100644
--- a/demo/src/index.ts
+++ b/demo/src/index.ts
@@ -196,8 +196,7 @@ function getKerningRules(): KerningRule[] {
 function getWrapChars(): WrapChar[] {
   return [
     { char: '↗', label: 'arrow' },
-    { char: '(', label: 'parenthesis' },
-    { char: ')', label: 'parenthesis' },
+    { char: ['(', ')'], label: 'parenthesis' },
   ]
 }
 
diff --git a/src/apply-style.ts b/src/apply-style.ts
index 6d3c3fd..ff04999 100644
--- a/src/apply-style.ts
+++ b/src/apply-style.ts
@@ -99,35 +99,47 @@ const applyStyleToChar = (currentSegment: string, nextSegment: string, options:
 
   let updatedSegment = currentSegment
   options.wrapChars.forEach(rule => {
-    const rawChar = rule.char
-    // char が1文字であることを保証する（サロゲートペアも考慮）
-    if (Array.from(rawChar).length !== 1) {
-      console.error(`Invalid configuration in wrapChars: '${rawChar}' is not valid. 'char' must be a single character.`)
-      // 該当ルールはスキップする
+    // rule.char を配列に正規化
+    const chars: string[] = typeof rule.char === 'string' ? [rule.char] : rule.char
+    // 各文字が1文字であるかを確認し、無効な文字は除外する（サロゲートペアも考慮）
+    const validChars = chars.filter(ch => Array.from(ch).length === 1)
+    if (validChars.length === 0) {
+      console.error(`Invalid configuration in wrapChars: rule.char does not contain any valid single character.`)
       return
     }
 
-    // label が指定されていなければ、デフォルトは rawChar を採用する
-    const label = rule.label ? rule.label : rawChar
+    // 配列の長さが1のときのみ、label の省略が可能（省略時はその文字を使用）
+    // 配列の長さが複数の場合は label の指定が必須
+    let label: string
+    if (validChars.length === 1) {
+      label = rule.label ? rule.label : validChars[0]
+    } else {
+      if (!rule.label) {
+        console.error(
+          `Invalid configuration in wrapChars: when grouping multiple characters, a label must be provided.`
+        )
+        return
+      }
+      label = rule.label
+    }
 
-    // クラス名として使用する際に安全な文字列か判定
-    // クラス名に使用できるのは、空白や " ' < > などの特殊文字を含まない文字列
+    // クラス名として使用する際に、安全な文字列か（空白や "、'、<、> を含まないか）判定
     const validClassRegex = /^[^"'<>\s]+$/
     if (!validClassRegex.test(label)) {
       console.error(
-        `Invalid configuration in wrapChars: '${rawChar}' is used. ` +
+        `Invalid configuration in wrapChars: '${validChars.join(',')}' is used. ` +
           `To be used as a CSS class, the 'label' property must not contain spaces, quotes, '<' or '>'. ` +
           `Skipping this wrapping rule.`
       )
       return
     }
 
-    // 有効な場合、クラス名を生成する（例: typesetting-char-あ など）
+    // 有効な場合、クラス名を生成する（例: typesetting-char-あ や typesetting-char-paren）
     const className = `typesetting-char-${label}`
-    const escapedChar = escapeRegExp(rawChar)
-
-    // 連続する該当文字をラッピングする正規表現を作成
-    const regex = new RegExp(`(${escapedChar}+)`, 'gu')
+    // 有効な文字群を用いて、正規表現用の文字クラスを構築する
+    const escapedChars = validChars.map(ch => escapeRegExp(ch)).join('')
+    // 連続して現れる対象文字列にマッチ（例: "(" または ")" の連続）
+    const regex = new RegExp(`([${escapedChars}]+)`, 'gu')
     updatedSegment = updatedSegment.replace(regex, match => `<span class="${className}">${match}</span>`)
   })
 
diff --git a/types/index.d.ts b/types/index.d.ts
index a59a081..bd2e0d6 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -75,6 +75,6 @@ export interface KerningRule {
  * label が未設定の場合は、char の値がそのままクラス名の一部として使用されます。
  */
 export interface WrapChar {
-  char: string
+  char: string | string[]
   label?: string
 }


Test patch:
diff --git a/tests/apply-style.test.ts b/tests/apply-style.test.ts
index ade24c1..5b7790f 100644
--- a/tests/apply-style.test.ts
+++ b/tests/apply-style.test.ts
@@ -206,3 +206,48 @@ describe('applyStyleToChar', () => {
     expect(applyStyleToChar(input, '', options)).toEqual(expected)
   })
 })
+
+describe('applyStyleToChar - extended tests for grouped characters', () => {
+  it('wraps grouped characters correctly when char is an array with multiple elements and label is provided', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: ['(', ')'], label: 'paren' }],
+    }
+    const input = 'Hello (world)!'
+    const expected =
+      'Hello <span class="typesetting-char-paren">(</span>world<span class="typesetting-char-paren">)</span>!'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).toEqual(expected)
+  })
+
+  it('wraps single element array correctly when label is omitted', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: ['あ'] }],
+    }
+    const input = 'あいうえおああ'
+    const expected = '<span class="typesetting-char-あ">あ</span>いうえお<span class="typesetting-char-あ">ああ</span>'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).toEqual(expected)
+  })
+
+  it('skips wrapping grouped characters when multiple characters are provided in array but label is omitted', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: ['(', ')'] }],
+    }
+    const input = 'Hello (world)!'
+    const output = applyStyleToChar(input, '', options)
+    // 無効な設定なので、ラッピング処理は行われず入力と同じ文字列が返る
+    expect(output).toEqual(input)
+  })
+
+  it('filters out invalid characters in the array and uses only valid ones', () => {
+    const options: TypesettingOptions = {
+      // 'ab' と '' は無効なため除外され、結果的に ['あ'] となる
+      wrapChars: [{ char: ['あ', 'ab', ''], label: 'group' }],
+    }
+    const input = 'あいうえおああ'
+    const expected =
+      '<span class="typesetting-char-group">あ</span>いうえお<span class="typesetting-char-group">ああ</span>'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).toEqual(expected)
+  })
+})