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();
+ }
+
+
+
+
+
+
+
+
///
/// Добавляет новый параграф с указанным текстом в конец документа.
///