文字列をまとめて描画した場合と一文字ずつ文字を描画した場合で長さが異なる - C#

文字列をまとめて描画した場合と一文字ずつ文字を描画した場合で長さが異なる現象と対処法を紹介します。

概要

Graphics.DrawString()メソッドで文字列の描画ができますが、DrawStringメソッドで文字列をまとめて描画した場合と、 DrawString メソッドで1文字ずつ描画した場合で文字列の表示悔過が違う場合があります。
原因はフォントの幅の求め方による違いです。この記事では、文字列をまとめて描画した場合と一文字ずつ文字を描画した場合でどのように表示が異なるかを 確認し、対処法のコードを紹介します。

現象の確認

次のプログラムを準備します。

UI

下図のフォームを準備します。Panelを1つ配置します。

コード

以下のコードを記述します。
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace KerningDemo
{
  public partial class FormTextDraw1 : Form
  {
    public FormTextDraw1()
    {
      InitializeComponent();
    }

    private void panel1_Paint(object sender, PaintEventArgs e)
    {
      string DrawText = "ぺんぎんクッキー";
      Font f = new Font("メイリオ",28);
      Brush b = new SolidBrush(Color.Black);
      e.Graphics.TextRenderingHint = TextRenderingHint.AntiAlias;

      e.Graphics.DrawString(DrawText, f,b,0,10);

      int xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        e.Graphics.DrawString(DrawText.Substring(i,1), f, b, xpos, 70);
        xpos = xpos + (int)e.Graphics.MeasureString(DrawText.Substring(i, 1),f).Width;
      }

      xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {        
        e.Graphics.DrawString(DrawText.Substring(i, 1), f, b, xpos, 130);
        xpos = xpos + (int)TextRenderer.MeasureText(e.Graphics, DrawText.Substring(i, 1), f).Width;
      }

      xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        e.Graphics.DrawString(DrawText.Substring(i, 1), f, b, xpos, 190);
        Rectangle rect = new Rectangle(xpos, 130, 200, 200);

        StringFormat sf = new StringFormat();
        CharacterRange[] characterRanges = { new CharacterRange(0, 1) };
        sf.SetMeasurableCharacterRanges(characterRanges);

        Region[] r = e.Graphics.MeasureCharacterRanges(DrawText.Substring(i, 1), f, rect, sf);
        xpos = xpos + (int)r[0].GetBounds(e.Graphics).Width;
      }

    }
  }
}

解説

画面に描画する文字列やフォント、ブラシを作成して準備します。
  string DrawText = "ぺんぎんクッキー";
  Font f = new Font("メイリオ",28);
  Brush b = new SolidBrush(Color.Black);

DrawStringメソッドを呼び出して画面に文字列を描画します。描画位置は、(x,y)=(0,10) を基準にします。
  e.Graphics.DrawString(DrawText, f,b,0,10);

60ピクセル下げた位置(x,y)=(0,70)から、同じ文字列を描画します。こちらは、DrawStringメソッドを利用して描画しますが、 一文字ずつ画面に描画します。次の文字の位置は、MeasureString メソッドを呼び出して描画した文字の幅を取得して、x座標に足しこんでいます。
  int xpos = 0;
  for (int i = 0; i < DrawText.Length; i++)
  {
    e.Graphics.DrawString(DrawText.Substring(i,1), f, b, xpos, 70);
    xpos = xpos + (int)e.Graphics.MeasureString(DrawText.Substring(i, 1),f).Width;
  }

さらに、60ピクセル下げた位置(x,y)=(0,130)から、同じ文字列を描画します。 こちらも、DrawStringメソッドを利用して一文字ずつ描画しますが、文字の幅を TextRenderer.MeasureText メソッドを利用して取得します。
  xpos = 0;
  for (int i = 0; i < DrawText.Length; i++)
  {        
    e.Graphics.DrawString(DrawText.Substring(i, 1), f, b, xpos, 130);
    xpos = xpos + (int)TextRenderer.MeasureText(e.Graphics, DrawText.Substring(i, 1), f).Width;
  }

さらに、60ピクセル下げた位置(x,y)=(0,190)から、同じ文字列を描画します。 こちらも、DrawStringメソッドを利用して一文字ずつ描画しますが、文字の幅を MeasureCharacterRanges メソッドを利用して取得します。
  xpos = 0;
  for (int i = 0; i < DrawText.Length; i++)
  {
    e.Graphics.DrawString(DrawText.Substring(i, 1), f, b, xpos, 190);
    Rectangle rect = new Rectangle(xpos, 130, 200, 200);

    StringFormat sf = new StringFormat();
    CharacterRange[] characterRanges = { new CharacterRange(0, 1) };
    sf.SetMeasurableCharacterRanges(characterRanges);

    Region[] r = e.Graphics.MeasureCharacterRanges(DrawText.Substring(i, 1), f, rect, sf);
    xpos = xpos + (int)r[0].GetBounds(e.Graphics).Width;
  }

実行結果

実行すると下図の画面が表示されます。


比較してみます。
MeasureStringを使用した場合は文字の間隔が開いてしまいます。これは、前後の余白が含まれている値が返るため、このような動作になります。 TextRenderer.MeasureTextの場合も同様で、こちらも前後の余白が含まれています。MeasureStringよりもさらに大きい値が返ります。
MeasureCharacterRanges メソッドを利用した場合は、余白が含まれないため、まとめて描画した状態と近い結果になりますが、 一括で描画したよりも文字の幅がわずかに短くなります。通常の文字の描画の利用では問題は出ませんが、同じ位置に重ねて描画する場合には、 文字の描画位置がずれるため、問題になる場合があります。

GetTextExtentPoint32 を利用した場合の結果

文字の幅を求める部分が違うため、ずれが出てしまっていると推測したため、 続いて、GetTextExtentPoint32 を利用した場合の動作を確認します。以下のコードを記述します。

コード

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Text;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace KerningDemo
{
  [StructLayout(LayoutKind.Sequential)]
  public struct LPSIZE
  {
    public int cx;
    public int cy;
  }


  public partial class FormTextDraw2 : Form
  {
    [DllImport("gdi32.dll", CharSet = CharSet.Unicode)]
    public static extern bool GetTextExtentPoint32(IntPtr hdc, string lpString, int cbString, out LPSIZE lpSize);

    [DllImport("gdi32.dll", EntryPoint = "DeleteObject")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool DeleteObject([In] IntPtr hObject);

    [DllImport("gdi32.dll", EntryPoint = "SelectObject")]
    public static extern IntPtr SelectObject([In] IntPtr hdc, [In] IntPtr hgdiobj);


    public FormTextDraw2()
    {
      InitializeComponent();
    }

    private void panel1_Paint(object sender, PaintEventArgs e)
    {
      string DrawText = "ぺんぎんクッキー";
      Font f = new Font("メイリオ", 28);
      Brush b = new SolidBrush(Color.Black);
      e.Graphics.TextRenderingHint = TextRenderingHint.AntiAlias;

      e.Graphics.DrawString(DrawText, f, b, 0, 10);

      int xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        e.Graphics.DrawString(DrawText.Substring(i, 1), f, b, xpos, 70);
        Rectangle rect = new Rectangle(xpos, 70, 200, 200);

        StringFormat sf = new StringFormat();
        CharacterRange[] characterRanges = { new CharacterRange(0, 1) };
        sf.SetMeasurableCharacterRanges(characterRanges);

        Region[] r = e.Graphics.MeasureCharacterRanges(DrawText.Substring(i, 1), f, rect, sf);
        xpos = xpos + (int)r[0].GetBounds(e.Graphics).Width;
      }

      xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        e.Graphics.DrawString(DrawText.Substring(i, 1), f, b, xpos, 130);
        IntPtr hDC = e.Graphics.GetHdc();
        xpos = xpos + GetTextWidth(DrawText.Substring(i, 1), 1, hDC, f);
        e.Graphics.ReleaseHdc(hDC);
      }

    }

    private int GetTextWidth(string text, int SourceLength, IntPtr hdc, Font f)
    {
      LPSIZE textSize;
      IntPtr oldfnt = SelectObject(hdc, f.ToHfont());
      GetTextExtentPoint32(hdc, text, SourceLength, out textSize);
      DeleteObject(SelectObject(hdc, oldfnt));
      return textSize.cx;
    }
  }
}

解説

Windows APIのGetTextExtentPoint32を利用して文字の幅を求めるコードを記述します。
    private int GetTextWidth(string text, int SourceLength, IntPtr hdc, Font f)
    {
      LPSIZE textSize;
      IntPtr oldfnt = SelectObject(hdc, f.ToHfont());
      GetTextExtentPoint32(hdc, text, SourceLength, out textSize);
      DeleteObject(SelectObject(hdc, oldfnt));
      return textSize.cx;
    }

文字の描画は上から次のようになります。
  • DrawStringによる一括描画
  • SetMeasurableCharacterRanges を利用して文字幅を求め一文字ずつ描画
  • GetTextExtentPoint32 を利用して文字幅を求め一文字ずつ描画

実行結果

実行すると下図の画面が表示されます。


比較してみます。
GetTextExtentPoint32 を使用した場合でも、DrawStringでまとめて描画した場合と異なる結果になり、 文字列の長さが短くなります。MeasureStringを使用した場合は文字の間隔が開いてしまいます。これは、前後の余白が含まれている値が返るため、このような動作になります。MeasureCharacterRanges を利用した場合の結果とも異なり、 GetTextExtentPoint32 では、MeasureCharacterRanges より文字の間隔がさらに狭くなる結果となりました。

TextRenderer を利用した場合の結果

DrawString() では難しそうなため、TextRenderer を利用する方法に変更してみます。

UI

先のフォームと同様にPanelを1つ配置します。
以下のコードを記述します。

コード

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Text;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace KerningDemo
{
  public partial class FormTextDraw3 : Form
  {
    public FormTextDraw3()
    {
      InitializeComponent();
    }

    private void panel1_Paint(object sender, PaintEventArgs e)
    {
      string DrawText = "ぺんぎんクッキー";
      Font f = new Font("メイリオ", 28);
      Brush b = new SolidBrush(Color.Black);
      e.Graphics.TextRenderingHint = TextRenderingHint.AntiAlias;

      TextRenderer.DrawText(e.Graphics, DrawText, f,new Point(0, 10), Color.Black);

      int xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        TextRenderer.DrawText(e.Graphics, DrawText.Substring(i,1), f, new Point(xpos, 70), Color.Black);
        xpos += TextRenderer.MeasureText(e.Graphics, DrawText.Substring(i, 1), f).Width;
      }

      xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        TextRenderer.DrawText(e.Graphics, DrawText.Substring(i, 1), f, new Point(xpos, 130), Color.Black);
        xpos += TextRenderer.MeasureText(e.Graphics, DrawText.Substring(i, 1), f, new Size(100,100),TextFormatFlags.NoPadding).Width;
      }

    }
  }
}

解説

文字の描画は上から次のようになります。
  • TextRenderer.DrawText メソッドによる一括描画
  • SetMeasurableCharacterRanges を利用して文字幅を求めTextRenderer.DrawText メソッドで一文字ずつ描画、文字幅はTextRenderer.MeasureTextで取得
  • SetMeasurableCharacterRanges を利用して文字幅を求めTextRenderer.DrawText メソッドで一文字ずつ描画、文字幅はTextRenderer.MeasureTextでTextFormatFlags.NoPadding オプションを指定して取得

実行結果

プロジェクトを実行します。下図の画面が表示されます。


比較してみます。オプションを指定せずに、TextRenderer.MeasureText で文字幅を取得した場合は前後に余白が入るため、文字間隔があいてしまいます。一方、TextFormatFlags.NoPadding オプションを指定して TextRenderer.MeasureText で文字幅を取得した場合は、一括で描画した場合と同じ結果になります。この設定により、一文字ずつ描画した場合とまとめて描画した場合で全く同じ結果となりました。

ExtTextOut を利用した場合の結果

ExtTextOutを利用した動作も確認します。

UI

先のフォームと同様にPanelを1つ配置します。

コード

以下のコードを記述します。
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace KerningDemo
{
  public partial class FormTextDraw4 : Form
  {
    [DllImport("gdi32.dll", EntryPoint = "SelectObject")]
    public static extern IntPtr SelectObject([In] IntPtr hdc, [In] IntPtr hgdiobj);

    [DllImport("gdi32.dll", EntryPoint = "DeleteObject")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool DeleteObject([In] IntPtr hObject);


    [DllImport("gdi32.dll")]
    public static extern int SetBkMode(IntPtr hdc, int iBkMode);

    public enum BkMode
    {
      TRANSPARENT = 1,
      OPAQUE = 2
    }

    [DllImport("gdi32.dll")]
    public static extern uint SetTextColor(IntPtr hdc, int crColor);

    [Flags]
    public enum ETOOptions : uint
    {
      ETO_CLIPPED = 0x4,
      ETO_GLYPH_INDEX = 0x10,
      ETO_IGNORELANGUAGE = 0x1000,
      ETO_NUMERICSLATIN = 0x800,
      ETO_NUMERICSLOCAL = 0x400,
      ETO_OPAQUE = 0x2,
      ETO_PDY = 0x2000,
      ETO_RTLREADING = 0x800,
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct RECT
    {
      public int _Left;
      public int _Top;
      public int _Right;
      public int _Bottom;
    }

    [DllImport("gdi32.dll", EntryPoint = "ExtTextOutW")]
    public static extern bool ExtTextOut(IntPtr hdc, int X, int Y, uint fuOptions,
    [In] ref RECT lprc, [MarshalAs(UnmanagedType.LPWStr)] string lpString, uint cbCount, [In] IntPtr lpDx);

    [DllImport("gdi32.dll", CharSet = CharSet.Unicode)]
    public static extern bool GetTextExtentPoint32(IntPtr hdc, string lpString, int cbString, out LPSIZE lpSize);


    public FormTextDraw4()
    {
      InitializeComponent();
    }

    private void panel1_Paint(object sender, PaintEventArgs e)
    {
      string DrawText = "ぺんぎんクッキー";
      Font f = new Font("メイリオ", 28);
      
      RECT rect;
      rect._Top = 0;
      rect._Left = 0;
      rect._Bottom = 1000;
      rect._Right = 1000;

      IntPtr hDC = e.Graphics.GetHdc();

      SetBkMode(hDC, (int)BkMode.TRANSPARENT);
      SetTextColor(hDC, ColorTranslator.ToWin32(Color.Black));
      IntPtr oldfnt = SelectObject(hDC, f.ToHfont());
      ExtTextOut(hDC, 0, 10, (uint)ETOOptions.ETO_CLIPPED, ref rect, DrawText, (uint)DrawText.Length, IntPtr.Zero);
      DeleteObject(SelectObject(hDC, oldfnt));

      e.Graphics.ReleaseHdc(hDC);


      //
      hDC = e.Graphics.GetHdc();
      SetBkMode(hDC, (int)BkMode.TRANSPARENT);
      SetTextColor(hDC, ColorTranslator.ToWin32(Color.Black));
      oldfnt = SelectObject(hDC, f.ToHfont());

      int xpos = 0;
      for (int i = 0; i < DrawText.Length; i++)
      {
        ExtTextOut(hDC, xpos, 70, (uint)ETOOptions.ETO_CLIPPED, ref rect, DrawText.Substring(i,1), 1, IntPtr.Zero);
        xpos += GetTextWidth(DrawText.Substring(i, 1), 1, hDC, f);
      }

      DeleteObject(SelectObject(hDC, oldfnt));
      e.Graphics.ReleaseHdc(hDC);

    }

    private int GetTextWidth(string text, int SourceLength, IntPtr hdc, Font f)
    {
      LPSIZE textSize;
      IntPtr oldfnt = SelectObject(hdc, f.ToHfont());
      GetTextExtentPoint32(hdc, text, SourceLength, out textSize);
      DeleteObject(SelectObject(hdc, oldfnt));
      return textSize.cx;
    }
  }
}

解説

文字の描画は上から次のようになります。
  • ExtTextOut 関数による一括描画
  • ExtTextOut 関数で一文字ずつ描画、文字幅はGetTextExtentPoint32 関数で取得

実行結果

プロジェクトを実行します。下図のウィンドウが表示されます。
ExtTextOutで文字列を描画した場合は、文字列の幅をGetTextExtentPoint32 関数で取得すれば、 一括して描画した状態と同じ描画ができます。


文字列をまとめて描画した場合と一文字ずつ文字を描画した場合で同じ描画結果にできました。

補足
なお、この記事で紹介している対処コードを利用しても、Yu Gothic UIフォントで描画処理を実行すると、 文字列をまとめて描画した場合と、1文字ずつ描画した場合の文字間隔が異なる結果になります。 これは、フォントの「プロポーショナル メトリクス」機能による影響です。
「プロポーショナル メトリクス」に対応して一文字ずつ出力する方法については こちらの記事を参照して下さい。

著者
iPentecのメインプログラマー
C#, ASP.NET の開発がメイン、少し前まではDelphiを愛用
掲載日: 2023-01-05
iPentec all rights reserverd.