OpenXML SDK で生成した Word 文書にタイ語を流し込むと、手入力時のように自然に折り返されず、行末から文字がはみ出す――現場で頻出するこの現象は、仕組みを理解すれば確実に解消できます。本記事ではゼロ幅スペース(ZWSP)の実装パターン、Word の改行エンジンの活用、サーバー側の語分割まで、実装コードと運用上の注意点を含めて徹底解説します。
問題の背景と要点
タイ語は英語のように語と語の間にスペースを入れないため、どこで折り返せるかをアプリケーション側が判断できないと行末での自動改行が機能しにくくなります。Word の UI から手入力したテキストは、Word が内部の辞書・アルゴリズムを使って適切に折り返し候補を計算するため、自然な位置で改行されます。一方、OpenXML SDK で生成したテキストは、語境界の手掛かりが一切ない生テキストのまま流し込まれることが多く、結果として行末ではみ出します。
現実解は次の三本柱です。
- ZWSP(U+200B)を語境界に挿入して、Word に折り返し候補を与える。
- Word の改行エンジンに一度通して保存し直す(Word Automation)。
- サーバー側で語分割(ICU4N / PyThaiNLP / Intl.Segmenter など)→ ZWSP を付与してから OpenXML に渡す。
手入力・貼り付け・OpenXML の違い
| 入力経路 | Word の内部処理 | 行末の挙動 | 備考 |
|---|---|---|---|
| ユーザー手入力 | 言語判定+タイ語改行アルゴリズム適用 | 自然に折り返す | 言語が th-TH と認識されやすい |
| Ctrl+Shift+V(書式なし貼り付け) | 生テキストを Word が解析 | 多くのケースで折り返す | 貼り付け後に Word が語境界を再計算 |
| OpenXML SDK で生成 | 語境界情報がないまま文書に固定 | 折り返さずはみ出しがち | ZWSP や語分割を明示しないと改善しない |
解決策 1:ゼロ幅スペース(ZWSP, U+200B)を挿入する
最もシンプルで効果が高い方法は、語の区切りに ZWSP を入れた文字列を OpenXML に渡すことです。ZWSP は表示上は何も見えませんが、「ここで折り返してよい」というヒントになります。
最小コード例(OpenXML への直接流し込み)
// DocumentFormat.OpenXml 参照
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
static void CreateThaiDocx(string path)
{
using (var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(new Body());
var body = main.Document.Body;
// タイ語テキストに ZWSP を埋め込む(例)
string thai = "สวัสดี\u200Bครับ\u200Bทุกคน";
// ランに言語設定(後述)を与えるとより安全
var run = new Run(
new RunProperties(
new Languages { Val = "th-TH" } // ランの既定言語をタイ語に
),
new Text(thai) { Space = SpaceProcessingModeValues.Preserve }
);
body.AppendChild(new Paragraph(run));
main.Document.Save();
}
}
実運用での課題:どこに ZWSP を入れるか
手作業で \u200B を挿むのは非現実的です。そこで、語分割アルゴリズムまたはヒューリスティックで自動挿入します。
ヒューリスティック版(グラフェム単位+タイ文字判定)
タイ文字の Unicode ブロック U+0E00–U+0E7F を利用し、グラフェム(視覚的な 1 文字)単位で連続するタイ文字の間に ZWSP を入れる簡易手法です。句読点や数字・英字は壊しにくく、ライブラリ不要で軽量です。
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
static bool IsThai(string grapheme)
{
if (string.IsNullOrEmpty(grapheme)) return false;
var code = char.ConvertToUtf32(grapheme, 0);
return (code >= 0x0E00 && code <= 0x0E7F);
}
static string InsertZwspForThai(string input)
{
var enumerator = StringInfo.GetTextElementEnumerator(input);
var list = new List();
while (enumerator.MoveNext())
list.Add((string)enumerator.Current);
if (list.Count == 0) return input;
var sb = new StringBuilder(input.Length * 2);
for (int i = 0; i < list.Count; i++)
{
string cur = list[i];
sb.Append(cur);
// 次の要素があり、現在と次がタイ文字グラフェムなら ZWSP を挿入
if (i + 1 < list.Count && IsThai(cur) && IsThai(list[i + 1]))
{
sb.Append('\u200B');
}
}
// 既存 ZWSP と重複した場合のクリーンアップ
var result = Regex.Replace(sb.ToString(), "\u200B{2,}", "\u200B");
return result;
}
ICU4N を使った語分割(C#)
より正確にやるなら、ICU4N の BreakIterator(言語別の語境界検出)を使います。辞書ベースの分割で誤挿入を大きく減らせます。
// 例:ICU4N(NuGet)を導入済みと仮定
using ICU4N.Text;
using System.Globalization;
using System.Text;
static string InsertZwspWithICU(string input)
{
var breaker = BreakIterator.GetWordInstance(new CultureInfo("th-TH"));
breaker.SetText(input);
var sb = new StringBuilder(input.Length * 2);
int start = breaker.First();
for (int end = breaker.Next(); end != BreakIterator.Done; end = breaker.Next())
{
string seg = input.Substring(start, end - start);
sb.Append(seg);
// 非空の単語セグメントの末尾に ZWSP
if (!string.IsNullOrWhiteSpace(seg))
sb.Append('\u200B');
start = end;
}
// 文末の余計な ZWSP を除去
if (sb.Length > 0 && sb[sb.Length - 1] == '\u200B')
sb.Length--;
return sb.ToString();
}
既存テキストの二重挿入を防ぐプリフライト処理
static string PreflightZwsp(string input)
{
if (string.IsNullOrEmpty(input)) return input;
// 異体の不可視スペースを ZWSP に統一(混入対策)
string normalized = input
.Replace("\uFEFF", "") // ZWNBSP(BOM)→ 行分割不可なので除去
.Replace("\u2060", ""); // WORD JOINER → 折返し禁止なので除去
// 連続する ZWSP を 1 つに
normalized = Regex.Replace(normalized, "\u200B{2,}", "\u200B");
return normalized;
}
OpenXML 側の言語・フォント設定
Word がテキストの言語を正しく認識できるよう、ランまたは文書既定に th-TH を設定するのがおすすめです。
// ランに言語・フォントを設定する例
var run = new Run(
new RunProperties(
new RunFonts { Ascii = "Tahoma", HighAnsi = "Tahoma", EastAsia = "Tahoma" },
new Languages { Val = "th-TH" }
),
new Text(InsertZwspForThai(input)) { Space = SpaceProcessingModeValues.Preserve }
);
文書全体の既定言語を設定するには、StyleDefinitionsPart の DocDefaults に Languages を追加します。既存スタイルを壊さないように差分適用してください。
static void EnsureDefaultLang(MainDocumentPart main, string lang = "th-TH")
{
var sdp = main.StyleDefinitionsPart ?? main.AddNewPart();
if (sdp.Styles == null) sdp.Styles = new Styles();
if (sdp.Styles.DocDefaults == null)
sdp.Styles.DocDefaults = new DocDefaults();
if (sdp.Styles.DocDefaults.RunPropertiesDefault == null)
sdp.Styles.DocDefaults.RunPropertiesDefault = new RunPropertiesDefault();
var rpr = sdp.Styles.DocDefaults.RunPropertiesDefault.RunProperties ??= new RunProperties();
var langs = rpr.GetFirstChild<Languages>();
if (langs == null)
{
rpr.AppendChild(new Languages { Val = lang });
}
else
{
langs.Val = lang;
}
sdp.Styles.Save();
}
フォントの注意
- タイ語グリフを確実に含むフォント(例:Tahoma、Angsana New、Leelawadee UI など)を明示しておくと文字化けを避けやすい。
- フォントが存在しない環境ではフォールバックが起き、行幅計算が変わることがあるため、印刷レイアウトが厳密な帳票では利用フォントを固定する。
解決策 2:Word の改行エンジンを利用(Word Automation)
OpenXML で文書を生成したあと、Word を自動起動して開き直し・保存すると、Word 側が語境界と行分割を再計算します。環境に Word をインストールできる場合の現実解です。
サンプル(C# / Interop)
using WordApp = Microsoft.Office.Interop.Word.Application;
using WordDoc = Microsoft.Office.Interop.Word.Document;
using Microsoft.Office.Interop.Word;
static void ReflowWithWord(string path)
{
WordApp app = null;
WordDoc doc = null;
try
{
app = new WordApp { Visible = false };
doc = app.Documents.Open(path, ReadOnly: false, Visible: false);
// ドキュメントの既定言語をタイ語に(念のため)
doc.Content.LanguageID = WdLanguageID.wdThai;
// Word にレイアウト再計算をさせる
doc.Repaginate(); // 明示的にページネーションを更新
doc.Save();
}
finally
{
doc?.Close(WdSaveOptions.wdSaveChanges);
if (app != null) { app.Quit(); System.Runtime.InteropServices.Marshal.ReleaseComObject(app); }
}
}
運用注意:
- サーバー常駐プロセスから Office Interop を使うのはベストプラクティスではありません(対話アプリ想定の COM)。運用はデスクトップ自動化・バッチ専用端末等で。
- 権限とプロファイルによりフォント・プリンタ設定が変わると版面が揺れます。処理アカウントを固定し、同一環境で実行してください。
解決策 3:サーバー側で語分割 → ZWSP 付与(ICU / PyThaiNLP / Intl.Segmenter)
Office がないサーバーやサーバーレス環境では、文書生成前にテキストを語単位に分割し、語の後ろに ZWSP を入れてから OpenXML に渡すのが堅実です。ICU4N(.NET)、PyThaiNLP(Python)、ブラウザの Intl.Segmenter(フロントエンド)などが選択肢になります。
.NET(ICU4N)
static string ToZwspByICU(string input)
{
// 前掲 InsertZwspWithICU を参照
return InsertZwspWithICU(PreflightZwsp(input));
}
Python(PyThaiNLP)で前処理してから C# へ渡す例
# pip install pythainlp
from pythainlp import word_tokenize
def to_zwsp(text: str) -> str:
tokens = word_tokenize(text, keep_whitespace=False)
return "\u200B".join(t for t in tokens if t)
print(to_zwsp("สวัสดีครับทุกคน"))
ブラウザ(Intl.Segmenter)で入力時に ZWSP を仕込む
ユーザーの Web フォーム入力をリアルタイムに語分割し、ZWSP を埋め込んでサーバーへ送るパターンです。OpenXML 側は特別な処理をしなくても折り返しやすくなります。
const seg = ('Segmenter' in Intl)
? new Intl.Segmenter('th', { granularity: 'word' })
: null;
function toZwsp(text) {
if (!seg) return text; // 非対応ブラウザはそのまま
const parts = [];
for (const s of seg.segment(text)) {
if (s.isWordLike) parts.push(s.segment, '\u200B');
else parts.push(s.segment);
}
// 末尾の余分な ZWSP を除去
if (parts.length > 0 && parts[parts.length - 1] === '\u200B') parts.pop();
return parts.join('').replace(/\u200B{2,}/g, '\u200B');
}
// 例:textarea に反映
const ta = document.querySelector('#thai');
ta.addEventListener('input', () => {
const cursor = ta.selectionStart;
const val = toZwsp(ta.value);
ta.value = val;
// 簡易的にキャレット位置を維持(必要なら改良)
ta.selectionStart = ta.selectionEnd = Math.min(cursor, val.length);
});
OpenXML 実装の全体像(テンプレートなしで組み立て)
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using System.IO;
static void BuildThaiDocx(string path, string rawThai)
{
var withZwsp = InsertZwspForThai(PreflightZwsp(rawThai));
using (var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(new Body());
EnsureDefaultLang(main, "th-TH");
var pPr = new ParagraphProperties(
new Justification { Val = JustificationValues.Both } // 両端揃えでも折返し可
);
var run = new Run(
new RunProperties(
new RunFonts { Ascii = "Tahoma", HighAnsi = "Tahoma", EastAsia = "Tahoma" },
new Languages { Val = "th-TH" }
),
new Text(withZwsp) { Space = SpaceProcessingModeValues.Preserve }
);
main.Document.Body.AppendChild(new Paragraph(pPr, run));
main.Document.Save();
}
}
ポイント:
Text.Space=Preserveは末尾スペース保持のための保険です(ZWSP 自体は影響を受けません)。Justification(両端揃え)でも ZWSP があれば正しく行分割できます。- 表のセルやテキストボックスなど別コンテナでも同じ戦略で動作します。
検証方法とデバッグのコツ
- 不可視文字の可視化:エディタの「不可視文字表示」を有効化し、
\u200Bを[ZWSP]などに置換表示するデバッグ用関数を用意すると便利。 - 重複 ZWSP の除去:正規表現
/\u200B{2,}/で 1 つに圧縮。 - 正規化の罠:HTML サニタイザや正規化処理が ZWSP を削除することがあります。保存直前に ZWSP が生きているかをログで確認。
- フォント差異:生成マシンと閲覧マシンでフォントが異なると、行長がわずかに変わることがあります。成果物の配布先も含めてフォント方針を決める。
デバッグ補助コード(ログ出力)
static string RevealZwsp(string s)
=> s.Replace("\u200B", "[ZWSP]");
Console.WriteLine(RevealZwsp(InsertZwspForThai("สวัสดีครับทุกคน"))); </code></pre>
</section>
<section>
<h2>FAQ:よくある疑問</h2>
<table>
<thead>
<tr>
<th>質問</th>
<th>回答</th>
</tr>
</thead>
<tbody>
<tr>
<td>U+00AD(ソフトハイフン)でも良い?</td>
<td>タイ語はハイフンで語を区切らないため不適切です。折り返し候補は ZWSP(U+200B)を使って与えます。</td>
</tr>
<tr>
<td>ZWSP は検索やコピーに影響しない?</td>
<td>影響します。検索ヒットや文字数カウントが変わる場合があります。必要に応じて提出前に ZWSP を除去するクリーナーを用意してください。</td>
</tr>
<tr>
<td><code>Languages</code> を設定すれば ZWSP なしでも折り返す?</td>
<td>言語設定は Word の解析精度を上げますが、<em>OpenXML で流し込んだ瞬間</em>に語境界がなければ依然として弱いです。最小コストの解法は ZWSP 付与です。</td>
</tr>
<tr>
<td>既存の .docx を後処理で直せる?</td>
<td>パーツを読み出してテキストを語分割→ZWSP 注入→上書き保存で修正可能です。Word Automation も現実解です。</td>
</tr>
<tr>
<td>段落中に英数字や記号が混ざるときは?</td>
<td>グラフェム単位のヒューリスティック or ICU を使えば、英数字周りで誤挿入しにくくなります。混在文では辞書ベース推奨です。</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>運用設計のチェックリスト</h2>
<ul>
<li>[入力系]Web フォームやバッチ取り込み段階で ZWSP を仕込む。</li>
<li>[生成系]OpenXML で <code>th-TH</code> の言語・フォントを明示。</li>
<li>[品質保証]ビルド後にサンプル文書を Word で開き、行末のはみ出し・禁則を目視確認。</li>
<li>[クリーニング]エクスポート(プレーンテキストや検索用)時には ZWSP を除去するフィルタを用意。</li>
<li>[代替手段]Office が使えるなら Word Automation のバッチを併用し、難文は再計算に任せる。</li>
</ul>
</section>
<section>
<h2>サンプル:OpenXML で段落・表・箇条書きに適用する</h2>
<h3>段落</h3>
<pre><code class="language-csharp">static Paragraph ThaiParagraph(string raw)
{
var text = InsertZwspForThai(PreflightZwsp(raw));
var run = new Run(
new RunProperties(new Languages { Val = "th-TH" }),
new Text(text) { Space = SpaceProcessingModeValues.Preserve }
);
return new Paragraph(run);
}
表セル
static TableCell ThaiCell(string raw)
{
var p = ThaiParagraph(raw);
return new TableCell(new TableCellProperties(), p);
}
箇条書き(Numbering)
// 略:NumberingPart の設定は既存テンプレートを流用
var li = ThaiParagraph("หัวข้อย่อยทดสอบ");
li.ParagraphProperties = new ParagraphProperties(
new NumberingProperties(new NumberingLevelReference() { Val = 0 }, new NumberingId() { Val = 1 })
);
禁則・相性の悪い文字の取り扱い
ZWSP は「改行してよい場所」を作りますが、逆に「改行してはいけない」結合記号(例:WORD JOINER U+2060、ノーブレークスペース U+00A0、ゼロ幅ノーブレークスペース U+FEFF)が交じると、行末で切れなくなります。取り込み時に次を除去・正規化しておきましょう。
| コードポイント | 名称 | 挙動 | 対策 |
|---|---|---|---|
| U+200B | Zero Width Space | 折り返し可能 | 推奨 |
| U+2060 | Word Joiner | 折り返し不可 | 除去 |
| U+00A0 | No-Break Space | 折り返し不可 | 通常スペースへ |
| U+FEFF | Zero Width No-Break Space/BOM | 折り返し不可 | 除去 |
性能・スケーラビリティ
- 大規模処理では、語分割→ZWSP 付与→OpenXML 生成の 3 段をパイプライン化し、テキスト処理を非同期キューに逃がすとスループットが上がります。
- ICU は初期化コストがあるため、スレッドセーフなシングルトンとして使い回すとよいでしょう。
- 文字列連結は
StringBuilder、または Span/ArrayPool を用いて GC 負荷を抑制。
品質保証のためのサンプル(ユニットテスト)
// xUnit 例:ZWSP が入っていること、連続しないことを検証
using Xunit;
using System.Text.RegularExpressions;
public class ThaiWrapTests
{
[Fact]
public void InsertZwsp_Should_Insert_And_Not_Duplicate()
{
var raw = "สวัสดีครับทุกคน";
var s = InsertZwspForThai(PreflightZwsp(raw));
Assert.Contains("\u200B", s);
Assert.DoesNotMatch(new Regex("\u200B{2,}"), s);
}
}
まとめ:最短で効く手順と選び方
- まずは ZWSP:生成文字列に
\u200Bを挿入してから OpenXML SDK で書き込む。 - Word が使えるなら:生成後に Word を開いて保存し直す(改行位置の再計算)。
- 自動化・大量処理なら:ICU 等で語分割 → ZWSP 付与 → OpenXML 生成。
- 言語指定:
Languages.Val = "th-TH"をラン/既定に設定し、フォントを明示。 - クリーニング:
U+2060やU+FEFFを除去し、連続 ZWSP を圧縮。
これらを組み合わせれば、OpenXML SDK で生成したタイ語段落も、手入力と同等に自然な折り返しを再現できます。

コメント