diff --git a/QWERTYkez.WordProcessor/DynamicMultiReplaceExt.cs b/QWERTYkez.WordProcessor/DynamicMultiReplaceExt.cs new file mode 100644 index 0000000..865a0de --- /dev/null +++ b/QWERTYkez.WordProcessor/DynamicMultiReplaceExt.cs @@ -0,0 +1,220 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Множественные динамические замены с извлечением параметра из "$key|param$". +/// Переиспользует движок (форматирование Run-ов, +/// разрывы страниц, смена ориентации, разбиение на параграфы — всё как в оригинале). +/// +internal static class DynamicMultiReplaceExt +{ + // ============================================================ + // ReplaceString (Func>) + // ============================================================ + + internal static void ReplaceStringMultiInBody( + Body body, + string oldValue, + Func> factory, + StringComparison comparisonType) + { + if (body is null || factory is null) return; + DynamicReplaceExt.ParsePattern(oldValue, out var prefix, out var suffix); + + var paragraphs = body.Elements().ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + ProcessDynamicStringMulti(paragraphs[i], prefix, suffix, factory, comparisonType); + } + + internal static void ReplaceStringMultiInTables( + OpenXmlElement element, + string oldValue, + Func> factory, + StringComparison comparisonType) + { + if (element is null || factory is null) return; + DynamicReplaceExt.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--) + ProcessDynamicStringMulti(paragraphs[i], prefix, suffix, factory, comparisonType); + } + } + + internal static void ReplaceStringMultiInHeadersFooters( + WordprocessingDocument doc, + string oldValue, + Func> factory, + StringComparison comparisonType) + { + if (doc is null || factory is null) return; + DynamicReplaceExt.ParsePattern(oldValue, out var prefix, out var suffix); + + foreach (var header in HeaderFooterProcessor.GetAllHeaders(doc)) + ProcessStringMultiInElement(header, prefix, suffix, factory, comparisonType); + foreach (var footer in HeaderFooterProcessor.GetAllFooters(doc)) + ProcessStringMultiInElement(footer, prefix, suffix, factory, comparisonType); + } + + private static void ProcessStringMultiInElement( + 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--) + ProcessDynamicStringMulti(allParagraphs[i], prefix, suffix, factory, comparisonType); + } + + private static void ProcessDynamicStringMulti( + Paragraph paragraph, + string prefix, + string suffix, + Func> factory, + StringComparison comparisonType) + { + if (paragraph is null || paragraph.Parent is null) return; + + var text = paragraph.InnerText; + if (string.IsNullOrEmpty(text)) return; + + var matches = DynamicReplaceExt.FindMatches(text, prefix, suffix, comparisonType); + if (matches.Count == 0) return; + + // Строим словарь уникальных matchedText -> значения для этого параграфа + var dict = new Dictionary>(); + for (int i = 0; i < matches.Count; i++) + { + var m = matches[i]; + string matchedText = text.Substring(m.Start, m.End - m.Start); + if (dict.ContainsKey(matchedText)) continue; + + IEnumerable values; + try { values = factory(m.Param); } + catch { continue; } + if (values is null) continue; + + var list = values as IList ?? values.ToList(); + if (list.Count == 0) continue; + + dict[matchedText] = list; + } + if (dict.Count == 0) return; + + var newParas = MultiReplaceExt.ProcessParagraphWithMergedBoundaries(paragraph, dict, comparisonType); + if (newParas is not null && newParas.Count > 0) + ParagraphReplacer.ReplaceParagraph(paragraph, newParas); + } + + // ============================================================ + // ReplaceItem (Func>) + // ============================================================ + + internal static void ReplaceItemMultiInBody( + Body body, + string oldValue, + Func> factory, + StringComparison comparisonType) + { + if (body is null || factory is null) return; + DynamicReplaceExt.ParsePattern(oldValue, out var prefix, out var suffix); + + var paragraphs = body.Elements().ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + ProcessDynamicItemMulti(paragraphs[i], prefix, suffix, factory, comparisonType); + } + + internal static void ReplaceItemMultiInTables( + OpenXmlElement element, + string oldValue, + Func> factory, + StringComparison comparisonType) + { + if (element is null || factory is null) return; + DynamicReplaceExt.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--) + ProcessDynamicItemMulti(paragraphs[i], prefix, suffix, factory, comparisonType); + } + } + + internal static void ReplaceItemMultiInHeadersFooters( + WordprocessingDocument doc, + string oldValue, + Func> factory, + StringComparison comparisonType) + { + if (doc is null || factory is null) return; + DynamicReplaceExt.ParsePattern(oldValue, out var prefix, out var suffix); + + foreach (var header in HeaderFooterProcessor.GetAllHeaders(doc)) + ProcessItemMultiInElement(header, prefix, suffix, factory, comparisonType); + foreach (var footer in HeaderFooterProcessor.GetAllFooters(doc)) + ProcessItemMultiInElement(footer, prefix, suffix, factory, comparisonType); + } + + private static void ProcessItemMultiInElement( + 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--) + ProcessDynamicItemMulti(allParagraphs[i], prefix, suffix, factory, comparisonType); + } + + private static void ProcessDynamicItemMulti( + Paragraph paragraph, + string prefix, + string suffix, + Func> factory, + StringComparison comparisonType) + { + if (paragraph is null || paragraph.Parent is null) return; + + var text = paragraph.InnerText; + if (string.IsNullOrEmpty(text)) return; + + var matches = DynamicReplaceExt.FindMatches(text, prefix, suffix, comparisonType); + if (matches.Count == 0) return; + + var dict = new Dictionary>(); + for (int i = 0; i < matches.Count; i++) + { + var m = matches[i]; + string matchedText = text.Substring(m.Start, m.End - m.Start); + if (dict.ContainsKey(matchedText)) continue; + + IEnumerable values; + try { values = factory(m.Param); } + catch { continue; } + if (values is null) continue; + + var list = values as IList ?? values.ToList(); + if (list.Count == 0) continue; + + dict[matchedText] = list; + } + if (dict.Count == 0) return; + + var newParas = MultiReplaceExt.ProcessParagraphWithMergedBoundaries(paragraph, dict, comparisonType); + if (newParas is not null && newParas.Count > 0) + ParagraphReplacer.ReplaceParagraph(paragraph, newParas); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/MultiReplace.cs b/QWERTYkez.WordProcessor/MultiReplace.cs index c1355fe..c1cb152 100644 --- a/QWERTYkez.WordProcessor/MultiReplace.cs +++ b/QWERTYkez.WordProcessor/MultiReplace.cs @@ -402,7 +402,7 @@ internal static class MultiReplaceExt // ---------- ОСНОВНОЙ АЛГОРИТМ ---------- - private static List? ProcessMultiReplacements( + internal static List? ProcessMultiReplacements( Paragraph original, IEnumerable>>? stringReplacements, IEnumerable>>? itemReplacements, @@ -848,4 +848,352 @@ internal static class MultiReplaceExt } } } + + + // ============================================================ + // НОВЫЕ ПУБЛИЧНЫЕ ТОЧКИ ВХОДА (mergeBoundaryText: true) + // ============================================================ + + internal static List? ProcessParagraphWithMergedBoundaries( + Paragraph paragraph, + IEnumerable>> replacements, + StringComparison comparisonType) + { + return ProcessMultiReplacements(paragraph, replacements, null, comparisonType, mergeBoundaryText: true); + } + + internal static List? ProcessParagraphWithMergedBoundaries( + Paragraph paragraph, + IEnumerable>> replacements, + StringComparison comparisonType) + { + return ProcessMultiReplacements(paragraph, null, replacements, comparisonType, mergeBoundaryText: true); + } + + // ============================================================ + // ОСНОВНОЙ АЛГОРИТМ (изменена только сигнатура + 4 места) + // ============================================================ + + private static List? ProcessMultiReplacements( + Paragraph original, + IEnumerable>>? stringReplacements, + IEnumerable>>? itemReplacements, + StringComparison comparisonType, + bool mergeBoundaryText = false) // <-- новый параметр + { + var body = original.Ancestors().FirstOrDefault(); + + bool hasEmptyParagraphsBefore = false; + var prevPara = original.PreviousSibling(); + while (prevPara is not null) + { + if (!string.IsNullOrEmpty(prevPara.InnerText)) break; + hasEmptyParagraphsBefore = true; + prevPara = prevPara.PreviousSibling(); + } + + SectionProperties? followingSection = null; + if (original.ParagraphProperties?.GetFirstChild()?.CloneNode(true) is not SectionProperties sourceSection) + { + var nextPara = original.NextSibling(); + if (nextPara is not null) + { + followingSection = nextPara.ParagraphProperties?.GetFirstChild()?.CloneNode(true) as SectionProperties; + } + } + else + { + followingSection = sourceSection; + } + followingSection ??= new SectionProperties(); + + // 1. Сбор определений + var definitions = new List(); + if (stringReplacements is not null) + { + foreach (var kvp in stringReplacements) + { + if (string.IsNullOrEmpty(kvp.Key) || kvp.Value is null || kvp.Value.Count() == 0) continue; + var items = kvp.Value.Select(v => new ReplaceItem(v)); + definitions.Add(new MatchDefinition(kvp.Key, items)); + } + } + if (itemReplacements is not null) + { + foreach (var kvp in itemReplacements) + { + if (string.IsNullOrEmpty(kvp.Key) || kvp.Value is null || kvp.Value.Count() == 0) continue; + definitions.Add(new MatchDefinition(kvp.Key, kvp.Value)); + } + } + if (definitions.Count == 0) return null; + + // 2. Анализ структуры параграфа + var runs = original.Descendants().ToList(); + if (runs.Count == 0) return null; + var structure = AnalyzeParagraphStructure(runs); + string fullText = structure.FullText; + if (fullText.Length == 0) return null; + + // 3. Поиск всех вхождений + var matches = new List(); + foreach (var def in definitions) + { + int pos = 0; + while ((pos = fullText.IndexOf(def.Key, pos, comparisonType)) != -1) + { + matches.Add(new Match { Definition = def, Start = pos, End = pos + def.Key.Length }); + pos += def.Key.Length; + } + } + if (matches.Count == 0) return null; + matches.Sort((a, b) => a.Start.CompareTo(b.Start)); + + // 4. Книжная секция для копирования полей + SectionProperties? portraitSection = null; + if (body is not null) + portraitSection = body.Elements().FirstOrDefault()?.CloneNode(true) as SectionProperties; + portraitSection ??= new SectionProperties(); + + // 5. Построение результата + var resultParas = new List(); + Paragraph? currentPara = null; + int currentPos = 0; + bool sectionChangeInsideGroup = false; + BreakType? lastOrientation = null; + + bool pageBreakBeforeNext = false; + BreakType? pendingOrientation = null; + bool firstSplitMarkerHandled = false; + int textCount = 0; + Paragraph? lastTextPara = null; + bool emptyParagraphCreated = false; + + // НОВОЕ: буфер runs для склейки "before"-текста с первым value-параграфом + List? pendingBeforeRuns = null; + + for (int i = 0; i < matches.Count; i++) + { + var match = matches[i]; + + // ---- Текст перед совпадением ---- + if (currentPos < match.Start) + { + var beforePara = CloneParagraphWithoutSection(original); + foreach (var seg in structure.Segments) + { + if (seg.End <= currentPos) continue; + if (seg.Start >= match.Start) break; + if (seg.Start >= currentPos && seg.End <= match.Start) + { + beforePara.AppendChild(seg.Run.CloneNode(true)); + } + else if (seg.Start < match.Start && seg.End > currentPos) + { + int startOffset = Math.Max(0, currentPos - seg.Start); + int endOffset = Math.Min(seg.Text.Length, match.Start - seg.Start); + var runClone = (Run)seg.Run.CloneNode(true); + foreach (var t in runClone.Elements().ToList()) t.Remove(); + string textPart = seg.Text.Substring(startOffset, endOffset - startOffset); + runClone.AppendChild(new Text(textPart)); + beforePara.AppendChild(runClone); + } + } + + if (beforePara.ChildElements.OfType().Any()) + { + if (!mergeBoundaryText) + { + resultParas.Add(beforePara); + currentPara = beforePara; + lastTextPara = beforePara; + } + else + { + // Копим runs, склеим их с первым value-параграфом + pendingBeforeRuns = new List(); + foreach (var r in beforePara.Elements()) + pendingBeforeRuns.Add((Run)r.CloneNode(true)); + } + } + } + // ---- /Текст перед совпадением ---- + + var values = match.Definition.Values.ToList(); + if (values.Count == 0) continue; + + // Fast-path (одиночный простой текст) — оставлен как есть. + // В merge-режиме пропускаем, чтобы before/remainder могли склеиться. + if (!mergeBoundaryText + && values.Count == 1 + && !string.IsNullOrEmpty(values[0].Text) + && !values[0].BreakValue.HasValue) + { + var newText = values[0].Text; + var matchSeg = structure.Segments.FirstOrDefault(s => s.Start >= match.Start && s.End <= match.End); + if (matchSeg is not null) + { + var run = matchSeg.Run; + foreach (var textElem in run.Elements().ToList()) + textElem.Remove(); + run.AppendChild(new Text(newText)); + return null; + } + } + + // Сброс флагов для новой группы + pageBreakBeforeNext = false; + pendingOrientation = null; + firstSplitMarkerHandled = false; + textCount = 0; + lastTextPara = null; + emptyParagraphCreated = false; + + foreach (var item in values) + { + if (!string.IsNullOrEmpty(item.Text)) + { + ProcessTextItem( + item.Text, + ref pendingOrientation, + ref currentPara, + ref lastTextPara, + ref textCount, + ref pageBreakBeforeNext, + ref sectionChangeInsideGroup, + ref lastOrientation, + original, + structure, + match.Start, + followingSection, + portraitSection, + resultParas); + + // НОВОЕ: вклеиваем before-runs в только что созданный параграф + if (mergeBoundaryText && pendingBeforeRuns is not null && currentPara is not null) + { + for (int j = pendingBeforeRuns.Count - 1; j >= 0; j--) + currentPara.InsertAt((Run)pendingBeforeRuns[j].CloneNode(true), 0); + pendingBeforeRuns = null; + } + } + else if (item.BreakValue.HasValue) + { + ProcessBreakItem( + item.BreakValue.Value, + ref pendingOrientation, + ref currentPara, + ref lastTextPara, + ref pageBreakBeforeNext, + ref firstSplitMarkerHandled, + ref sectionChangeInsideGroup, + ref lastOrientation, + ref textCount, + ref emptyParagraphCreated, + hasEmptyParagraphsBefore, + original, + followingSection, + portraitSection, + resultParas); + + // НОВОЕ: если создался параграф под break (landscape/portrait), тоже вклеим + if (mergeBoundaryText && pendingBeforeRuns is not null && currentPara is not null) + { + // вставляем before-runs ПЕРЕД любыми break-runs + for (int j = pendingBeforeRuns.Count - 1; j >= 0; j--) + currentPara.InsertAt((Run)pendingBeforeRuns[j].CloneNode(true), 0); + pendingBeforeRuns = null; + } + } + } + + // Закрытие секции (без изменений) + if (sectionChangeInsideGroup && lastOrientation.HasValue && currentPara is not null) + { + if (currentPara.ParagraphProperties?.GetFirstChild() is null) + { + bool addPageSize = (lastOrientation.Value == BreakType.NewLandscapeSection); + AddSectionProperties(currentPara, lastOrientation.Value, addPageSize, followingSection, portraitSection); + } + } + + if (!sectionChangeInsideGroup && currentPara is not null) + { + var sourcePageSize = followingSection.GetFirstChild(); + if (sourcePageSize is not null) + { + if (currentPara.ParagraphProperties?.GetFirstChild() is null) + { + currentPara.ParagraphProperties ??= new ParagraphProperties(); + currentPara.ParagraphProperties.AppendChild(followingSection.CloneNode(true)); + } + } + } + + EnsureBodyLandscapeSection(body!, followingSection, sectionChangeInsideGroup, lastOrientation); + + currentPos = match.End; + } + + // ---- Обработка остатка текста ---- + if (currentPos < fullText.Length) + { + var remainderPara = BuildRemainderParagraph(original, structure, currentPos); + if (remainderPara is not null) + { + bool canMerge = mergeBoundaryText + && resultParas.Count > 0 + && !pendingOrientation.HasValue + && !sectionChangeInsideGroup; + + if (canMerge) + { + // Вклеиваем runs остатка в последний параграф + var lastPara = resultParas[resultParas.Count - 1]; + foreach (var r in remainderPara.Elements().ToList()) + lastPara.AppendChild(r.CloneNode(true)); + } + else + { + // Оригинальное поведение + if (pendingOrientation.HasValue) + { + var breakRun = new Run(new Break { Type = BreakValues.Page }); + remainderPara.InsertAt(breakRun, 0); + bool addPageSize = (pendingOrientation.Value == BreakType.NewLandscapeSection); + AddSectionProperties(remainderPara, pendingOrientation.Value, addPageSize, followingSection, portraitSection); + pendingOrientation = null; + } + else if (sectionChangeInsideGroup && lastOrientation.HasValue) + { + var breakRun = new Run(new Break { Type = BreakValues.Page }); + remainderPara.InsertAt(breakRun, 0); + bool addPageSize = (lastOrientation.Value == BreakType.NewLandscapeSection); + AddSectionProperties(remainderPara, lastOrientation.Value, addPageSize, followingSection, portraitSection); + } + else + { + var sourcePageSize = followingSection.GetFirstChild(); + if (sourcePageSize is not null) + { + remainderPara.ParagraphProperties ??= new ParagraphProperties(); + remainderPara.ParagraphProperties.AppendChild(followingSection.CloneNode(true)); + } + } + resultParas.Add(remainderPara); + } + } + } + // ---- /Обработка остатка текста ---- + + // 7. Очистка пустых параграфов + for (int i = resultParas.Count - 1; i >= 0; i--) + { + var p = resultParas[i]; + if (!p.ChildElements.OfType().Any() && p.ParagraphProperties is null) + resultParas.RemoveAt(i); + } + + return resultParas.Count > 0 ? resultParas : null; + } } \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/WordWriter.cs b/QWERTYkez.WordProcessor/WordWriter.cs index 305aedd..f5dbf9c 100644 --- a/QWERTYkez.WordProcessor/WordWriter.cs +++ b/QWERTYkez.WordProcessor/WordWriter.cs @@ -344,7 +344,7 @@ internal sealed class WordWriter : WordReader, IWordWriter - + #region динамические змены с параметром public void ReplaceString(string oldValue, Func newValues) { @@ -373,7 +373,6 @@ internal sealed class WordWriter : WordReader, IWordWriter _isModified = true; } } - public void ReplaceItem(string oldValue, Func newValues) { ThrowIfDisposed(); @@ -401,7 +400,6 @@ internal sealed class WordWriter : WordReader, IWordWriter _isModified = true; } } - public void ReplaceToText(string oldValue, Action buildText) { ThrowIfDisposed(); @@ -422,45 +420,75 @@ internal sealed class WordWriter : WordReader, IWordWriter } } - - - - - - - + public void ReplaceString(string oldValue, Func newValues) + => ReplaceString(oldValue, (Func>)newValues); public void ReplaceString(string oldValue, Func> newValues) { - throw new NotImplementedException(); - } + ThrowIfDisposed(); - public void ReplaceString(string oldValue, Func newValues) - { - throw new NotImplementedException(); - } + 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-multi)] START: '{oldValue}'"); +#endif + + lock (_syncLock) + { + // 1. Основной текст + DynamicMultiReplaceExt.ReplaceStringMultiInBody(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + DynamicMultiReplaceExt.ReplaceStringMultiInTables(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + DynamicMultiReplaceExt.ReplaceStringMultiInHeadersFooters(_doc, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + public void ReplaceItem(string oldValue, Func newValues) + => ReplaceItem(oldValue, (Func>)newValues); public void ReplaceItem(string oldValue, Func> newValues) { - throw new NotImplementedException(); - } + ThrowIfDisposed(); - public void ReplaceItem(string oldValue, Func newValues) - { - throw new NotImplementedException(); + 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-multi)] START: '{oldValue}'"); +#endif + + lock (_syncLock) + { + // 1. Основной текст + DynamicMultiReplaceExt.ReplaceItemMultiInBody(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + DynamicMultiReplaceExt.ReplaceItemMultiInTables(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + DynamicMultiReplaceExt.ReplaceItemMultiInHeadersFooters(_doc, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } } public void ReplaceToTable(string oldValue, Action buildTable) { throw new NotImplementedException(); } - public void ReplaceToTable(string oldValue, FontProps baseFont, Action buildTable) { throw new NotImplementedException(); } - - + #endregion