File: System\Windows\Forms\Rendering\TextExtensions.cs
Web Access
Project: src\src\System.Windows.Forms\src\System.Windows.Forms.csproj (System.Windows.Forms)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Drawing;
using System.Windows.Forms.Internal;
 
namespace System.Windows.Forms;
 
internal static class TextExtensions
{
    // The value of the ItalicPaddingFactor comes from several tests using different fonts & drawing
    // flags and some benchmarking with GDI+.
    private const float ItalicPaddingFactor = 1 / 2f;
 
    // Used to clear TextRenderer specific flags from TextFormatFlags
    internal const int GdiUnsupportedFlagMask = unchecked((int)0xFF000000);
 
    [Conditional("DEBUG")]
    private static void ValidateFlags(DRAW_TEXT_FORMAT flags)
    {
        Debug.Assert(((uint)flags & GdiUnsupportedFlagMask) == 0,
            "Some custom flags were left over and are not GDI compliant!");
    }
 
    private static (DRAW_TEXT_FORMAT Flags, TextPaddingOptions Padding) SplitTextFormatFlags(TextFormatFlags flags)
    {
        if (((uint)flags & GdiUnsupportedFlagMask) == 0)
        {
            return ((DRAW_TEXT_FORMAT)flags, TextPaddingOptions.GlyphOverhangPadding);
        }
 
        // Clear TextRenderer custom flags.
        DRAW_TEXT_FORMAT windowsGraphicsSupportedFlags = (DRAW_TEXT_FORMAT)((uint)flags & ~GdiUnsupportedFlagMask);
 
        TextPaddingOptions padding = flags.HasFlag(TextFormatFlags.LeftAndRightPadding)
            ? TextPaddingOptions.LeftAndRightPadding
            : flags.HasFlag(TextFormatFlags.NoPadding)
                ? TextPaddingOptions.NoPadding
                : TextPaddingOptions.GlyphOverhangPadding;
 
        return (windowsGraphicsSupportedFlags, padding);
    }
 
    /// <summary>
    ///  Draws the given <paramref name="text"/> text in the given <paramref name="hdc"/>.
    /// </summary>
    /// <param name="backColor">If <see cref="Color.Empty"/>, the hdc current background color is used.</param>
    /// <param name="foreColor">If <see cref="Color.Empty"/>, the hdc current foreground color is used.</param>
    public static unsafe void DrawText(
        this HDC hdc,
        ReadOnlySpan<char> text,
        FontCache.Scope font,
        Rectangle bounds,
        Color foreColor,
        TextFormatFlags flags,
        Color backColor = default)
    {
        if (text.IsEmpty || foreColor == Color.Transparent)
        {
            return;
        }
 
        (DRAW_TEXT_FORMAT dt, TextPaddingOptions padding) = SplitTextFormatFlags(flags);
 
        // DrawText requires default text alignment.
        using SetTextAlignmentScope alignment = new(hdc, default);
 
        // Color empty means use the one currently selected in the dc.
        using var textColor = foreColor.IsEmpty ? default : new SetTextColorScope(hdc, foreColor);
        using SelectObjectScope fontSelection = new(hdc, (HFONT)font);
 
        BACKGROUND_MODE newBackGroundMode = (backColor.IsEmpty || backColor == Color.Transparent)
            ? BACKGROUND_MODE.TRANSPARENT
            : BACKGROUND_MODE.OPAQUE;
 
        using SetBkModeScope backgroundMode = new(hdc, newBackGroundMode);
        using var backgroundColor = newBackGroundMode != BACKGROUND_MODE.TRANSPARENT
            ? new SetBackgroundColorScope(hdc, backColor)
            : default;
 
        DRAWTEXTPARAMS dtparams = GetTextMargins(font, padding);
 
        bounds = AdjustForVerticalAlignment(hdc, text, bounds, dt, &dtparams);
 
        // Adjust unbounded rect to avoid overflow.
        if (bounds.Width == int.MaxValue)
        {
            bounds.Width -= bounds.X;
        }
 
        if (bounds.Height == int.MaxValue)
        {
            bounds.Height -= bounds.Y;
        }
 
        RECT rect = bounds;
        PInvoke.DrawTextEx(hdc, text, &rect, dt, &dtparams);
    }
 
    /// <summary>
    ///  Get the bounding box internal text padding to be used when drawing text.
    /// </summary>
    public static DRAWTEXTPARAMS GetTextMargins(
        this FontCache.Scope font,
        TextPaddingOptions padding = default)
    {
        // DrawText(Ex) adds a small space at the beginning of the text bounding box but not at the end,
        // this is more noticeable when the font has the italic style. We compensate with this factor.
 
        int leftMargin = 0;
        int rightMargin = 0;
        float overhangPadding;
 
        switch (padding)
        {
            case TextPaddingOptions.GlyphOverhangPadding:
                // [overhang padding][Text][overhang padding][italic padding]
                overhangPadding = font.Data.Height / 6f;
                leftMargin = (int)Math.Ceiling(overhangPadding);
                rightMargin = (int)Math.Ceiling(overhangPadding * (1 + ItalicPaddingFactor));
                break;
 
            case TextPaddingOptions.LeftAndRightPadding:
                // [2 * overhang padding][Text][2 * overhang padding][italic padding]
                overhangPadding = font.Data.Height / 6f;
                leftMargin = (int)Math.Ceiling(2 * overhangPadding);
                rightMargin = (int)Math.Ceiling(overhangPadding * (2 + ItalicPaddingFactor));
                break;
 
            case TextPaddingOptions.NoPadding:
            default:
                break;
        }
 
        return new DRAWTEXTPARAMS
        {
            iLeftMargin = leftMargin,
            iRightMargin = rightMargin
        };
    }
 
    /// <summary>
    ///  Adjusts <paramref name="bounds"/> to allow for vertical alignment.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   The GDI DrawText does not do multiline alignment when User32.DT.SINGLELINE is not set. This
    ///   adjustment is to workaround that limitation.
    ///  </para>
    /// </remarks>
    public static unsafe Rectangle AdjustForVerticalAlignment(
        this HDC hdc,
        ReadOnlySpan<char> text,
        Rectangle bounds,
        DRAW_TEXT_FORMAT flags,
        DRAWTEXTPARAMS* dtparams)
    {
        ValidateFlags(flags);
 
        // No need to do anything if TOP (Cannot test DT_TOP because it is 0), single line text or measuring text.
        bool isTop = !flags.HasFlag(DRAW_TEXT_FORMAT.DT_BOTTOM) && !flags.HasFlag(DRAW_TEXT_FORMAT.DT_VCENTER);
        if (isTop || flags.HasFlag(DRAW_TEXT_FORMAT.DT_SINGLELINE) || flags.HasFlag(DRAW_TEXT_FORMAT.DT_CALCRECT))
        {
            return bounds;
        }
 
        RECT rect = bounds;
 
        // Get the text bounds.
        flags |= DRAW_TEXT_FORMAT.DT_CALCRECT;
        int textHeight = PInvoke.DrawTextEx(hdc, text, &rect, flags, dtparams);
 
        // If the text does not fit inside the bounds then return the bounds that were passed in.
        // This way we paint the top of the text at the top of the bounds passed in.
        if (textHeight > bounds.Height)
        {
            return bounds;
        }
 
        Rectangle adjustedBounds = bounds;
 
        if (flags.HasFlag(DRAW_TEXT_FORMAT.DT_VCENTER))
        {
            // Middle
            adjustedBounds.Y = adjustedBounds.Top + adjustedBounds.Height / 2 - textHeight / 2;
        }
        else
        {
            // Bottom
            adjustedBounds.Y = adjustedBounds.Bottom - textHeight;
        }
 
        return adjustedBounds;
    }
 
    /// <summary>
    ///  Returns the bounds in logical units of the given <paramref name="text"/>.
    /// </summary>
    /// <param name="proposedSize">
    ///  <para>
    ///   The desired bounds. It will be modified as follows:
    ///  </para>
    ///  <list type="bullet">
    ///   <item><description>The base is extended to fit multiple lines of text.</description></item>
    ///   <item><description>The width is extended to fit the largest word.</description></item>
    ///   <item><description>The width is reduced if the text is smaller than the requested width.</description></item>
    ///   <item><description>The width is extended to fit a single line of text.</description></item>
    ///  </list>
    /// </param>
    public static unsafe Size MeasureText(
        this HDC hdc,
        ReadOnlySpan<char> text,
        FontCache.Scope font,
        Size proposedSize,
        TextFormatFlags flags)
    {
        (DRAW_TEXT_FORMAT dt, TextPaddingOptions padding) = SplitTextFormatFlags(flags);
 
        if (text.IsEmpty)
        {
            return Size.Empty;
        }
 
        // DrawText returns a rectangle useful for aligning, but not guaranteed to encompass all
        // pixels (its not a FitBlackBox, if the text is italicized, it will overhang on the right.)
        // So we need to account for this.
 
        DRAWTEXTPARAMS dtparams = GetTextMargins(font, padding);
 
        // If Width / Height are < 0, we need to make them larger or DrawText will return
        // an unbounded measurement when we actually trying to make it very narrow.
        int minWidth = 1 + dtparams.iLeftMargin + dtparams.iRightMargin;
 
        if (proposedSize.Width <= minWidth)
        {
            proposedSize.Width = minWidth;
        }
 
        if (proposedSize.Height <= 0)
        {
            proposedSize.Height = 1;
        }
 
        RECT rect = new(proposedSize);
 
        using SelectObjectScope fontSelection = new(hdc, font.Object);
 
        // If proposedSize.Height == int.MaxValue it is assumed bounds are needed. If flags contain SINGLELINE and
        // VCENTER or BOTTOM options, DrawTextEx does not bind the rectangle to the actual text height since
        // it assumes the text is to be vertically aligned; we need to clear the VCENTER and BOTTOM flags to
        // get the actual text bounds.
        if (proposedSize.Height == int.MaxValue && dt.HasFlag(DRAW_TEXT_FORMAT.DT_SINGLELINE))
        {
            // Clear vertical-alignment flags.
            dt &= ~(DRAW_TEXT_FORMAT.DT_BOTTOM | DRAW_TEXT_FORMAT.DT_VCENTER);
        }
 
        if (proposedSize.Width == int.MaxValue)
        {
            // If there is no constraining width, there should be no need to calculate word breaks.
            dt &= ~(DRAW_TEXT_FORMAT.DT_WORDBREAK);
        }
 
        dt |= DRAW_TEXT_FORMAT.DT_CALCRECT;
        PInvoke.DrawTextEx(hdc, text, &rect, dt, &dtparams);
 
        return rect.Size;
    }
 
    /// <summary>
    ///  Returns the dimensions the of the given <paramref name="text"/>.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   This method is used to get the size in logical units of a line of text; it uses GetTextExtentPoint32 function
    ///   which computes the width and height of the text ignoring TAB\CR\LF characters.
    ///  </para>
    ///  <para>
    ///   A text extent is the distance between the beginning of the space and a character that will fit in the space.
    ///  </para>
    /// </remarks>
    public static unsafe Size GetTextExtent(this HDC hdc, string? text, HFONT hfont)
    {
        if (string.IsNullOrEmpty(text))
        {
            return Size.Empty;
        }
 
        Size size = default;
        using SelectObjectScope selectFont = new(hdc, hfont);
 
        fixed (char* pText = text)
        {
            PInvoke.GetTextExtentPoint32W(hdc, pText, text.Length, (SIZE*)(void*)&size);
        }
 
        return size;
    }
}