1

I'm trying to split a very long string (document) on several pages containing a TextBlock, however, I need to make each page of specific number of lines which means that I need to split the TextBlock into lines.

I tried to create several logics but no luck of getting an accurate thing, but found a solution here (Get the lines of the TextBlock according to the TextWrapping property?) which worked for me on my prototype project then stopped working and gets the whole text in one line.

Here is the code from the above topic:

public static class TextUtils
    {
        public static IEnumerable<string> GetLines(this TextBlock source)
        {
            var text = source.Text;
            int offset = 0;
            TextPointer lineStart = source.ContentStart.GetPositionAtOffset(1, LogicalDirection.Forward);
            do
            {
                TextPointer lineEnd = lineStart != null ? lineStart.GetLineStartPosition(1) : null;
                int length = lineEnd != null ? lineStart.GetOffsetToPosition(lineEnd) : text.Length - offset;
                yield return text.Substring(offset, length);
                offset += length;
                lineStart = lineEnd;
            }
            while (lineStart != null);
        }
    }

And this is my code:

<TextBlock x:Name="testTB" TextAlignment="Justify" FontFamily="Arial" FontSize="12" TextWrapping="Wrap" Width="100"/>
testTB.Text = Functions.GenString(200);

foreach (string xc in testTB.GetLines())
{
    MessageBox.Show(xc);
}

Where I guess that the issue is that lineStart.GetLineStartPosition(1) is returning null.

Any help is appreciated, thanks in advance.

BionicCode
  • 1
  • 4
  • 28
  • 44
Mohamed Ashraf
  • 181
  • 3
  • 19
  • This has few open ended qeustions. Whats the source for this textblock? Did you account for the UI width and length as the textblock may vary based on the UI (be it, windows/web). did you explore the options of scrollbars usage in the textblock or what is the ideal intent of your work? – Ak777 Mar 16 '20 at 18:34
  • The text source is stored in a database, for the sake of simplicity let's just say that GenString function generates random string. The TextBlock size is static and in the runtime the string is indeed wrapped into lines. And **No**, I don't want to use scrollbars. What I'm trying to achieve is simply filling the TextBlock with text until a certain number of lines (which represents the height I need) then get the remaining text to be filled in another page. – Mohamed Ashraf Mar 16 '20 at 18:52
  • `TextBlock` doesn't have this kind of API as it is designed and optimized for single line text. You could make thing a lot easier when switching to a read-only `TextBox`. It offers all the API you need. An other solution could be to use the [FormattedText`](https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext?view=netframework-4.8#examples) class to measure the text width and calculate line breaks yourself. – BionicCode Mar 16 '20 at 20:18
  • Well, the code above once worked before and worked for other people, on a textblock. However, I thought about textboxes but as far as I know, they doesn't provide ability to use inline bold and underline to format what is inside. – Mohamed Ashraf Mar 16 '20 at 20:22
  • I was not saying that it didn't work. I was just suggesting to use the `TextBox` as it offers a simple API in order to get the lines or actual line count. The code you've posted is a simple iteration. The only reason why it doesn't work is the content of the `TextBlock`b – BionicCode Mar 16 '20 at 21:46
  • I know textbox is better option but it doesn't provide bold and underline options which are required... And the text of the textblock is just a simple plain text generated and assigned to the text property of the textblock. – Mohamed Ashraf Mar 16 '20 at 23:28

1 Answers1

1

To me the code you have posted looks error prone. It will work only if the TextBlock contains plain text. But when you are using Inline elements like Run, Bold or Underline, you no more have plain text as content, but also context markers like tags for the inline elements. I guess this is where your offset based string.Substring fails.

The solution is to create a TextRange from the retrieved TextPointer results and extract the plain text via the TextRange.Text property.

The following implementation supports both: plain text set via the TextBlock.Text property and text set using Inline elements:

public static IEnumerable<string> GetLines(this TextBlock source)
{
  TextPointer lineStart = source.ContentStart.GetPositionAtOffset(1, LogicalDirection.Forward);
  do
  {
    TextPointer lineEnd = lineStart.GetLineStartPosition(1) ?? source.ContentEnd; 
    var textRange = new TextRange(lineStart, lineEnd);
    lineStart = lineEnd;
    yield return textRange.Text;
  }
  while (lineStart.IsAtLineStartPosition);
}

Remarks

It is important to wait until the TextBlock.Loaded event was raised. This is because the TextBlock splits the single text string into lines during the UIElement.Measure process, as this is the moment where the control knows its desired size and therefore the max available width of a line. UIElement.Measure is invoked by the rendering engine, when the layout loading has started.

Example

MainWindow.xaml

<Window>
  <TextBlock x:Name="TextBlock" 
             TextWrapping="Wrap"
             Width="100">
    <TextBlock.Inlines>
      <Run
        Text="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat," />
      <Bold>
        <Bold.Inlines>
          <Run Text="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, conse" />
        </Bold.Inlines>
      </Bold>
      <LineBreak />
      <LineBreak />
      <LineBreak />
      <Underline>
        <Run Text="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut " />
        <Run Text="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut " />
      </Underline>
      <Run Text="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut " />
    </TextBlock.Inlines>
  </TextBlock>
</Window>

MainWindow.xaml.cs

partial class MainWindow : Window
{
  public MainWindow()
  {
    this.Loaded += OnLoaded;
  }

  private void OnLoaded(object sender, EventArgs e)
  {
    var lines = this.TextBlock.GetLines().ToList(); // Returns 54 lines
  }
}
BionicCode
  • 1
  • 4
  • 28
  • 44
  • I indeed going to face the issue with inlines and formatting but for now I can't even use plain text for some reason, I tried your code and not working as well... Same issue, the function outputs all the text as one line. Can you give me a full working example, maybe I have an issue with my xaml or text assigning methode, maybe you can send me a xaml snippet and a usage sample. Sorry for the hassle I'm causing ... – Mohamed Ashraf Mar 16 '20 at 23:40
  • What can you make wrong when assigning a string to `TextBlock.Text`. I'm not sure if you are serious. Nevertheless I've added the code I just used to test it. The method returns 54 lines. – BionicCode Mar 17 '20 at 00:50
  • Your posted `GetLines` implementation crashes with the example input. – BionicCode Mar 17 '20 at 00:54
  • In fact I was serious but not about assigning the string incorrectly, but with other small details that might cause issues. **So the problem actually is that I was trying to assign the string and process it on the window loading, without waiting for the whole thing to load.** So this is the small detail that I was missing. However, thanks a lot for your help and for optimizing the code to work with inline formatting as well. – Mohamed Ashraf Mar 17 '20 at 00:59
  • 1
    Alright then, you are welcome. Just keep in mind to do nothing unless the relevant controls are Loaded. `TextBlock` will apply line wrapping during the `UIElement.Measure` computations. This method is only triggered when the layout will load. Because only here the control knows its desired rendering size and can split the string accordingly. Until then, the `TextBlock` only contains one single string of text. – BionicCode Mar 17 '20 at 01:09
  • So is it better to wait for the whole page to load or there is a way to make a specific piece of code to wait for the load ? – Mohamed Ashraf Mar 17 '20 at 01:11
  • You must wait until the control that contains the control of interest is loaded. Since the loading of the layout of the relevant control can be deferred e.g. when the control of interest won't show up initially e.g. when it's not part of the visual tree yet, you should subscribe to the `Loaded` event of this specific control. Otherwise, when executing the parent's Loaded handler, the control is still not prepared for rendering. – BionicCode Mar 17 '20 at 01:34
  • 1
    Alternatively use `Dispatcher.InvokeAsync` together with `DispatcherPriority.Loaded`. But this will execute once the complete scope (thread) of the current dispatcher is loaded which is the main thread most of the time. In this case this would be equal to application loaded. For your scenario you could subscribe to `TextBlock.Loaded`. – BionicCode Mar 17 '20 at 01:34