Подсветка ключевых слов в 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 исправил лексер для правильного различения случаев числа со знаком и бинарного оператора: