Подсветка ключевых слов в TextBlock или RichTextBox

1,00
р.
Юзер выбирает текстовый файл, содержимое считывается в TextBlock/RichTextBox. После этого юзер нажимает на кнопку "Форматировать" и в TextBlock/RichTextBox должен добавиться этот текст, но найденные слова должны поменять свой цвет или шрифт. Как это реализовать?
Код XAML

Код C#
private void OpenFile() { OpenFileDialog dlg = new OpenFileDialog() dlg.DefaultExt = ".txt" dlg.Filter = "Text documents (.txt)|*.txt" Nullable result = dlg.ShowDialog()
if (result == true) { StreamReader r = new StreamReader(dlg.FileName, System.Text.Encoding.Default, false) TextBlock1.Text = r.ReadToEnd() this.Title = dlg.FileName } }
private void ButtonFormat_Click(object sender, RoutedEventArgs e) { string str = TextBlock1.Text TextBlock1.Text = "" string pattern = @"(\W|^)hello(\W|$)" Regex regex = new Regex(pattern, RegexOptions.IgnoreCase) Match match = regex.Match(str)
//System.Windows.Media.SolidColorBrush color = System.Windows.Media.Brushes.Red //System.Windows.Media.SolidColorBrush color2 = System.Windows.Media.Brushes.Black
// отображаем все совпадения while (match.Success) { // Здесь добавляются в Текст найденные слова "hello" // реализовать изменение цвета или размер шрифта найденных слов // другие слова должны оставаться такими, как были. TextBlock1.Text += match.Groups[0].Value + " " match = match.NextMatch() }
}

Ответ
Итак, для начала, регулярные выражения. Их можно использовать для токенизации, но тем не менее парсинг и разбор текста программы не должен быть частью UI-уровня. Поэтому наш код должен отдавать текст на разбор модельному уровню, желательно асинхронно.
Для этого определим сначала вспомогательные типы:
enum TokenType { Ident, Comment, Keyword, Punct, String, Number }
class QualifiedToken { public TokenType Type public TextPointer StartPosition public int StartOffset public TextPointer EndPosition public int EndOffset }
class RawText { public string Text public TextPointer Start }
и класс с простейшим публичным интерфейсом:
class Lexer { public static Task> Parse(IEnumerable texts) { var lexer = new Lexer(texts) return Task.Run(() => lexer.Parse().ToList()) }
// имплементация }
Мы должны передавать TextPointer внутрь, поскольку иначе мы рискуем потерять точную позицию распознанного токена в документе. Точно так же мы возвращаем результат, адресуя его через смещение относительно TextPointer'а.
Собирать тексты довольно просто: GetParagraphs(doc.Blocks).ToList(), где GetParagraphs рекурсивно обходит документ:
IEnumerable GetParagraphs(BlockCollection blockCollection) { foreach (var block in blockCollection) { var para = block as Paragraph if (para != null) { yield return para } else { foreach (var innerPara in GetParagraphs(block.SiblingBlocks)) yield return innerPara } } }
Каждый абзац — самостоятельная часть текста, их можно обрабатывать вместе или по отдельности:
async Task UpdateParagraph(Paragraph par) { var completeTextRange = new TextRange(par.ContentStart, par.ContentEnd) completeTextRange.ClearAllProperties() await UpdateInlines(par.Inlines) }
async Task UpdateAllParagraphs(IEnumerable paragraphs) { var materialParagraphs = paragraphs.ToList() if (materialParagraphs.Count == 0) return var completeTextRange = new TextRange(materialParagraphs.First().ContentStart, materialParagraphs.Last().ContentEnd) completeTextRange.ClearAllProperties() await UpdateInlines(materialParagraphs.SelectMany(par => par.Inlines)) }
async Task UpdateInlines(IEnumerable inlines) { var texts = ExtractText(inlines) var positionsAndBrushes = (from qualifiedToken in await Lexer.Parse(texts) let brush = GetBrushForTokenType(qualifiedToken.Type) where brush != null let start = qualifiedToken.StartPosition .GetPositionAtOffset(qualifiedToken.StartOffset) let end = qualifiedToken.EndPosition .GetPositionAtOffset(qualifiedToken.EndOffset) let position = new TextRange(start, end) select new { position, brush }).ToList()
foreach (var pb in positionsAndBrushes) pb.position.ApplyPropertyValue(TextElement.ForegroundProperty, pb.brush) }
(нам пришлось материализовать список позиций, так как применение атрибутов инвалидирует смещения)
Вспомогательные методы для ExtractText скучны и бесхитростны, они отслеживают Run'ы и LineBreak'и:
IEnumerable ExtractText(IEnumerable inlines) { return inlines.SelectMany(ExtractText) }
IEnumerable ExtractText(Inline inline) { return ExtractTextImpl((dynamic)inline) }
IEnumerable ExtractTextImpl(Run run) { return new[] { new RawText() { Text = run.Text, Start = run.ContentStart } } }
IEnumerable ExtractTextImpl(LineBreak br) { return new[] { new RawText() { Text = "
", Start = br.ContentStart } } }
IEnumerable ExtractTextImpl(Span span) { return ExtractText(span.Inlines) }
IEnumerable ExtractTextImpl(Inline inline) { return Enumerable.Empty() }
[Для более современного C# 8 можно использовать более лаконичную форму:
IEnumerable ExtractText(IEnumerable inlines) => inlines.SelectMany(inline => inline switch { Run run => new[] { new RawText() { Text = run.Text, Start = run.ContentStart } }, LineBreak br => new[] { new RawText() { Text = "
", Start = br.ContentStart } }, Span span => ExtractText(span.Inlines), _ => Enumerable.Empty() })
с сопоставлением шаблонов.]
В итоге получаем вот такую раскраску:

Лексер сам по себе практически тривиален. Должен правильно понимать числа, строки и комментарии.
class Lexer { public static Task> Parse(IEnumerable texts) { var lexer = new Lexer(texts) return Task.Run(() => lexer.Parse().ToList()) }
List partIndices = new List() List pointers = new List() string totalText
Lexer(IEnumerable texts) { StringBuilder sb = new StringBuilder() foreach (var text in texts) { partIndices.Add(sb.Length) sb.Append(text.Text) pointers.Add(text.Start) } totalText = sb.ToString() }
Tuple GetBasePointerAndOffset(int position) { var partNo = partIndices.BinarySearch(position) if (partNo < 0) partNo = ~partNo - 1 var partStart = partIndices[partNo] var delta = position - partStart return Tuple.Create(pointers[partNo], delta) }
static Regex combineRegex(Dictionary name2pattern) { var combinedRegexParts = "^(" + string.Join("|", name2pattern.Select( kvp => string.Format("(?<{0}>{1})", kvp.Key, kvp.Value))) + ")" return new Regex(combinedRegexParts, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.Singleline) }
static Lexer() { var numPatternWithoutSign = @"(\d+(\.\d*)?)|(\.\d+)" var numPatternWithSign = @"[+-]?" + numPatternWithoutSign
var regexParts = new Dictionary() { { "ident", @"\p{L}\w*" }, { "num", numPatternWithSign }, { "comment", @"(/\*.*?\*/)|(//.*?$)" }, { "punct", @"(:=)|(<=)|(>=)|(==)|[ +\-\*/\(\){}:<>=]" }, { "string", "\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"" }, { "space", @"\s+" } }
recognizer = combineRegex(regexParts)
regexParts["num"] = numPatternWithoutSign recognizerWithoutSignedNumbers = combineRegex(regexParts)
names = regexParts.Keys }
static Regex recognizer, recognizerWithoutSignedNumbers static IEnumerable names static Dictionary tokenMapping = new Dictionary() { { "ident", TokenType.Ident }, { "num", TokenType.Number }, { "comment", TokenType.Comment }, { "punct", TokenType.Punct }, { "string", TokenType.String }, { "space", null } }
IEnumerable Parse() { string restLine = totalText int currPos = 0 TokenType? lastSignigicantToken = null
while (restLine != "") { bool allowSignedNumber = lastSignigicantToken == TokenType.Punct var match = (allowSignedNumber ? recognizer : recognizerWithoutSignedNumbers) .Match(restLine) var nameAndGroup = names.Select(name => new { name, group = match.Groups[name] }) .Single(ng => ng.group.Success)
var text = nameAndGroup.group.Value var length = nameAndGroup.group.Length
var tokenType = tokenMapping[nameAndGroup.name] if (tokenType == TokenType.Ident && CheckKeyword(text)) tokenType = TokenType.Keyword
if (tokenType != null) { var start = GetBasePointerAndOffset(currPos) var end = GetBasePointerAndOffset(currPos + length) yield return new QualifiedToken() { Type = tokenType.Value, StartPosition = start.Item1, StartOffset = start.Item2, EndPosition = end.Item1, EndOffset = end.Item2 } }
if (tokenType != null && tokenType != TokenType.Comment) lastSignigicantToken = tokenType
currPos += length restLine = restLine.Substring(length) } }
static HashSet keywords = new HashSet() { "for", "while", "int", "string", "bool" }
bool CheckKeyword(string text) { return keywords.Contains(text) } }
Если кому интересно, вот тестовый проект: https://github.com/vladd/Simple-Lexer

Обновление: По итогам дискуссии с @ReinRaus исправил лексер для правильного различения случаев числа со знаком и бинарного оператора: