Repository: yamatoiizuka/palt-typesetting (ts)

Issue:
Character Wrapping（特例文字）
Adobe Illustrator や Indesign の合成フォントにおける、特例文字にあたる機能を追加する。


```javascript
wrapChar: [
  {
    char: 'あ',
    label: 'hira-a'
  },
  {
    char: '」',
    // label は省略可。エスケープ文字の取り扱いについて要確認。
  },
]

```

の設定により、

```html
<!-- INPUT -->
「ああ、そうだね。」と言った。

<!-- OUTPUT -->
「<span class="typesetting-char-hira-a">ああ</span>、そうだね。<span class="typesetting-char-」">」</span>と言った。
```
上記のような HTML を生成する。

Pull request description:
335 character wrapping
Fix #335 

Patch:
diff --git a/README.md b/README.md
index 39bdf70..5461489 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,20 @@ const options = {
   // 英数を `.typesetting-latin` でラップします。
   wrapLatin: true,
 
+  // 特例文字 `char` を `.typesetting-char-[label]` でラップします。
+  wrapChars: [
+    {
+      char: 'あ',
+      label: 'hira-a',
+      // output: <span class="typesetting-char-hira-a">あ</span>
+    },
+    {
+      char: '」',
+      // output: <span class="typesetting-char-」">」</span>
+      // label が未指定の場合は、char の値がそのまま label として使用されます。
+    },
+  ],
+
   // 罫線などの分離禁則文字を `.typesetting-no-breaks` でラップし、文字間を 0 に設定します。
   noSpaceBetweenNoBreaks: true,
 
@@ -182,7 +196,7 @@ typesetter.renderToElements(elements)
  */
 const srcHtml = '「日本語」とEnglish'
 console.log('output: ' + typesetter.render(srcHtml))
-// output: <span class="typeset typesetting-wrapper typesetting-word-break">「日本語」<span class="typesetting-thin-space" style="letter-spacing: 0.2em;" data-content=" "></span>と<span class="typesetting-thin-space" style="letter-spacing: 0.2em;" data-content=" "></span><span class="typesetting-latin">English </span></span>
+// output: <span class="typeset typesetting-wrapper typesetting-word-break">「日本語<span class="typesetting-char-」">」</span><span class="typesetting-thin-space" style="letter-spacing: 0.2em;" data-content=" "></span>と<span class="typesetting-thin-space" style="letter-spacing: 0.2em;" data-content=" "></span><span class="typesetting-latin">English </span></span>
 ```
 
 ### コンストラクタ
@@ -201,25 +215,27 @@ console.log('output: ' + typesetter.render(srcHtml))
 
 ### オプション
 
-| オプション名             | 説明                                                                                                                             | オプションの型                                           | デフォルト値 |
-| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
-| `useWordBreak`           | 単語や助詞など、単語区切りでの改行を行います。                                                                                   | `boolean`                                                | `true`       |
-| `wrapLatin`              | 英数を `span.typesetting-latin` でラップします。                                                                                 | `boolean`                                                | `true`       |
-| `noSpaceBetweenNoBreaks` | 罫線などの分離禁則文字を `span.typesetting-no-breaks` でラップし、文字間を 0 に設定します。                                      | `boolean`                                                | `true`       |
-| `insertThinSpaces`       | 四分アキスペースを自動で挿入します。                                                                                             | `boolean`                                                | `true`       |
-| `thinSpaceWidth`         | 四分アキスペースの幅を設定します。 <br>※四分アキは本来 0.25em ですが、視覚上の補正としてデフォルト値を 0.2 em に設定しています。 | `string`                                                 | `'0.2em'`    |
-| `kerningRules`           | 特定の文字間のカーニングルールを設定します。                                                                                     | `{between: [string, string], value: string \| number}[]` | `[]`         |
+| オプション名             | 説明                                                                                                                                                                                                                                                     | オプションの型                                           | デフォルト値 |
+| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
+| `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}[]` | `[]`         |
 
 ### 生成される CSS クラス
 
-| CSS クラス名              | 説明                                                                                                        |
-| ------------------------- | ----------------------------------------------------------------------------------------------------------- |
-| `.typesetting-wrapper`    | HTML 文字列中のテキストコンテンツをラップします。<br>オプションの指定に関わらず、常に生成されます。         |
-| `.typesetting-word-break` | HTML 文字列中のテキストコンテンツをラップします。<br>`useWordBreak: true` のときに生成されます。            |
-| `.typesetting-latin`      | テキストコンテンツ中の英数をラップします。<br>`wrapLatin: true` のときに生成されます。                      |
-| `.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` で指定された特例文字に付与されるクラスです。<br>※ `label` が未指定の場合、`char` の値を `label` として使用します。 |
+| `.typesetting-no-breaks`    | テキストコンテンツ中の分離禁則文字をラップします。<br>`noSpaceBetweenNoBreaks: true` のときに生成されます。                    |
+| `.typesetting-thin-space`   | 挿入される四分アキスペースエレメントの CSS クラスです。<br>`insertThinSpaces: true` のときに生成されます。                     |
+| `.typesetting-kerning`      | 挿入されるカーニングエレメントの CSS クラスです。<br>有効な `kerningRules` が存在するときに生成されます。                      |
 
 ---
 
@@ -231,9 +247,16 @@ TypeScript の型定義を提供しています。完全な型定義は[こち
 
 ```typescript
 import Typesetter from 'palt-typesetting'
-import type { TypesettingOptions, KerningRule } from 'palt-typesetting/types'
+import type { TypesettingOptions, KerningRule, WrapChar } from 'palt-typesetting/types'
 import 'palt-typesetting/dist/typesetter.css'
 
+const chars: WrapChar[] = [
+  {
+    char: 'あ',
+    label: 'hira-a',
+  },
+]
+
 const kerning: KerningRule[] = [
   {
     between: ['し', 'ま'],
@@ -244,6 +267,7 @@ const kerning: KerningRule[] = [
 const options: TypesettingOptions = {
   thinSpaceWidth: '0.25em',
   kerningRules: kerning,
+  wrapChars: chars,
 }
 
 const typeset = new Typesetter(options)
diff --git a/demo/package-lock.json b/demo/package-lock.json
index d6b4161..989b275 100644
--- a/demo/package-lock.json
+++ b/demo/package-lock.json
@@ -21,7 +21,7 @@
       "version": "0.8.0",
       "license": "MIT",
       "dependencies": {
-        "jsdom": "^25.0.0",
+        "jsdom": "^26.0.0",
         "linebreak": "^1.1.0"
       },
       "devDependencies": {
@@ -38,7 +38,7 @@
         "typescript": "^5",
         "vitest": "^3.0.5",
         "webpack": "^5.90.3",
-        "webpack-cli": "^5.1.4"
+        "webpack-cli": "^6.0.1"
       }
     },
     "../palt-typesetting": {
diff --git a/demo/src/index.html b/demo/src/index.html
index 51fbea8..8c25d7e 100644
--- a/demo/src/index.html
+++ b/demo/src/index.html
@@ -38,6 +38,11 @@
           <label for="wrapLatinToggle">wrapLatin</label>
         </div>
 
+        <div class="option">
+          <input type="checkbox" id="wrapCharsToggle" checked />
+          <label for="wrapCharsToggle">wrapChars</label>
+        </div>
+
         <div class="option">
           <input type="checkbox" id="noSpaceBetweenNoBreaksToggle" checked />
           <label for="noSpaceBetweenNoBreaksToggle">noSpaceBetweenNoBreaks</label>
@@ -104,7 +109,7 @@
 
         <br /><br />──────<br /><br />
 
-        Author: 飯塚大和（Yamato Iizuka）
+        Author: 飯塚大和 (Yamato Iizuka)
         <br /><br />
         GitHub:<a href="https://github.com/yamatoiizuka" target="_balnk">@yamatoiizuka</a><br />Twitter:
         <a href="https://twitter.com/yamatoiizuka">@yamatoiizuka</a><br />E-mail:
diff --git a/demo/src/index.ts b/demo/src/index.ts
index 2ee8584..72cf0b4 100644
--- a/demo/src/index.ts
+++ b/demo/src/index.ts
@@ -1,7 +1,7 @@
 import './style.css'
 import './module/typekit'
 import Typesetter from 'palt-typesetting'
-import type { TypesettingOptions, KerningRule } from 'palt-typesetting/types'
+import type { TypesettingOptions, KerningRule, WrapChar } from 'palt-typesetting/types'
 import 'palt-typesetting/dist/typesetter.css'
 
 // HTML要素の取得
@@ -17,9 +17,11 @@ const insertThinSpacesToggle = document.getElementById('insertThinSpacesToggle')
 const wrapLatinToggle = document.getElementById('wrapLatinToggle') as HTMLInputElement
 const noSpaceBetweenNoBreaksToggle = document.getElementById('noSpaceBetweenNoBreaksToggle') as HTMLInputElement
 const kerningRulesToggle = document.getElementById('kerningRulesToggle') as HTMLInputElement
+const wrapCharsToggle = document.getElementById('wrapCharsToggle') as HTMLInputElement
 
 let options: TypesettingOptions = {
-  kerningRules: getKerningRules(true),
+  kerningRules: getKerningRules(),
+  wrapChars: getWrapChars(),
 }
 
 /**
@@ -133,7 +135,8 @@ function updateOptions() {
     insertThinSpaces: insertThinSpacesToggle.checked,
     wrapLatin: wrapLatinToggle.checked,
     noSpaceBetweenNoBreaks: noSpaceBetweenNoBreaksToggle.checked,
-    kerningRules: getKerningRules(kerningRulesToggle.checked),
+    kerningRules: kerningRulesToggle.checked ? getKerningRules() : [],
+    wrapChars: wrapCharsToggle.checked ? getWrapChars() : [],
   }
 }
 
@@ -166,27 +169,36 @@ function handleTargetCheckboxChange(event: Event) {
 
 /**
  * カーニングルールを取得する関数
- * @param {boolean} isEnabled - カーニングルールが有効かどうか
  * @returns {KerningRule[]} カーニングルールの配列
  */
-function getKerningRules(isEnabled: boolean): KerningRule[] {
-  return isEnabled
-    ? [
-        { between: ['美', 'し'], value: 60 },
-        { between: ['ス', 'ト'], value: 120 },
-        { between: ['イ', 'ブ'], value: 20 },
-        { between: ['ブ', 'ラ'], value: -30 },
-        { between: ['ラ', 'リ'], value: 30 },
-        { between: ['て', '、'], value: -60 },
-        { between: ['す', '。'], value: -120 },
-        { between: ['よ', 'う'], value: 60 },
-        { between: ['う', 'な'], value: 40 },
-        { between: ['さ', 'れ'], value: 20 },
-        { between: ['れ', 'た'], value: -60 },
-        { between: ['供', 'し'], value: 40 },
-        { between: ['し', 'ま'], value: 70 },
-      ]
-    : []
+function getKerningRules(): KerningRule[] {
+  return [
+    { between: ['美', 'し'], value: 60 },
+    { between: ['ス', 'ト'], value: 120 },
+    { between: ['イ', 'ブ'], value: 20 },
+    { between: ['ブ', 'ラ'], value: -30 },
+    { between: ['ラ', 'リ'], value: 30 },
+    { between: ['て', '、'], value: -60 },
+    { between: ['す', '。'], value: -120 },
+    { between: ['よ', 'う'], value: 60 },
+    { between: ['う', 'な'], value: 40 },
+    { between: ['さ', 'れ'], value: 20 },
+    { between: ['れ', 'た'], value: -60 },
+    { between: ['供', 'し'], value: 40 },
+    { between: ['し', 'ま'], value: 70 },
+  ]
+}
+
+/**
+ * 特例文字の設定を取得する関数
+ * @returns {wrapCharRule[]} 特例文字の設定の配列
+ */
+function getWrapChars(): WrapChar[] {
+  return [
+    { char: '↗', label: 'arrow' },
+    { char: '(', label: 'parenthesis' },
+    { char: ')', label: 'parenthesis' },
+  ]
 }
 
 // 初期化処理
diff --git a/demo/src/style.css b/demo/src/style.css
index eb48927..65cdad3 100644
--- a/demo/src/style.css
+++ b/demo/src/style.css
@@ -93,6 +93,23 @@ h1 {
   font-weight: 200;
 }
 
+/*
+ * 特例文字のスタイリング
+ */
+.typesetting-char-arrow {
+  font-family: 'arial', sans-serif;
+  font-weight: 400;
+  font-style: normal;
+}
+
+.typesetting-char-parenthesis {
+  font-family: 'ojuju', sans-serif;
+  font-weight: 400;
+  font-style: normal;
+  font-size: 110%;
+  line-height: 1;
+}
+
 .en-section {
   line-height: 1.8;
 }
diff --git a/src/apply-style.ts b/src/apply-style.ts
index 46e87fd..6d3c3fd 100644
--- a/src/apply-style.ts
+++ b/src/apply-style.ts
@@ -7,33 +7,33 @@ import { TypesettingOptions } from '../types'
 /**
  * 与えられたテキストに対して、word-breakとoverflow-wrapスタイルを持つspanタグでラップします。
  *
- * @param text - スタイリングされるテキスト。
- * @return スタイリングされたテキスト。テキストが空の場合は、そのまま返されます。
+ * @param currentNodeValue - スタイリングされるテキスト。
+ * @param nextNodeValue - 次のテキスト（未使用ですが、TransformFunctionに合わせたシグネチャ）。
+ * @param options - TypesettingOptions
+ * @return スタイリングされたテキスト。テキストが空の場合はそのまま返されます。
  */
 const applyStyleToText = (currentNodeValue: string, nextNodeValue: string, options: TypesettingOptions): string => {
-  // ここでは nextNodeValue を使用していませんが、関数のシグネチャはTransformFunctionに合わせています。
   if (currentNodeValue === ' ') {
     return currentNodeValue
   }
-
   return applyWrapperStyle(currentNodeValue, options.useWordBreak)
 }
 
 /**
- * 与えられたテキストセグメントにスタイリングを適用します。これには、セグメントの内容に基づいて、
- * 特定のクラスまたはスタイルでHTMLのspanタグでセグメントをラップすることが含まれる場合があります。
+ * 与えられたテキストセグメントにスタイリングを適用します。
+ * セグメントがスペースの場合はそのままとし、ラテン文字や分離禁則文字に対して固有のスタイルを適用します。
  *
- * @param segment - スタイリングされるテキストセグメント。
- * @return スタイリングされたテキストセグメント。セグメントがスペースであるか、特別なスタイリングが不要であれば、
- *     そのまま返されます。それ以外の場合は、適切なスタイリングでspanタグでラップされます。
+ * @param currentSegment - 対象のテキストセグメント
+ * @param nextSegment - 次のセグメント（判定に利用）
+ * @param options - TypesettingOptions
+ * @return 適切なスタイリングが適用されたテキストセグメント
  */
 const applyStyleToSegment = (currentSegment: string, nextSegment: string, options: TypesettingOptions): string => {
-  // セグメントがスペースであればそのまま返す
   if (currentSegment === ' ') {
     return currentSegment
   }
 
-  // ラテン文字のセグメントには 'latin' クラスを適用
+  // ラテン文字の場合は 'latin' クラスでラップ
   if (options.wrapLatin && LanguageClass.isLatin(currentSegment)) {
     return currentSegment.replace(util.latinRegex, match => applyLatinStyle(match))
   }
@@ -49,10 +49,10 @@ const applyStyleToSegment = (currentSegment: string, nextSegment: string, option
 /**
  * カーニングルールに基づき、テキストセグメントにカーニングを適用します。
  *
- * @param currentSegment - カーニングを適用する現在のセグメント。
- * @param nextSegment - 次のセグメント（カーニング適用の判断に使用）。
- * @param kerningRules - 適用するカーニングルールの配列。
- * @return カーニング適用後のテキストセグメント。
+ * @param currentSegment - カーニング適用対象となる現在のセグメント
+ * @param nextSegment - 次のセグメント（カーニング適用の判断に使用）
+ * @param options - TypesettingOptions
+ * @return カーニング適用後のテキストセグメント
  */
 const applyKerningToSegment = (currentSegment: string, nextSegment: string, options: TypesettingOptions): string => {
   const chars = [...currentSegment]
@@ -75,4 +75,63 @@ const applyKerningToSegment = (currentSegment: string, nextSegment: string, opti
   return kernedChars.join('')
 }
 
-export { applyStyleToText, applyStyleToSegment, applyKerningToSegment }
+/**
+ * 正規表現で特殊文字をエスケープするヘルパー関数
+ *
+ * @param str - エスケープする文字列
+ * @return エスケープされた文字列
+ */
+const escapeRegExp = (str: string): string => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
+
+/**
+ * 特例文字の設定に基づき、テキストセグメント内の対象文字（連続した部分）を指定のクラスでラップします。
+ *
+ * @param currentSegment - 対象のセグメント
+ * @param nextSegment - 次のセグメント（本処理では未使用）
+ * @param options - TypesettingOptions。wrapChars の設定を含む
+ * @return 特例文字のラッピングを適用したセグメント
+ */
+const applyStyleToChar = (currentSegment: string, nextSegment: string, options: TypesettingOptions): string => {
+  // wrapChars の設定がない場合はそのまま返す
+  if (!options.wrapChars || options.wrapChars.length === 0) {
+    return currentSegment
+  }
+
+  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.`)
+      // 該当ルールはスキップする
+      return
+    }
+
+    // label が指定されていなければ、デフォルトは rawChar を採用する
+    const label = rule.label ? rule.label : rawChar
+
+    // クラス名として使用する際に安全な文字列か判定
+    // クラス名に使用できるのは、空白や " ' < > などの特殊文字を含まない文字列
+    const validClassRegex = /^[^"'<>\s]+$/
+    if (!validClassRegex.test(label)) {
+      console.error(
+        `Invalid configuration in wrapChars: '${rawChar}' 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-あ など）
+    const className = `typesetting-char-${label}`
+    const escapedChar = escapeRegExp(rawChar)
+
+    // 連続する該当文字をラッピングする正規表現を作成
+    const regex = new RegExp(`(${escapedChar}+)`, 'gu')
+    updatedSegment = updatedSegment.replace(regex, match => `<span class="${className}">${match}</span>`)
+  })
+
+  return updatedSegment
+}
+
+export { applyStyleToText, applyStyleToSegment, applyKerningToSegment, applyStyleToChar }
diff --git a/src/index.ts b/src/index.ts
index 0ade8ae..113411b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,6 @@
 import HTMLProcessor from './html-processor.js'
 import insertSeparatorsToText from './insert-separators.js'
-import { applyStyleToText, applyStyleToSegment, applyKerningToSegment } from './apply-style.js'
+import { applyStyleToText, applyStyleToSegment, applyKerningToSegment, applyStyleToChar } from './apply-style.js'
 import win from './win.js'
 import type { TypesettingOptions, KerningRule } from '../types'
 
@@ -16,6 +16,7 @@ class Typesetter extends HTMLProcessor {
     return {
       useWordBreak: true,
       wrapLatin: true,
+      wrapChars: [],
       noSpaceBetweenNoBreaks: true,
       insertThinSpaces: true,
       thinSpaceWidth: '0.2em',
@@ -41,8 +42,8 @@ class Typesetter extends HTMLProcessor {
      * サポートされていない場合は、単語区切りの改行と四分アキの挿入をスキップします。
      */
     const transformFunctions = isIntlSegmenterSupported
-      ? [applyStyleToText, insertSeparatorsToText, applyStyleToSegment, applyKerningToSegment]
-      : [applyStyleToSegment, applyKerningToSegment]
+      ? [applyStyleToText, insertSeparatorsToText, applyStyleToSegment, applyKerningToSegment, applyStyleToChar]
+      : [applyStyleToSegment, applyKerningToSegment, applyStyleToChar]
 
     const validatedOptions = Typesetter.validateOptions(options)
 
diff --git a/types/index.d.ts b/types/index.d.ts
index f7eb559..a59a081 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -24,6 +24,14 @@ export interface TypesettingOptions {
    */
   wrapLatin?: boolean
 
+  /**
+   * 特例文字の設定。
+   * 指定された char をラップします。
+   * ラップされた文字は、.typesetting-char-[label] というクラス名でラップされます。
+   * label が未設定の場合は、char の値がそのままクラス名の一部として使用されます。
+   */
+  wrapChars?: WrapChar[]
+
   /**
    * 分離禁則文字を .typesetting-no-breaks でラップし、文字間を 0 に設定します。
    * useWordBreak が true の場合にのみ有効です。
@@ -59,3 +67,14 @@ export interface KerningRule {
   between: [string, string]
   value: string | number
 }
+
+/**
+ * 特例文字の設定を定義するインターフェース。
+ * char: ラッピング対象の文字を指定します。
+ * label: ラッピング時に使用するクラス名の一部となる文字列（任意）。
+ * label が未設定の場合は、char の値がそのままクラス名の一部として使用されます。
+ */
+export interface WrapChar {
+  char: string
+  label?: string
+}


Test patch:
diff --git a/tests/apply-style.test.ts b/tests/apply-style.test.ts
index a29267e..ade24c1 100644
--- a/tests/apply-style.test.ts
+++ b/tests/apply-style.test.ts
@@ -1,5 +1,5 @@
 import Typesetter, { TypesettingOptions } from '../src'
-import { applyStyleToSegment, applyKerningToSegment } from '../src/apply-style'
+import { applyStyleToSegment, applyKerningToSegment, applyStyleToChar } from '../src/apply-style'
 import { describe, it, expect } from 'vitest'
 
 describe('applyStyleToSegment', () => {
@@ -143,3 +143,66 @@ describe('applyStyleToSegment without useWordBreak option', () => {
     expect(applyKerningToSegment(current, next, options)).toEqual(expected)
   })
 })
+
+describe('applyStyleToChar', () => {
+  it('wraps consecutive occurrences of special characters according to configuration', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: 'あ', label: 'hira-a' }, { char: '」' }],
+    }
+    const input = '「ああ、そうだね。」と言った。'
+    const expected =
+      '「<span class="typesetting-char-hira-a">ああ</span>、そうだね。<span class="typesetting-char-」">」</span>と言った。'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).toEqual(expected)
+  })
+
+  it('returns original text if wrapChar configuration is not provided', () => {
+    const options: TypesettingOptions = {}
+    const input = 'テスト'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).toEqual('テスト')
+  })
+
+  it('wraps multiple occurrences correctly', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: 'あ', label: 'hira-a' }],
+    }
+    const input = 'あいうえおああ'
+    // "あ" と "ああ" それぞれをラップする
+    const expected =
+      '<span class="typesetting-char-hira-a">あ</span>いうえお<span class="typesetting-char-hira-a">ああ</span>'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).toEqual(expected)
+  })
+
+  // このテストは、wrapChar の設定で char: '"' を指定した場合、
+  // デフォルトのラベルが不正な文字を含むため、ラッピング処理がスキップされることを検証します。
+  // その結果、期待されるラッピング結果（クラス名に不正な文字が含まれる HTML）は生成されないことを確認します。
+  it('should skip wrapping for multiple occurrences of invalid characters when default label is unsafe (e.g. double quotes)', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: '"' }],
+    }
+    const input = '"hi"'
+    const expected = '<span class="typesetting-char-"">"</span>hi<span class="typesetting-char-"">"</span>'
+    const output = applyStyleToChar(input, '', options)
+    expect(output).not.toEqual(expected)
+  })
+
+  it('should skip wrapping when char has more than one character (invalid rule)', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: 'ab', label: 'double' }],
+    }
+    const input = 'ababab'
+    // ルールが無効なため、ラッピング処理は適用されず入力と同じ文字列が返される
+    expect(applyStyleToChar(input, '', options)).toEqual(input)
+  })
+
+  it('wraps emoji correctly when char is a single emoji (even if represented by surrogate pairs)', () => {
+    const options: TypesettingOptions = {
+      wrapChars: [{ char: '😀', label: 'smiley' }],
+    }
+    const input = '😀😀'
+    const expected = '<span class="typesetting-char-smiley">😀😀</span>'
+    expect(applyStyleToChar(input, '', options)).toEqual(expected)
+  })
+})