WinForms TextBoxの文字間隔を自動調整する方法|C#でカーニング&トラッキングを実装

WinForms の標準 TextBox には「テキストがはみ出たら自動で文字間隔を詰めて全体を見せる」機能はありません。とはいえ実装は不可能ではありません。ここでは、既製の TextBox を直接いじるのではなく、描画専用のカスタムコントロールで間隔を制御し、編集時だけ通常の TextBox をオーバーレイする方式で、要件を現実的なコストで満たす手順とコードを詳解します。

目次

前提と結論(先に要点)

  • 標準 TextBox には可変カーニング/追い込みの API がありません。Font・AutoSize・PasswordChar 等で代用はできません。
  • WinForms でも GDI を直接叩けば 1 文字ごとの送り量(advance)を指定して描画できます(ExtTextOutW の lpDx)。これを使えば「負のトラッキング(文字詰め)」が実現できます。
  • ただし TextBox 自体はオーナードロー不可。表示は自前描画、編集は通常 TextBox に委譲する二層構造が現実解です。
  • IME・選択・キャレット・アクセシビリティまで自作するのは工数過多。編集時のみ標準 TextBox にスイッチすれば、ユーザー体験は保ちつつ実装量を抑えられます。

アーキテクチャ概要

以下の二つのコントロールを組み合わせます。

  1. AutoKernDisplay(描画専用):Control を継承。OnPaint 内で GetTextExtentExPointW で各文字の advance を取得し、必要なだけ均等または重み付きに縮め、ExtTextOutW の lpDx に配列を渡して描画。
  2. AutoKernTextBox(ラッパー):通常は AutoKernDisplay を表示。フォーカス取得やクリックで 境界内にボーダー無し TextBox をオーバーレイし、編集を委譲。確定・フォーカスアウトでテキストを戻して表示層に切替。
要件標準 TextBox 単体提案アーキテクチャ
はみ出し時の自動詰め不可可(GDI 直接描画)
IME/選択/キャレット可編集時のみ標準 TextBox に委譲
工数・保守低中(表示だけ自前)
体験の一貫性既定編集中のみ標準の振る舞い

実装の肝:GDI で 1 文字送りを制御する

GDI には二つの重要関数があります。

  • GetTextExtentExPointW:描画せずに「ここまで描くと何ピクセル進むか」という 累積 advance 配列を返す。
  • ExtTextOutW:描画時に 各文字ごとの advance(lpDx 配列)を指定可能。ここに「通常より小さい advance」を入れれば実質的な負のトラッキングになります。

GDI+(Graphics.DrawString)や TextRenderer.DrawText では per-glyph spacing を直接いじれないため、P/Invoke で GDI を呼ぶのがポイントです。

ソースコード:描画専用コントロール AutoKernDisplay

単一行を前提にしています(折返し不要の UI で使う想定)。

using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;

public class AutoKernDisplay : Control
{
private ContentAlignment _textAlign = ContentAlignment.MiddleLeft;
private float _maxNegativeTrackingEm = 0.15f; // 1 文字間あたりの最大短縮量(em 単位)


public AutoKernDisplay()
{
    SetStyle(ControlStyles.AllPaintingInWmPaint |
             ControlStyles.UserPaint |
             ControlStyles.OptimizedDoubleBuffer |
             ControlStyles.ResizeRedraw, true);
    TabStop = false;
}

public ContentAlignment TextAlign
{
    get => _textAlign;
    set { _textAlign = value; Invalidate(); }
}

/// <summary>1 文字間に対する最大の負トラッキング量(em 単位・0~0.5 程度)</summary>
public float MaxNegativeTrackingEm
{
    get => _maxNegativeTrackingEm;
    set { _maxNegativeTrackingEm = Math.Max(0f, Math.Min(0.5f, value)); Invalidate(); }
}

protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint(e);
    e.Graphics.Clear(BackColor);
    var s = Text ?? string.Empty;
    if (s.Length == 0) return;

    IntPtr hdc = e.Graphics.GetHdc();
    IntPtr hFont = IntPtr.Zero;
    IntPtr old = IntPtr.Zero;
    bool drawn = false;

    try
    {
        hFont = Font.ToHfont();
        old = SelectObject(hdc, hFont);
        SetBkMode(hdc, 1); // TRANSPARENT
        SetTextColor(hdc, ColorToColorRef(ForeColor));

        int n = s.Length;
        int[] dxCum = new int[n];
        SIZE size;
        if (!GetTextExtentExPointW(hdc, s, n, int.MaxValue, IntPtr.Zero, dxCum, out size))
            return;

        // 累積距離 → 各文字の advance に変換
        int[] adv = new int[n];
        for (int i = 0; i < n; i++)
            adv[i] = (i == 0) ? dxCum[0] : dxCum[i] - dxCum[i - 1];

        int total = size.cx;
        int target = ClientSize.Width;
        int[] dxOut = (int[])adv.Clone();

        if (total > target && n > 1)
        {
            int deficit = total - target;
            double perGap = deficit / (double)(n - 1);

            // 1 gap あたりの上限(px)
            float emPx = Font.SizeInPoints * e.Graphics.DpiY / 72f;
            double perGapLimit = _maxNegativeTrackingEm * emPx;
            if (perGap > perGapLimit) perGap = perGapLimit;

            // 均等配分(端数は誤差拡散)
            double carry = 0;
            int sum = 0;
            for (int i = 0; i < n; i++)
            {
                int reduce = (i < n - 1) ? (int)Math.Floor(perGap + carry) : 0;
                carry += (perGap - reduce);
                int v = adv[i] - reduce;
                if (v < 1) v = 1; // つぶれ防止
                dxOut[i] = v;
                sum += v;
            }
            // 合計幅の微調整
            int over = sum - target; // 0 以上
            int j = 0;
            while (over > 0 && j < n - 1)
            {
                if (dxOut[j] > 1) { dxOut[j]--; over--; }
                j++; if (j >= n - 1) j = 0;
            }
        }

        // 配置
        var origin = ComputeOrigin(ClientRectangle, dxOut, _textAlign, Font.Height);
        ExtTextOutW(hdc, origin.X, origin.Y, 0, IntPtr.Zero, s, n, dxOut);
        drawn = true;
    }
    finally
    {
        if (old != IntPtr.Zero) SelectObject(hdc, old);
        if (hFont != IntPtr.Zero) DeleteObject(hFont);
        e.Graphics.ReleaseHdc(hdc);
    }

    // 何らかの理由で GDI 描画できなければ通常描画
    if (!drawn)
    {
        TextRenderer.DrawText(e.Graphics, Text, Font, ClientRectangle, ForeColor,
            TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl |
            TranslateAlignFlags(_textAlign));
    }
}

private static Point ComputeOrigin(Rectangle rc, int[] dxOut, ContentAlignment align, int fontHeight)
{
    int width = 0; foreach (var v in dxOut) width += v;
    int x = rc.Left;
    if (align == ContentAlignment.MiddleCenter || align == ContentAlignment.TopCenter || align == ContentAlignment.BottomCenter)
        x = rc.Left + (rc.Width - width) / 2;
    else if (align == ContentAlignment.MiddleRight || align == ContentAlignment.TopRight || align == ContentAlignment.BottomRight)
        x = rc.Right - width;

    int y = rc.Top;
    if (align == ContentAlignment.MiddleLeft || align == ContentAlignment.MiddleCenter || align == ContentAlignment.MiddleRight)
        y = rc.Top + (rc.Height - fontHeight) / 2;
    else if (align == ContentAlignment.BottomLeft || align == ContentAlignment.BottomCenter || align == ContentAlignment.BottomRight)
        y = rc.Bottom - fontHeight;

    return new Point(x, y);
}

private static int ColorToColorRef(Color c) => (c.R) | (c.G << 8) | (c.B << 16);

[StructLayout(LayoutKind.Sequential)]
private struct SIZE { public int cx; public int cy; }

[DllImport("gdi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool GetTextExtentExPointW(
    IntPtr hdc, string lpszStr, int cchString, int nMaxExtent,
    IntPtr lpnFit, [Out] int[] alpDx, out SIZE lpSize);

[DllImport("gdi32.dll", CharSet = CharSet.Unicode)]
private static extern bool ExtTextOutW(
    IntPtr hdc, int x, int y, uint options, IntPtr lprect,
    string lpString, int cch, int[] lpDx);

[DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr hdc, IntPtr h);
[DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr ho);
[DllImport("gdi32.dll")] private static extern int SetBkMode(IntPtr hdc, int mode);
[DllImport("gdi32.dll")] private static extern uint SetTextColor(IntPtr hdc, int crColor);

private static TextFormatFlags TranslateAlignFlags(ContentAlignment a)
{
    TextFormatFlags h = TextFormatFlags.Left;
    if (a == ContentAlignment.TopCenter || a == ContentAlignment.MiddleCenter || a == ContentAlignment.BottomCenter)
        h = TextFormatFlags.HorizontalCenter;
    else if (a == ContentAlignment.TopRight || a == ContentAlignment.MiddleRight || a == ContentAlignment.BottomRight)
        h = TextFormatFlags.Right;

    TextFormatFlags v = TextFormatFlags.VerticalCenter;
    if (a == ContentAlignment.TopLeft || a == ContentAlignment.TopCenter || a == ContentAlignment.TopRight)
        v = TextFormatFlags.Top;
    else if (a == ContentAlignment.BottomLeft || a == ContentAlignment.BottomCenter || a == ContentAlignment.BottomRight)
        v = TextFormatFlags.Bottom;

    return h | v;
}


} 

ラッパー:編集時のみ標準 TextBox をオーバーレイ

フォーカス時だけ TextBox を前面に出すコンテナです。IME、選択、ショートカット、クリップボードなどの面倒は TextBox に任せられます。

using System;
using System.Drawing;
using System.Windows.Forms;

public class AutoKernTextBox : Control
{
private readonly AutoKernDisplay _display;
private readonly TextBox _editor;


public AutoKernTextBox()
{
    SetStyle(ControlStyles.ContainerControl | ControlStyles.ResizeRedraw, true);

    _display = new AutoKernDisplay { Dock = DockStyle.Fill, BackColor = SystemColors.Window, ForeColor = SystemColors.WindowText };
    _editor  = new TextBox { BorderStyle = BorderStyle.None, Visible = false };

    Controls.Add(_display);
    Controls.Add(_editor);

    Padding = new Padding(3); // TextBox の内側余白に近づける
    TabStop = true;

    _editor.TextChanged += (s, e) => _display.Text = _editor.Text;
    _editor.Leave      += (s, e) => EndEdit(true);
    _editor.KeyDown    += Editor_KeyDown;
}

public override string Text
{
    get => _display.Text;
    set { _display.Text = value; _editor.Text = value; Invalidate(); }
}

public ContentAlignment TextAlign
{
    get => _display.TextAlign;
    set { _display.TextAlign = value; Invalidate(); }
}

public float MaxNegativeTrackingEm
{
    get => _display.MaxNegativeTrackingEm;
    set { _display.MaxNegativeTrackingEm = value; }
}

public new Font Font
{
    get => base.Font;
    set { base.Font = value; _display.Font = value; _editor.Font = value; }
}

public new Color BackColor
{
    get => base.BackColor;
    set { base.BackColor = value; _display.BackColor = value; _editor.BackColor = value; }
}

public new Color ForeColor
{
    get => base.ForeColor;
    set { base.ForeColor = value; _display.ForeColor = value; _editor.ForeColor = value; }
}

protected override void OnEnter(EventArgs e)
{
    base.OnEnter(e);
    BeginEdit();
}

protected override void OnClick(EventArgs e)
{
    base.OnClick(e);
    BeginEdit();
}

protected override void OnResize(EventArgs e)
{
    base.OnResize(e);
    _editor.Bounds = InnerRect();
}

private void BeginEdit()
{
    _editor.Bounds = InnerRect();
    _editor.Text = _display.Text;
    _editor.Visible = true;
    _display.Visible = false;
    _editor.SelectionStart = _editor.Text?.Length ?? 0;
    _editor.Focus();
}

private void EndEdit(bool commit)
{
    if (!_editor.Visible) return;
    if (commit) Text = _editor.Text;
    _editor.Visible = false;
    _display.Visible = true;
    _display.Invalidate();
}

private void Editor_KeyDown(object sender, KeyEventArgs e)
{
    if (e.KeyCode == Keys.Escape) { EndEdit(false); e.Handled = true; }
    else if (e.KeyCode == Keys.Enter) { EndEdit(true); e.Handled = true; }
}

private Rectangle InnerRect()
{
    var rc = ClientRectangle;
    return Rectangle.Inflate(rc, -Padding.Left, -Padding.Top);
}


} 

使い方(フォームへの配置)

// フォームのコンストラクタ等
var tb = new AutoKernTextBox
{
    Location = new Point(20, 20),
    Size = new Size(280, 28),
    Font = new Font("Meiryo UI", 10f),
    MaxNegativeTrackingEm = 0.15f, // 1 文字間で最大 0.15em 詰め
    TextAlign = ContentAlignment.MiddleLeft,
    Text = "領収書番号:AB-2025-000123"
};
this.Controls.Add(tb);

なぜ TextBox を直接カスタム描画しないのか

Win32 の EDIT コントロール(TextBox の実体)はオーナードローの仕組みがありません。サブクラス化して WM_PAINT を横取りする方法もありますが、既定の描画を止められず、二重描画やキャレットとの不整合が発生しがちです。さらに IME の合成テキスト、選択反転、アクセシビリティ通知などを含むフル実装はコストが高く、保守も難しくなります。「表示は自前、編集は純正」という二層構造は、UI の一貫性と現実的な工数の最適解です。

アルゴリズム詳細とチューニング

均等配分 vs. 重み付き配分

上掲コードは 均等配分で詰めていますが、可読性向上のために下記のような重み付けも有効です。

  • 空白・ダッシュ等は詰めやすい(重み 1.5)
  • 句読点・記号はやや詰める(重み 1.2)
  • かな漢字・ラテン本文は詰めすぎない(重み 0.8)

重み合計で正規化し、欠損ピクセルを比率配分すれば OK です。概念コード:

// weights[i] に各「文字と文字の間」の重み(n-1 要素)を入れる
// perGapTotal = deficit / sum(weights)
for (int i = 0; i &lt; n; i++)
{
    int reduce = (i &lt; n - 1) ? (int)Math.Round(perGapTotal * weights[i]) : 0;
    dxOut[i] = Math.Max(1, adv[i] - reduce);
}

ハードリミット(MaxNegativeTrackingEm)

読みやすさを守るため、1 ギャップあたりの短縮量を em 単位で上限化しました。0.10~0.20emが実用的な範囲です。これ以上は可読性が崩れやすく、ClearType のサブピクセルレンダリングゆらぎも目立ちます。

測定 API の注意点

  • Graphics.MeasureString は余白やオーバーハングを含むため、ピクセル単位の詰めには不向きです。
  • TextRenderer.MeasureText は GDI ベースですが、per-glyph の advance は取れません。
    → GetTextExtentExPointW で 累積 advance を取り、差分で 1 文字ずつの advance に変換します。
  • DPI 依存を考慮し、em→px 変換は Font.SizeInPoints * Dpi / 72 で近似しています。

UI/UX ガイドライン

状況推奨挙動補足
通常時AutoKernDisplay を表示全体が枠内に収まるよう自動詰め
フォーカス/クリック時TextBox をオーバーレイして編集ユーザーは違和感なく編集可能
極端に長い文字列上限を超えたらフォント縮小・スクロールにフォールバック可読性を最優先
右寄せレイアウトTextAlign で右寄せ数値・コードの末尾合わせに有効

代替案(要件次第での現実解)

  • 水平スクロールを許容:最小工数。データ入力フィールドに向く。
  • フォントサイズの自動縮小:追い込みより視認性が安定。ただし高さも変わる。
  • 文字の横方向スケール:Graphics.ScaleTransform(sx,1) で横縮小。間隔ではなく字形自体が歪む点に注意。
  • WPF の使用:不可条件であれば除外。将来的に UI 刷新時の選択肢としては有力。

制約・既知の留意点

  • 複数行/折返し:本記事コードは単一行前提。複数行でやるなら、改行ごとに行幅合わせロジックを回す必要があります。
  • サロゲート/合成文字:GDI の計測は UTF-16 の「char」単位です。絵文字や結合文字列の扱いは完全ではありません。
  • RightToLeft:右→左言語では起点やカーソル遷移の仕様が異なります。描画 origin と配列の並びに注意してください。
  • ClearType:1px 単位で間隔を変更するとサブピクセルのにじみが変わることがあります。過度な詰めは避けましょう。
  • 印刷:画面と印刷の DPI が異なります。印刷は別の GDI/HDC で再計測してください。

パフォーマンスのヒント

  • 差分再描画:Text と Size が変わったときのみ Invalidate()。
  • 短い文字列では O(n) です。領収書番号・ID・コード等の 10~40 文字程度なら十分に軽量です。
  • キャッシュ:同一テキスト/フォント/幅の組み合わせで dxOut をキャッシュすると連続レイアウトで効きます。

テスト観点チェックリスト

  • 最小幅で 1 文字がつぶれない(advance >= 1)
  • 日本語/英数字混在、句読点・カッコの前後で字詰めしすぎない
  • フォーカスの入り/抜けでテキストが欠落しない
  • 高 DPI(150%, 200%)で描画が崩れない
  • ダークテーマで前景・背景色が想定通り

実務 Tips

  • 数値の桁合わせ:右寄せで末尾が揃うようにし、間隔調整で「見切れ」を防止。
  • ラベル風用途:入力不可の固定表示(検索結果ヘッダー等)なら AutoKernDisplay 単体で十分。
  • 入力欄:編集が主目的なら「編集時はスクロール、確定後は詰めて表示」のハイブリッドが UX と保守のバランスが良好。

FAQ

Q. TrueType のカーニングペアは使っていますか?
A. ここでは 「フォントのカーニング」ではなく「送り量の制御」で狭めています。フォント内部のカーニング情報は GDI の組版に依存しますが、lpDx による advance 指定が最終結果を決めるため、求める見た目を得やすいのが利点です。

Q. 文字間だけで足りないときは?
A. MaxNegativeTrackingEm で上限を守り、超えた分は フォント縮小・スクロール・省略記号などにフォールバックしてください。可読性を崩さないのが最優先です。

Q. 高速入力時に表示がガタつく
A. 描画が高頻度になるケースは、編集中は標準 TextBox のスクロールを使い、確定後だけ詰める運用が安定します。

まとめ

WinForms の TextBox 自体に可変カーニングはありませんが、GDI の lpDx を使った自前描画で「はみ出したら自動で詰めて収める」ことは可能です。編集面は標準 TextBox に委譲する二層構造にすることで、IME・選択・アクセシビリティを含む複雑さを回避しつつ、要件と UX を両立できます。本文の AutoKernDisplay と AutoKernTextBox をベースに、アプリのドメインに合わせて重み付け・上限値・フォールバック戦略を調整してみてください。


実装チートシート

項目推奨値/例メモ
測定 APIGetTextExtentExPointW累積 advance を取得
描画 APIExtTextOutW(lpDx 指定)1 文字ごと advance を渡す
最大詰め量0.10~0.20em可読性優先で上限化
対象単一行複数行は行ごとに同処理が必要
編集 UXオーバーレイ TextBoxIME/選択は純正挙動
配置TextAlign で左/中/右数字系は右寄せ推奨

拡張アイデア(必要に応じて)

  • 句読点・空白優先の重み付き詰め:読みやすさ向上。
  • 省略記号との併用:… で末尾を省略しつつ、残りを軽く詰める。
  • モノスペース最適化:コードや ID 表示は固定幅フォント+軽い詰めで視認性が上がる。
  • アクセシビリティ:フォーカス移動時に編集モードへ自動遷移し、説明テキスト(ScreenReader)を付与。

サンプル:重み付き配分関数(応用)

日本語と英数字が混在するケース向けには、以下のような簡易重み関数を用意して可読性を保つのが効果的です。

private static double GapWeight(char left, char right)
{
    // 空白・ダッシュは詰めやすい
    if (char.IsWhiteSpace(left) || char.IsWhiteSpace(right)) return 1.6;
    if ("-–—~".IndexOf(left) >= 0 || "-–—~".IndexOf(right) >= 0) return 1.4;


// 句読点・カッコ類はやや詰める
const string punct = "、。.,;:)]}〉》」』】>>)";
if (punct.IndexOf(left) >= 0 || punct.IndexOf(right) >= 0) return 1.2;

// 絵文字などは不可侵
if (char.GetUnicodeCategory(left) == System.Globalization.UnicodeCategory.OtherSymbol ||
    char.GetUnicodeCategory(right) == System.Globalization.UnicodeCategory.OtherSymbol) return 0.6;

// 既定
return 1.0;


} 

この重みを deficit の配分に使うだけで、均等配分より自然な見栄えになります。

デバッグのコツ

  • advance の可視化:デバッグビルドでは各文字の境界線を e.Graphics.DrawLine で描いてチェック。
  • 端数処理:float→int の丸め誤差は端から 1px ずつ回して均すと収まりが良い(上掲コード参照)。
  • 異フォント:Meiryo UI と Yu Gothic UI で advance の癖が違うため、上限値の調整を忘れずに。

実装を採用すべき場面・避けるべき場面

採用が有効避けるべき
領収書番号・伝票番号・ID・短いタイトルなど「単一行で枠内に収めたい」要素長文本文、可読性が最優先の段落テキスト
固定サイズのダッシュボード・カード UIアクセシビリティ要件が厳しく、文字間の一貫性を保つ必要がある場面
横幅が頻繁に変わるレスポンシブ風レイアウト複数行・自動折返し・字下げなど組版が複雑なコンテンツ

最後に

WinForms でも「はみ出したら自動で詰める」は実現できます。鍵は TextBox を直接いじらず、描画は GDI・編集は純正という責務分離です。まずはサンプルをプロジェクトに組み込み、MaxNegativeTrackingEm を 0.10~0.20 の範囲で試し、現場のデータに合わせて配分ロジックを微調整してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次