UWP: Compute text height in a RichTextBlock gives weird results
Asked Answered
C

1

1

I need a reliable method to get the height of the text contained in a RichTextBlock, even before it is actually drawn on the scene.

Using the normal Measure() method produces a weird result, as it can be seen in the MVCE: https://github.com/cghersi/UWPExamples/tree/master/MeasureText (I want to keep fiexed the width, and measure the final height, but the result of DesiredSize is far different from the actual height!!).

For this reason, I found a rough method (mentioned here https://mcmap.net/q/651100/-how-can-i-measure-the-text-size-in-uwp-apps), that I extended to serve my purpose, where we use some Win2D API to compute the content height.

The problem is that in some cases, this method provides an height that is smaller than the expected one.

  1. Is there a general way to retrieve the (correct) height of a TextBlock, even before it is drawn on the scene?
  2. If this is not the case, what am I doing wrong?

Here's my code (which you can find also as MVCE here: https://github.com/cghersi/UWPExamples/tree/master/RichText):

    public sealed partial class MainPage
    {
        public static readonly FontFamily FONT_FAMILY = new FontFamily("Assets/paltn.ttf#Palatino-Roman");
        public const int FONT_SIZE = 10;
        private readonly Dictionary<string, object> FONT = new Dictionary<string, object>
        {
            { AttrString.FONT_FAMILY_KEY, FONT_FAMILY },
            { AttrString.FONT_SIZE_KEY, FONT_SIZE },
            { AttrString.LINE_HEAD_INDENT_KEY, 10 },
            { AttrString.LINE_SPACING_KEY, 1.08 },
            { AttrString.FOREGROUND_COLOR_KEY, new SolidColorBrush(Colors.Black) }
        };

        // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable
        private readonly RichTextBlock m_displayedText;

        public MainPage()
        {
            InitializeComponent();

            // create the text block:
            m_displayedText = new RichTextBlock
            {
                MaxLines = 0, //Let it use as many lines as it wants
                TextWrapping = TextWrapping.Wrap,
                AllowFocusOnInteraction = false,
                IsHitTestVisible = false,
                Width = 80,
                Height = 30,
                Margin = new Thickness(100)
            };

            // set the content with the right properties:
            AttrString content = new AttrString("Excerpt1 InkLink", FONT);
            SetRichText(m_displayedText, content);

            // add to the main panel:
            MainPanel.Children.Add(m_displayedText);

            // compute the text height: (this gives the wrong answer!!):
            double textH = GetRichTextHeight(content, (float)m_displayedText.Width);
            Console.WriteLine("text height: {0}", textH);
        }

        public static double GetRichTextHeight(AttrString text, float maxWidth)
        {
            if (text == null)
                return 0;

            CanvasDevice device = CanvasDevice.GetSharedDevice();
            double finalH = 0;
            foreach (AttributedToken textToken in text.Tokens)
            {
                CanvasTextFormat frmt = new CanvasTextFormat()
                {
                    Direction = CanvasTextDirection.LeftToRightThenTopToBottom,
                    FontFamily = textToken.Get(AttrString.FONT_FAMILY_KEY, FONT_FAMILY).Source,
                    FontSize = textToken.Get(AttrString.FONT_SIZE_KEY, FONT_SIZE),
                    WordWrapping = CanvasWordWrapping.Wrap
                };
                CanvasTextLayout layout = new CanvasTextLayout(device, textToken.Text, frmt, maxWidth, 0f);
                finalH += layout.LayoutBounds.Height;
            }

            return finalH;

            //return textBlock.Blocks.Sum(block => block.LineHeight);
        }

        private static void SetRichText(RichTextBlock label, AttrString str)
        {
            if ((str == null) || (label == null))
                return;
            label.Blocks.Clear();
            foreach (AttributedToken token in str.Tokens)
            {
                Paragraph paragraph = new Paragraph()
                {
                    TextAlignment = token.Get(AttrString.TEXT_ALIGN_KEY, TextAlignment.Left),
                    TextIndent = token.Get(AttrString.LINE_HEAD_INDENT_KEY, 0),
                };
                double fontSize = token.Get(AttrString.FONT_SIZE_KEY, FONT_SIZE);
                double lineSpacing = token.Get(AttrString.LINE_SPACING_KEY, 1.0);
                paragraph.LineHeight = fontSize * lineSpacing;
                paragraph.LineStackingStrategy = LineStackingStrategy.BlockLineHeight;
                Run run = new Run
                {
                    Text = token.Text,
                    FontFamily = token.Get(AttrString.FONT_FAMILY_KEY, FONT_FAMILY),
                    FontSize = fontSize,
                    Foreground = token.Get(AttrString.FOREGROUND_COLOR_KEY, new SolidColorBrush(Colors.Black)),
                    FontStyle = token.Get(AttrString.ITALIC_KEY, false) ? 
                        Windows.UI.Text.FontStyle.Italic : Windows.UI.Text.FontStyle.Normal
                };
                paragraph.Inlines.Add(run);
                label.Blocks.Add(paragraph);
            }
        }
    }

    public class AttrString
    {
        public const string FONT_FAMILY_KEY = "Fam";
        public const string FONT_SIZE_KEY = "Size";
        public const string LINE_HEAD_INDENT_KEY = "LhI";
        public const string LINE_SPACING_KEY = "LSpace";
        public const string FOREGROUND_COLOR_KEY = "Color";
        public const string ITALIC_KEY = "Ita";
        public const string TEXT_ALIGN_KEY = "Align";
        public const string LINE_BREAK_MODE_KEY = "LineBreak";

        public static Dictionary<string, object> DefaultCitationFont { get; set; }
        public static Dictionary<string, object> DefaultFont { get; set; }

        public List<AttributedToken> Tokens { get; set; }

        public AttrString(string text, Dictionary<string, object> attributes)
        {
            Tokens = new List<AttributedToken>();
            Append(text, attributes);
        }

        public AttrString(AttrString copy)
        {
            if (copy?.Tokens == null)
                return;
            Tokens = new List<AttributedToken>(copy.Tokens);
        }

        public AttrString Append(string text, Dictionary<string, object> attributes)
        {
            Tokens.Add(new AttributedToken(text, attributes));
            return this;
        }

        public bool IsEmpty()
        {
            foreach (AttributedToken t in Tokens)
            {
                if (!string.IsNullOrEmpty(t.Text))
                    return false;
            }

            return true;
        }

        public override string ToString()
        {
            StringBuilder sb = new StringBuilder();
            foreach (AttributedToken t in Tokens)
            {
                sb.Append(t.Text);
            }
            return sb.ToString();
        }
    }

    public class AttributedToken
    {
        public string Text { get; set; }

        public Dictionary<string, object> Attributes { get; set; }

        public AttributedToken(string text, Dictionary<string, object> attributes)
        {
            Text = text;
            Attributes = attributes;
        }

        public T Get<T>(string key, T defaultValue)
        {
            if (string.IsNullOrEmpty(key) || (Attributes == null))
                return defaultValue;
            if (Attributes.ContainsKey(key))
                return (T)Attributes[key];
            else
                return defaultValue;
        }

        public override string ToString()
        {
            return Text;
        }
    }

** UPDATE **:

After further digging into the issue, the problem seems related to the lack of configurability for the CanvasTextFormat object, especially for the indentation of the first line (expressed in the RichTextBlock using the property Paragraph.TextIndent). Is there any way to specify such setting in a CanvasTextFormat object?

Congruence answered 22/4, 2019 at 21:33 Comment(5)
For now, there's no 'TextIndent' similar property for CanvasTextFormat. Could you please tell me your final requirement? Why did you want to use win2D to get the font height?Josephus
I'd imagine you could put the RichTextBox inside a custom panel, and during the MeasureOverride you could measure it to the panels width / infinite height to get the height, before doing the proper measure. CanvasTextLayout is more analogous to RichTextBlock than CanvasTextFormat fwiw, as Layout can take individual runs with separate formatting.Lacefield
@XavierXie-MSFT and Johnny Westlake thanks for the explanation, I need the fastest (performance-wise) method to compute the total height of the text contained in a RichTextBlock, even before the RichTextBlock is drawn for the first time. I have a scene with tens of thousands of those RichTextBlocks, so I also need to keep the hierarchy as slim as possible.Congruence
@JohnnyWestlake you are right, indeed I am currently using CanvasTextLayout, passing an argument of type CanvasTextFormat in the method GetRichTextHeight(), but what I am missing is the way to tell the CanvasTextLayout that the first line of my RichTextBlock is indented by 10.Congruence
@JohnnyWestlake BTW, this method works if a full paragraph is formatted in the same way, but in the future, we may be asked to have in the same paragraph different formats, which will require to enhance the GetRichTextHeight() method by telling either the CanvasTextFormat or the CanvasTextLayout that the text starts at a specified position (basically, having an indent...). Do you have any clue on how to tackle this issue?Congruence
C
1

Looking at your MeasureText MVCE code, the problem with calling Measure() on the RichTextBlock comes down to this line:

    m_textBlock.Margin = new Thickness(200);

This sets a universal margin of 200 on all sides, which means the element needs at least 200 width on the left plus 200 width on the right, or 400 width. Since your Measure(300,infinite) specifies an available width of less than the minimum required 400 width, the RichTextBlock decides that the best it can do is wrap the text at every character, producing the massive 5740 pixel height (plus the 200+200 height from the margin).

If you remove that line, the RichTextBlock will use the specified constraint of 300 and correctly measure its desired height as 90 pixels, which is what it renders as on screen (if you set Width=300 or otherwise result in the actual element layout to have the same constraint).

Alternatively, since you know the width you want for the element, you could set Width=300 on it and it will then measure with that width. The Height will be expanded as a result of the set Margin, though.

I'm assuming you don't actually have Margin=200 set in your real app, and instead have something smaller like Margin=5 to account for margin you actually want when the RichTextBlock is in the tree and drawing. If this is the case, then you can either:

  1. Use the Width=300 approach for measuring and subtract off the top+bottom margin from the DesireSize.Height.
  2. Measure with (300+margin.Left+margin.Right) as the width so that once the margin is subtracted off from that total availableSize the remaining width the text can use is your intended 300. You'll still need to subtract off the top+bottom margin from the DesireSize.Height.
Crabbed answered 22/5, 2019 at 2:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.