単語のワードブレークをしないワードラップの座標を計算して文字を描画する - C#

単語のワードブレークをしないワードラップの座標を計算して文字を描画するコードを紹介します。

概要

こちらの記事では文字ごとの座標値を計算してワードラップされた文字列を描画するコードを紹介しました。
紹介したコードでワードラップされた文字列を描画できますが、英単語が途中で切れてワードラップされて描画されます。 この記事では、単語を分割せずにワードラップされた文字列を描画するコードを紹介します。

ロジック

次の手順でワードラップ文字列を描画します。
  1. 描画する文字列を GetTextExtentExPoint() 関数で文字ごとの座標値を求めます
  2. 描画する文字列を単語ごとに分割します。
  3. 単語の長さを確認し、横幅に収まる場合は文字列を描画します。
  4. 描画位置を右にずらし、次の単語の長さを確認し、横幅に収まる場合は文字列を描画します。
  5. 横幅からあふれる場合は、描画位置のY座標を加算し、X座標を左端に設定します。
  6. 単語の長さを確認し、横幅に収まる場合は文字列を描画します。
  7. 横幅に収まらない場合は、単語を途中で分割し単語を描画します。
  8. 3.に戻ります
  9. すべての文字列を描画したら処理を終了します。

プログラム例

UI

下図のフォームを作成します。

コード

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

namespace DrawStringWordWrap
{
  public partial class FormWordWrapCalcDrawTextRendererWord : Form
  {
    [StructLayout(LayoutKind.Sequential)]
    public struct SIZE
    {
      public int cx;
      public int cy;

      public SIZE(int cx, int cy)
      {
        this.cx = cx;
        this.cy = cy;
      }
    }

    [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", EntryPoint = "GetTextExtentExPointW")]
    static extern bool GetTextExtentExPoint(IntPtr hdc, [MarshalAs(UnmanagedType.LPWStr)] string lpszStr,
      int cchString, int nMaxExtent, out int lpnFit, int[] alpDx, out SIZE lpSize);

    private class WordInfo
    {
      public string Text;
      public int[] Dx;
    }

    public FormWordWrapCalcDrawTextRendererWord()
    {
      InitializeComponent();
    }

    private void panel1_Paint(object sender, PaintEventArgs e)
    {
      Font f = new Font("Yu Gothic UI", 12);
      string DocText = "If you eat a penguin cookie(ぺんぎんクッキー), everyone will have a Happy Time!! TottemonagaiTangodesuyoTottemoTottemo.";

      int areaLeft = 64;
      int areaTop = 32;
      int areaWidth = 140;
      int areaHeight = 180;
      Pen p = new Pen(Color.FromArgb(0x45, 0x72, 0xAA));
      Rectangle r = new Rectangle(areaLeft, areaTop, areaWidth, areaHeight);

      e.Graphics.DrawRectangle(p, r);

      IntPtr hDC = e.Graphics.GetHdc();
      int[] Dx = new int[DocText.Length];
      int FitCount;
      SIZE sz;

      IntPtr oldfnt = SelectObject(hDC, f.ToHfont());
      GetTextExtentExPoint(hDC, DocText, DocText.Length, 1024, out FitCount, Dx, out sz);
      IntPtr cfont = SelectObject(hDC, oldfnt);
      DeleteObject(cfont);

      e.Graphics.ReleaseHdc();

      List<WordInfo> WordList = new List<WordInfo>();

      string Pattern = @"[A-Za-z0-9=_`~@#&*{;:'""\^\+\(\[]*[ \?\-\)\]}!%>]?";
      Regex reg = new Regex(Pattern);

      int currentPos = 0;
      int currentCharPos = 0;
      Match m = reg.Match(DocText);

      while (m.Success == true) {
        if (currentPos < m.Index) {
          if (0 < m.Index - currentPos) {
            WordInfo wi = new WordInfo();
            wi.Text = DocText.Substring(currentPos, m.Index - currentPos);
            wi.Dx = new int[m.Index - currentPos];
            for (int i = 0; i < wi.Dx.Length; i++) {
              wi.Dx[i] = Dx[currentPos + i] - currentCharPos;
            }
            WordList.Add(wi);
          }
        }
        if (m.Length == 0) {
          WordInfo wi = new WordInfo();
          wi.Text = (DocText.Substring(m.Index, 1));
          wi.Dx = new int[1];
          wi.Dx[0] = Dx[currentPos] - currentCharPos;
          WordList.Add(wi);

          currentPos++;
          currentCharPos = Dx[currentPos - 1];
        }
        else if (0 < m.Length) {
          WordInfo wi = new WordInfo();
          wi.Text = (DocText.Substring(m.Index, m.Length));
          wi.Dx = new int[m.Length];
          for (int i = 0; i < wi.Dx.Length; i++) {
            wi.Dx[i] = Dx[currentPos + i] - currentCharPos;
          }
          WordList.Add(wi);

          currentPos = m.Index + m.Length;
          currentCharPos = Dx[m.Index + m.Length - 1];
        }

        if (DocText.Length <= currentPos) break; 
        m = m.NextMatch();
      }

      for (int i = 0; i < WordList.Count; i++) {
        textBox1.Text += WordList[i].Text;
        textBox1.Text += " - ";
        for (int j = 0; j < WordList[i].Dx.Length; j++) {
          textBox1.Text += string.Format("{0:d},", WordList[i].Dx[j]);
        }
        textBox1.Text += "\r\n";
      }

      int posx = areaLeft;
      int posy = areaTop;
      for (int i = 0; i < WordList.Count; i++) {
        if (posx + WordList[i].Dx[WordList[i].Dx.Length - 1] < areaLeft + areaWidth) {
          TextRenderer.DrawText(e.Graphics, WordList[i].Text, f, new Rectangle(posx, posy, areaWidth, sz.cy),
            Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
          posx += WordList[i].Dx[WordList[i].Dx.Length - 1];
        }
        else {
          posy += sz.cy;
          posx = areaLeft;
          if (posx + WordList[i].Dx[WordList[i].Dx.Length - 1] < areaLeft + areaWidth) {
            TextRenderer.DrawText(e.Graphics, WordList[i].Text, f, new Rectangle(posx, posy, areaWidth, sz.cy),
              Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
            posx += WordList[i].Dx[WordList[i].Dx.Length - 1];
          }
          else {
            int currentDx = 0;
            int currentIndex = 0;
            for (int j = 0; j < WordList[i].Dx.Length; j++) {
              if (areaLeft + areaWidth < posx + (WordList[i].Dx[j] - currentDx)) {
                string drawText = WordList[i].Text.Substring(currentIndex, j - 1 - currentIndex);
                TextRenderer.DrawText(e.Graphics, drawText, f, new Rectangle(posx, posy, areaWidth, sz.cy),
                  Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
                posy += sz.cy;
                posx = areaLeft;
                currentIndex = j - 1;
                currentDx = WordList[i].Dx[j-2];
              }
            }
            TextRenderer.DrawText(e.Graphics, WordList[i].Text.Substring(currentIndex), f, new Rectangle(posx, posy, areaWidth, sz.cy),
               Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
            posx += WordList[i].Dx[WordList[i].Dx.Length-1] - currentDx;
          }
        }
      }
    }
  }
}

解説

描画に使用するフォントを作成します。
  Font f = new Font("Yu Gothic UI", 12);

画面に描画するテキストを準備します。
  string DocText = "If you eat a penguin cookie(ぺんぎんクッキー), everyone will have a Happy Time!! TottemonagaiTangodesuyoTottemoTottemo.";

テキストが描画される枠を描画します。
  int areaLeft = 64;
  int areaTop = 32;
  int areaWidth = 140;
  int areaHeight = 180;
  Pen p = new Pen(Color.FromArgb(0x45, 0x72, 0xAA));
  Rectangle r = new Rectangle(areaLeft, areaTop, areaWidth, areaHeight);

  e.Graphics.DrawRectangle(p, r);

デバイスコンテキストのハンドルを取得し、SelectObject関数で描画フォントをデバイスコンテキスト設定します。 フォントの設定後にGetTextExtentExPoint()関数を呼び出し、文字の座標値を求めます。
GetTextExtentExPoint()関数を呼び出し後に、フォントを戻し、DeleteObjectでフォントを削除します。
  IntPtr hDC = e.Graphics.GetHdc();
  int[] Dx = new int[DocText.Length];
  int FitCount;
  SIZE sz;

  IntPtr oldfnt = SelectObject(hDC, f.ToHfont());
  GetTextExtentExPoint(hDC, DocText, DocText.Length, 1024, out FitCount, Dx, out sz);
  IntPtr cfont = SelectObject(hDC, oldfnt);
  DeleteObject(cfont);

  e.Graphics.ReleaseHdc();

正規表現式を利用して単語ごとに分割します。正規表現は次の式を利用します。
[A-Za-z0-9=_`~@#&*{;:'""\^\+\(\[]*[ \?\-\)\]}!%>]?

  List<WordInfo> WordList = new List<WordInfo>();

  string Pattern = @"[A-Za-z0-9=_`~@#&*{;:'""\^\+\(\[]*[ \?\-\)\]}!%>]?";
  Regex reg = new Regex(Pattern);

  int currentPos = 0;
  int currentCharPos = 0;
  Match m = reg.Match(DocText);

NextMatch() メソッドを繰り返し呼び出し、単語ごとに分割します。
単語はWordListリストに追加します。単語内の文字の座標をDx配列に設定します。
  while (m.Success == true) {
    if (currentPos < m.Index) {
      if (0 < m.Index - currentPos) {
        WordInfo wi = new WordInfo();
        wi.Text = DocText.Substring(currentPos, m.Index - currentPos);
        wi.Dx = new int[m.Index - currentPos];
        for (int i = 0; i < wi.Dx.Length; i++) {
          wi.Dx[i] = Dx[currentPos + i] - currentCharPos;
        }
        WordList.Add(wi);
      }
    }
    if (m.Length == 0) {
      WordInfo wi = new WordInfo();
      wi.Text = (DocText.Substring(m.Index, 1));
      wi.Dx = new int[1];
      wi.Dx[0] = Dx[currentPos] - currentCharPos;
      WordList.Add(wi);

      currentPos++;
      currentCharPos = Dx[currentPos - 1];
    }
    else if (0 < m.Length) {
      WordInfo wi = new WordInfo();
      wi.Text = (DocText.Substring(m.Index, m.Length));
      wi.Dx = new int[m.Length];
      for (int i = 0; i < wi.Dx.Length; i++) {
        wi.Dx[i] = Dx[currentPos + i] - currentCharPos;
      }
      WordList.Add(wi);

      currentPos = m.Index + m.Length;
      currentCharPos = Dx[m.Index + m.Length - 1];
    }

    if (DocText.Length <= currentPos) break; 
    m = m.NextMatch();
  }

テキストボックスにWordListリストの値を表示します。分割された単語と、その単語の文字ごとの座標値をテキストボックスに表示します。
  for (int i = 0; i < WordList.Count; i++) {
    textBox1.Text += WordList[i].Text;
    textBox1.Text += " - ";
    for (int j = 0; j < WordList[i].Dx.Length; j++) {
      textBox1.Text += string.Format("{0:d},", WordList[i].Dx[j]);
    }
    textBox1.Text += "\r\n";
  }

単語のリストWordList の要素を取り出し画面に描画します。
単語がテキスト領域に収まる場合は描画し、テキスト領域からあふれる場合は改行して、 次の行に描画します。改行しても単語の長さがテキスト領域の幅より大きい場合は単語の途中で分割して、 単語の文字列を描画します。

  int posx = areaLeft;
  int posy = areaTop;
  for (int i = 0; i < WordList.Count; i++) {
    if (posx + WordList[i].Dx[WordList[i].Dx.Length - 1] < areaLeft + areaWidth) {
      TextRenderer.DrawText(e.Graphics, WordList[i].Text, f, new Rectangle(posx, posy, areaWidth, sz.cy),
        Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
      posx += WordList[i].Dx[WordList[i].Dx.Length - 1];
    }
    else {
      posy += sz.cy;
      posx = areaLeft;
      if (posx + WordList[i].Dx[WordList[i].Dx.Length - 1] < areaLeft + areaWidth) {
        TextRenderer.DrawText(e.Graphics, WordList[i].Text, f, new Rectangle(posx, posy, areaWidth, sz.cy),
          Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
        posx += WordList[i].Dx[WordList[i].Dx.Length - 1];
      }
      else {
        int currentDx = 0;
        int currentIndex = 0;
        for (int j = 0; j < WordList[i].Dx.Length; j++) {
          if (areaLeft + areaWidth < posx + (WordList[i].Dx[j] - currentDx)) {
            string drawText = WordList[i].Text.Substring(currentIndex, j - 1 - currentIndex);
            TextRenderer.DrawText(e.Graphics, drawText, f, new Rectangle(posx, posy, areaWidth, sz.cy),
              Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
            posy += sz.cy;
            posx = areaLeft;
            currentIndex = j - 1;
            currentDx = WordList[i].Dx[j-2];
          }
        }
        TextRenderer.DrawText(e.Graphics, WordList[i].Text.Substring(currentIndex), f, new Rectangle(posx, posy, areaWidth, sz.cy),
           Color.Black, TextFormatFlags.Default | TextFormatFlags.NoPadding);
        posx += WordList[i].Dx[WordList[i].Dx.Length-1] - currentDx;
      }
    }
  }

実行結果

実行します。下図の画面が表示されます。長い単語以外は、単語の途中が分割されない形式でワードラップのテキストが描画できています。
画面の右側には分割された単語の一覧と、単語の座標位置の値が表示されています。英単語は単語ごとに分割され、 日本語の文字は1文字1単語に分割されている状況が確認できます。


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