From eb3055a2e8709b0124965ab413d24e3b7d68f4e3 Mon Sep 17 00:00:00 2001 From: melekhin Date: Thu, 10 Sep 2026 10:17:33 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D0=BD=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=8B=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- QWERTYkez.WordProcessor/DynamicReplaceExt.cs | 256 ++++++++++++++++++ .../DynamicReplaceToTextExt.cs | 214 +++++++++++++++ QWERTYkez.WordProcessor/WordWriter.cs | 129 +++++++++ 3 files changed, 599 insertions(+) create mode 100644 QWERTYkez.WordProcessor/DynamicReplaceExt.cs create mode 100644 QWERTYkez.WordProcessor/DynamicReplaceToTextExt.cs diff --git a/QWERTYkez.WordProcessor/DynamicReplaceExt.cs b/QWERTYkez.WordProcessor/DynamicReplaceExt.cs new file mode 100644 index 0000000..04cd231 --- /dev/null +++ b/QWERTYkez.WordProcessor/DynamicReplaceExt.cs @@ -0,0 +1,256 @@ +namespace QWERTYkez.WordProcessor; + +internal static class DynamicReplaceExt +{ + // ============================================================ + // Разбор шаблона вида "$key|$" на prefix="$key|" и suffix="$" + // Параметр помещается между prefix и suffix. + // ============================================================ + internal static void ParsePattern(string oldValue, out string prefix, out string suffix) + { + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + int lastPipe = oldValue.LastIndexOf('|'); + if (lastPipe < 0) + throw new ArgumentException("Dynamic pattern must contain '|' separator", nameof(oldValue)); + + prefix = oldValue.Substring(0, lastPipe + 1); + suffix = oldValue.Substring(lastPipe + 1); + } + + internal readonly struct MatchInfo(int start, int end, string param) + { + public readonly int Start = start; + public readonly int End = end; + public readonly string Param = param; + } + + // ============================================================ + // Поиск всех вхождений prefix + + suffix в тексте. + // Возвращает список в порядке возрастания позиции. + // ============================================================ + internal static List FindMatches( + string text, + string prefix, + string suffix, + StringComparison comparisonType) + { + var result = new List(); + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) + return result; + + int searchPos = 0; + while (searchPos < text.Length) + { + int pStart = text.IndexOf(prefix, searchPos, comparisonType); + if (pStart < 0) break; + + int paramStart = pStart + prefix.Length; + int pEnd; + if (string.IsNullOrEmpty(suffix)) + { + pEnd = text.Length; + } + else + { + pEnd = text.IndexOf(suffix, paramStart, comparisonType); + if (pEnd < 0) break; + } + + string param = text.Substring(paramStart, pEnd - paramStart); + int matchEnd = pEnd + suffix.Length; + result.Add(new MatchInfo(pStart, matchEnd, param)); + + // следующий поиск — после окончания текущего совпадения + searchPos = matchEnd; + if (searchPos <= pStart) searchPos = pStart + 1; // защита от зацикливания + } + return result; + } + + // ============================================================ + // REPLACE STRING (Func) + // ============================================================ + + internal static void ReplaceStringInBody( + Body body, string oldValue, Func factory, StringComparison comparisonType) + { + if (body is null || factory is null) return; + ParsePattern(oldValue, out var prefix, out var suffix); + + var paragraphs = body.Elements().ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + paragraphs[i].SimpleReplaceDynamic(prefix, suffix, factory, comparisonType); + } + } + + internal static void ReplaceStringInTables( + OpenXmlElement element, string oldValue, Func factory, StringComparison comparisonType) + { + if (element is null || factory is null) return; + ParsePattern(oldValue, out var prefix, out var suffix); + + foreach (var table in TableTextProcessor.GetAllTables(element)) + { + var paragraphs = TableTextProcessor.GetTableParagraphs(table).ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + paragraphs[i].SimpleReplaceDynamic(prefix, suffix, factory, comparisonType); + } + } + } + + internal static void ReplaceStringInHeadersFooters( + WordprocessingDocument doc, string oldValue, Func factory, StringComparison comparisonType) + { + if (doc is null || factory is null) return; + ParsePattern(oldValue, out var prefix, out var suffix); + + foreach (var header in HeaderFooterProcessor.GetAllHeaders(doc)) + ReplaceStringInElement(header, prefix, suffix, factory, comparisonType); + + foreach (var footer in HeaderFooterProcessor.GetAllFooters(doc)) + ReplaceStringInElement(footer, prefix, suffix, factory, comparisonType); + } + + private static void ReplaceStringInElement( + OpenXmlElement element, string prefix, string suffix, + Func factory, StringComparison comparisonType) + { + var paragraphs = element.Descendants().ToList(); + var tables = TableTextProcessor.GetAllTables(element).ToList(); + var tableParagraphs = tables.SelectMany(t => TableTextProcessor.GetTableParagraphs(t)).ToList(); + + var allParagraphs = paragraphs.Concat(tableParagraphs).Distinct().ToList(); + for (int i = allParagraphs.Count - 1; i >= 0; i--) + { + allParagraphs[i].SimpleReplaceDynamic(prefix, suffix, factory, comparisonType); + } + } + + internal static bool SimpleReplaceDynamic( + this Paragraph? paragraph, + string prefix, + string suffix, + Func factory, + StringComparison comparisonType) + { + if (paragraph is null || factory is null) return false; + + var text = paragraph.InnerText; + if (string.IsNullOrEmpty(text)) return false; + + var matches = FindMatches(text, prefix, suffix, comparisonType); + if (matches.Count == 0) return false; + + // Обходим справа налево: правые замены не сдвигают координаты левых. + for (int i = matches.Count - 1; i >= 0; i--) + { + var m = matches[i]; + string matchedText = text.Substring(m.Start, m.End - m.Start); + string replacement = factory(m.Param) ?? string.Empty; + + paragraph.SimpleReplace(matchedText, replacement, comparisonType); + } + return true; + } + + // ============================================================ + // REPLACE ITEM (Func) + // ============================================================ + + internal static void ReplaceItemInBody( + Body body, string oldValue, Func factory, StringComparison comparisonType) + { + if (body is null || factory is null) return; + ParsePattern(oldValue, out var prefix, out var suffix); + + var paragraphs = body.Elements().ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + paragraphs[i].SimpleReplaceItemDynamic(prefix, suffix, factory, comparisonType); + } + } + + internal static void ReplaceItemInTables( + OpenXmlElement element, string oldValue, Func factory, StringComparison comparisonType) + { + if (element is null || factory is null) return; + ParsePattern(oldValue, out var prefix, out var suffix); + + foreach (var table in TableTextProcessor.GetAllTables(element)) + { + var paragraphs = TableTextProcessor.GetTableParagraphs(table).ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + paragraphs[i].SimpleReplaceItemDynamic(prefix, suffix, factory, comparisonType); + } + } + } + + internal static void ReplaceItemInHeadersFooters( + WordprocessingDocument doc, string oldValue, Func factory, StringComparison comparisonType) + { + if (doc is null || factory is null) return; + ParsePattern(oldValue, out var prefix, out var suffix); + + foreach (var header in HeaderFooterProcessor.GetAllHeaders(doc)) + ReplaceItemInElement(header, prefix, suffix, factory, comparisonType); + + foreach (var footer in HeaderFooterProcessor.GetAllFooters(doc)) + ReplaceItemInElement(footer, prefix, suffix, factory, comparisonType); + } + + private static void ReplaceItemInElement( + OpenXmlElement element, string prefix, string suffix, + Func factory, StringComparison comparisonType) + { + var paragraphs = element.Descendants().ToList(); + var tables = TableTextProcessor.GetAllTables(element).ToList(); + var tableParagraphs = tables.SelectMany(t => TableTextProcessor.GetTableParagraphs(t)).ToList(); + + var allParagraphs = paragraphs.Concat(tableParagraphs).Distinct().ToList(); + for (int i = allParagraphs.Count - 1; i >= 0; i--) + { + allParagraphs[i].SimpleReplaceItemDynamic(prefix, suffix, factory, comparisonType); + } + } + + internal static bool SimpleReplaceItemDynamic( + this Paragraph? paragraph, + string prefix, + string suffix, + Func factory, + StringComparison comparisonType) + { + if (paragraph is null || factory is null) return false; + + var text = paragraph.InnerText; + if (string.IsNullOrEmpty(text)) return false; + + var matches = FindMatches(text, prefix, suffix, comparisonType); + if (matches.Count == 0) return false; + + for (int i = matches.Count - 1; i >= 0; i--) + { + var m = matches[i]; + string matchedText = text.Substring(m.Start, m.End - m.Start); + + ReplaceItem item = factory(m.Param); + if (item is null) continue; + + if (item.BreakValue.HasValue) + { + // Поддерживаются PageBreak / NewLandscapeSection / NewPortraitSection + paragraph.SimpleReplace(matchedText, string.Empty, comparisonType, item.BreakValue.Value); + } + else + { + paragraph.SimpleReplace(matchedText, item.Text ?? string.Empty, comparisonType); + } + } + return true; + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/DynamicReplaceToTextExt.cs b/QWERTYkez.WordProcessor/DynamicReplaceToTextExt.cs new file mode 100644 index 0000000..33cdc84 --- /dev/null +++ b/QWERTYkez.WordProcessor/DynamicReplaceToTextExt.cs @@ -0,0 +1,214 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Вспомогательный класс для динамической замены параграфов, содержащих "$key|param$", +/// на текст, построенный через . +/// +internal static class DynamicReplaceToTextExt +{ + internal static void ReplaceParagraphsContainingTextToTextDynamic( + Body body, + string oldValue, + Action buildText) + { + if (body is null || string.IsNullOrEmpty(oldValue) || buildText is null) + return; + + DynamicReplaceExt.ParsePattern(oldValue, out var prefix, out var suffix); + + var paragraphs = body.Elements().ToList(); + if (paragraphs.Count == 0) + return; + + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var paragraph = paragraphs[i]; + if (paragraph is null) continue; + + var paraText = paragraph.InnerText; + if (string.IsNullOrEmpty(paraText)) continue; + + int pStart = paraText.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (pStart < 0) continue; + + int paramStart = pStart + prefix.Length; + int pEnd = string.IsNullOrEmpty(suffix) + ? paraText.Length + : paraText.IndexOf(suffix, paramStart, StringComparison.OrdinalIgnoreCase); + if (pEnd < 0) continue; + + string param = paraText.Substring(paramStart, pEnd - paramStart); + string matchedText = paraText.Substring(pStart, pEnd + suffix.Length - pStart); + + var fontProps = ExtractFontPropsFromParagraph(paragraph, matchedText); + var paragraphProps = paragraph.ParagraphProperties?.CloneNode(true) as ParagraphProperties; + + var capturedParam = param; + ReplaceWithTextBuilder(paragraph, tb => buildText(capturedParam, tb), fontProps, paragraphProps); + } + } + + // ------------------------------------------------------------ + // Ниже — 1:1 копия из ReplaceToTextExt (private helper-методы) + // ------------------------------------------------------------ + + private static void ReplaceWithTextBuilder( + Paragraph paragraph, + Action buildText, + FontProps? baseFont, + ParagraphProperties? baseParagraphProps) + { + if (paragraph is null || paragraph.Parent is null || buildText is null) + return; + + var parent = paragraph.Parent; + + var builder = TextBuilder.Create(baseFont, baseParagraphProps); + buildText(builder); + var newParagraphs = builder.Build(); + + int paraIndex = -1; + var children = parent.ChildElements; + + for (int i = 0; i < children.Count; i++) + { + if (children[i] == paragraph) + { + paraIndex = i; + break; + } + } + + if (paraIndex == -1) + return; + + parent.RemoveChild(paragraph); + + for (int i = 0; i < newParagraphs.Count; i++) + { + parent.InsertAt(newParagraphs[i], paraIndex + i); + } + } + + private static FontProps? ExtractFontPropsFromParagraph(Paragraph paragraph, string searchText) + { + if (paragraph is null || string.IsNullOrEmpty(searchText)) + return null; + + var runs = paragraph.Descendants().ToList(); + if (runs.Count == 0) + return null; + + var runInfos = new List<(Run Run, int Start, int End, string Text)>(); + int currentPosition = 0; + + foreach (var run in runs) + { + var runText = GetRunText(run); + if (!string.IsNullOrEmpty(runText)) + { + runInfos.Add((run, currentPosition, currentPosition + runText.Length, runText)); + currentPosition += runText.Length; + } + } + + foreach (var (run, start, end, text) in runInfos) + { + if (text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0) + { + return CreateFontPropsFromRun(run); + } + } + + var fullText = string.Concat(runInfos.Select(r => r.Text)); + int textIndex = fullText.IndexOf(searchText, StringComparison.OrdinalIgnoreCase); + + if (textIndex >= 0) + { + var intersectingRun = runInfos.FirstOrDefault(r => + r.Start <= textIndex && r.End > textIndex); + + if (intersectingRun.Run is not null) + { + return CreateFontPropsFromRun(intersectingRun.Run); + } + } + + return null; + } + + private static FontProps CreateFontPropsFromRun(Run run) + { + var runProperties = run.RunProperties; + if (runProperties is null) + return new FontProps(); + + var fontProps = new FontProps(); + + var runFonts = runProperties.GetFirstChild(); + if (runFonts is not null) + { + fontProps = fontProps with + { + FontFamily = runFonts.Ascii ?? runFonts.HighAnsi ?? runFonts.ComplexScript + }; + } + + var fontSize = runProperties.GetFirstChild(); + if (fontSize is not null && !string.IsNullOrEmpty(fontSize.Val) && + uint.TryParse(fontSize.Val, out uint halfPoints)) + { + fontProps = fontProps with { Size = halfPoints / 2.0 }; + } + + var bold = runProperties.GetFirstChild(); + if (bold is not null) + { + bool isBold = true; + if (bold.Val is not null) + isBold = bold.Val.Value; + fontProps = fontProps with { IsBold = isBold }; + } + + var italic = runProperties.GetFirstChild(); + if (italic is not null) + { + bool isItalic = true; + if (italic.Val is not null) + isItalic = italic.Val.Value; + fontProps = fontProps with { IsItalic = isItalic }; + } + + var underline = runProperties.GetFirstChild(); + if (underline is not null && underline.Val is not null) + { + fontProps = fontProps with { Underline = underline.Val.Value }; + } + + var color = runProperties.GetFirstChild(); + if (color is not null && !string.IsNullOrEmpty(color.Val)) + { + fontProps = fontProps with { Color = color.Val }; + } + + var verticalAlignment = runProperties.GetFirstChild(); + if (verticalAlignment is not null && verticalAlignment.Val is not null) + { + fontProps = fontProps with { SubSup = verticalAlignment.Val.Value }; + } + + return fontProps; + } + + private static string GetRunText(Run run) + { + if (run is null) return string.Empty; + + var sb = new StringBuilder(); + foreach (var text in run.Elements()) + { + sb.Append(text.Text ?? string.Empty); + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/WordWriter.cs b/QWERTYkez.WordProcessor/WordWriter.cs index c12f5de..305aedd 100644 --- a/QWERTYkez.WordProcessor/WordWriter.cs +++ b/QWERTYkez.WordProcessor/WordWriter.cs @@ -337,6 +337,135 @@ internal sealed class WordWriter : WordReader, IWordWriter #endregion + + + + + + + + + + public void ReplaceString(string oldValue, Func newValues) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (newValues is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.ReplaceString(dynamic)] START: '{oldValue}'"); +#endif + + lock (_syncLock) + { + // 1. Основной текст + DynamicReplaceExt.ReplaceStringInBody(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + DynamicReplaceExt.ReplaceStringInTables(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + DynamicReplaceExt.ReplaceStringInHeadersFooters(_doc, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + public void ReplaceItem(string oldValue, Func newValues) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (newValues is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.ReplaceItem(dynamic)] START: '{oldValue}'"); +#endif + + lock (_syncLock) + { + // 1. Основной текст + DynamicReplaceExt.ReplaceItemInBody(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + DynamicReplaceExt.ReplaceItemInTables(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + DynamicReplaceExt.ReplaceItemInHeadersFooters(_doc, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + public void ReplaceToText(string oldValue, Action buildText) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (buildText is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.ReplaceToText(dynamic)] START: '{oldValue}'"); +#endif + + lock (_syncLock) + { + DynamicReplaceToTextExt.ReplaceParagraphsContainingTextToTextDynamic(_body, oldValue, buildText); + _isModified = true; + } + } + + + + + + + + + public void ReplaceString(string oldValue, Func> newValues) + { + throw new NotImplementedException(); + } + + public void ReplaceString(string oldValue, Func newValues) + { + throw new NotImplementedException(); + } + + public void ReplaceItem(string oldValue, Func> newValues) + { + throw new NotImplementedException(); + } + + public void ReplaceItem(string oldValue, Func newValues) + { + throw new NotImplementedException(); + } + + public void ReplaceToTable(string oldValue, Action buildTable) + { + throw new NotImplementedException(); + } + + public void ReplaceToTable(string oldValue, FontProps baseFont, Action buildTable) + { + throw new NotImplementedException(); + } + + + + + + + + /// /// Добавляет новый параграф с указанным текстом в конец документа. ///