Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Indentation of second line in WPF TextFormatter

Tags:

c#

.net

vb.net

wpf

I'm making a WPF text-editor using TextFormatter. I need to indent the second line in each paragraph.

The indentation width in the second line should be like the width of the first word on the first line, including the white space after the first word. Something like that:

Indent of second line in Indentation Inde
       second line in Indentation Indenta
of second line in Indentation of second l
ine in Indentation of second line in Inde
       ntation of second line in

Second thing: The last line in the paragraph should be in the center.

how to make this happen?

Thanks in advance!!

like image 869
google dev Avatar asked Sep 14 '17 09:09

google dev


1 Answers

This is far from being easy. I suggest you use WPF's Advanced Text Formatting.

There is an offical (relatively poor, but it's the only one) sample: TextFormatting.

So, I have created a small sample app with a textbox and a special custom control that renders the text from the textbox simultaneously, the way you want (well, almost, see remarks at the end).

<Window x:Class="WpfApp3.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApp1"
        Title="MainWindow" Height="550" Width="725">
    <StackPanel Margin="10">
        <TextBox  Name="TbSource" AcceptsReturn="True" TextWrapping="Wrap" BorderThickness="1"
                 VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"></TextBox>
        <Border BorderThickness="1" BorderBrush="#ABADB3" Margin="0" Padding="0">
            <local:MyTextControl Margin="5" Text="{Binding ElementName=TbSource, Path=Text}" />
        </Border>
    </StackPanel>
</Window>

I have chosen to write a custom control, but you could also build a geometry (like in the official 'TextFormatting' sample).

[ContentProperty(nameof(Text))]
public class MyTextControl : FrameworkElement
{
    // I have only declared Text as a dependency property, but fonts, etc should be here
    public static DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(MyTextControl),
        new FrameworkPropertyMetadata(string.Empty,
            FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.AffectsMeasure));

    private List<TextLine> _lines = new List<TextLine>();
    private TextFormatter _formatter = TextFormatter.Create();

    public string Text { get => ((string)GetValue(TextProperty)); set { SetValue(TextProperty, value); } }

    protected override Size MeasureOverride(Size availableSize)
    {
        // dispose old stuff
        _lines.ForEach(l => l.Dispose());

        _lines.Clear();
        double height = 0;
        double width = 0;
        var ts = new MyTextSource(Text);
        var index = 0;
        double maxWidth = availableSize.Width;
        if (double.IsInfinity(maxWidth))
        {
            // it means width was not fixed by any constraint above this.
            // we pick an arbitrary value, we could use visual parent, etc.
            maxWidth = 100;
        }

        double firstWordWidth = 0; // will be computed with the 1st line

        while (index < Text.Length)
        {
            // we indent the second line
            var props = new MyTextParagraphProperties(new MyTextRunProperties(), _lines.Count == 1 ? firstWordWidth : 0);
            var line = _formatter.FormatLine(ts, index, maxWidth, props, null);
            if (_lines.Count == 0)
            {
                // get first word and whitespace real width (so we can support justification / whitespaces widening, kerning)
                firstWordWidth = line.GetDistanceFromCharacterHit(new CharacterHit(ts.FirstWordAndSpaces.Length, 0));
            }

            index += line.Length;
            _lines.Add(line);

            height += line.TextHeight;
            width = Math.Max(width, line.WidthIncludingTrailingWhitespace);
        }
        return new Size(width, height);
    }

    protected override void OnRender(DrawingContext dc)
    {
        double height = 0;
        for (int i = 0; i < _lines.Count; i++)
        {
            if (i == _lines.Count - 1)
            {
                // last line centered (using pixels, not characters)
                _lines[i].Draw(dc, new Point((RenderSize.Width - _lines[i].Width) / 2, height), InvertAxes.None);
            }
            else
            {
                _lines[i].Draw(dc, new Point(0, height), InvertAxes.None);
            }
            height += _lines[i].TextHeight;
        }
    }
}

// this is a simple text source, it just gives back one set of characters for the whole string
public class MyTextSource : TextSource
{
    public MyTextSource(string text)
    {
        Text = text;
    }

    public string Text { get; }

    public string FirstWordAndSpaces
    {
        get
        {
            if (Text == null)
                return null;

            int pos = Text.IndexOf(' ');
            if (pos < 0)
                return Text;

            while (pos < Text.Length && Text[pos] == ' ')
            {
                pos++;
            }

            return Text.Substring(0, pos);
        }
    }

    public override TextRun GetTextRun(int index)
    {
        if (Text == null || index >= Text.Length)
            return new TextEndOfParagraph(1);

        return new TextCharacters(
           Text,
           index,
           Text.Length - index,
           new MyTextRunProperties());
    }

    public override TextSpan<CultureSpecificCharacterBufferRange> GetPrecedingText(int indexLimit) => throw new NotImplementedException();
    public override int GetTextEffectCharacterIndexFromTextSourceCharacterIndex(int index) => throw new NotImplementedException();
}

public class MyTextParagraphProperties : TextParagraphProperties
{
    public MyTextParagraphProperties(TextRunProperties defaultTextRunProperties, double indent)
    {
        DefaultTextRunProperties = defaultTextRunProperties;
        Indent = indent;
    }

    // TODO: some of these should be DependencyProperties on the control
    public override FlowDirection FlowDirection => FlowDirection.LeftToRight;
    public override TextAlignment TextAlignment => TextAlignment.Justify;
    public override double LineHeight => 0;
    public override bool FirstLineInParagraph => true;
    public override TextRunProperties DefaultTextRunProperties { get; }
    public override TextWrapping TextWrapping => TextWrapping.Wrap;
    public override TextMarkerProperties TextMarkerProperties => null;
    public override double Indent { get; }
}

public class MyTextRunProperties : TextRunProperties
{
    // TODO: some of these should be DependencyProperties on the control
    public override Typeface Typeface => new Typeface("Segoe UI");
    public override double FontRenderingEmSize => 20;
    public override Brush ForegroundBrush => Brushes.Black;
    public override Brush BackgroundBrush => Brushes.White;
    public override double FontHintingEmSize => FontRenderingEmSize;
    public override TextDecorationCollection TextDecorations => new TextDecorationCollection();
    public override CultureInfo CultureInfo => CultureInfo.CurrentCulture;
    public override TextEffectCollection TextEffects => new TextEffectCollection();
}

This is the result:

enter image description here

Things I have not done:

  • This does not support edit, it's not a textbox. This is too much work for such a small bounty :-)
  • Support multiple paragraphs. I've just indented the second line in my sample. You would need to parse the text to extract "paragraphs" whatever you think that is.
  • DPI awareness support should be added (for .NET Framework 4.6.2 or above). This is done in the 'TextFormatting' sample, you basically need to carry the PixelsPerDip value all around.
  • What happens in some edge cases (only two lines, etc.)
  • Expose usual properties (FontFamily, etc...) on the custom control
like image 140
Simon Mourier Avatar answered Oct 11 '22 12:10

Simon Mourier