Files
QWERTYkez.OpenXmlProcessors/QWERTYkez.WordProcessor/MultiReplace.cs
T
2026-08-03 16:11:54 +07:00

652 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace QWERTYkez.WordProcessor;
#if DEBUG
public static class Debugger
{
public static StringBuilder Builder { get; } = new();
}
#endif
/// <summary>
/// Выполняет замену всех вхождений ключей из словаря на соответствующие массивы значений.
/// Каждое значение из массива помещается в отдельный параграф, причём первое значение
/// остаётся в текущем параграфе, а последующие создают новые.
/// Текст между вхождениями и после последнего сохраняется в соответствующих параграфах.
/// Поддерживает разрывы страниц и смену ориентации (альбомная/книжная) через <see cref="ReplaceItem.SplitValue"/>.
/// </summary>
internal static class MultiReplaceExt
{
// ---------- ПУБЛИЧНЫЕ МЕТОДЫ (для Body) ----------
#region Body.Replace с одним ключом
internal static void Replace(this Body body, string oldValue, IEnumerable<string> newValues, StringComparison comparisonType)
{
if (body is null || string.IsNullOrEmpty(oldValue) || newValues is null) return;
var dict = new Dictionary<string, IEnumerable<string>> { { oldValue, newValues } };
body.Replace(dict, comparisonType);
}
internal static void Replace(this Body body, string oldValue, IEnumerable<ReplaceItem> newValues, StringComparison comparisonType)
{
if (body is null || string.IsNullOrEmpty(oldValue) || newValues is null) return;
var dict = new Dictionary<string, IEnumerable<ReplaceItem>> { { oldValue, newValues } };
body.Replace(dict, comparisonType);
}
#endregion
#region Body.Replace со словарём массивов
internal static void Replace(this Body body, IEnumerable<KeyValuePair<string, IEnumerable<string>>> replacements, StringComparison comparisonType)
{
if (body is null || replacements is null) return;
var paragraphs = body.Elements<Paragraph>().ToList();
for (int i = paragraphs.Count - 1; i >= 0; i--)
{
var p = paragraphs[i];
if (p?.Parent is null) continue;
var newParas = ProcessMultiReplacements(p, replacements, null, comparisonType);
if (newParas is not null && newParas.Count > 0)
ParagraphReplacer.ReplaceParagraph(p, newParas);
}
}
internal static void Replace(this Body body, IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>> replacements, StringComparison comparisonType)
{
if (body is null || replacements is null) return;
var paragraphs = body.Elements<Paragraph>().ToList();
for (int i = paragraphs.Count - 1; i >= 0; i--)
{
var p = paragraphs[i];
if (p?.Parent is null) continue;
var newParas = ProcessMultiReplacements(p, null, replacements, comparisonType);
if (newParas is not null && newParas.Count > 0)
ParagraphReplacer.ReplaceParagraph(p, newParas);
}
}
#endregion
#region Paragraph.ReplaceWithMultiple (один ключ)
internal static bool ReplaceWithMultiple(this Paragraph? paragraph, string oldValue, IEnumerable<string> newValues, StringComparison comparisonType)
{
if (paragraph is null || string.IsNullOrEmpty(oldValue) || newValues is null || newValues.Count() == 0)
return false;
var dict = new Dictionary<string, IEnumerable<string>> { { oldValue, newValues } };
var newParas = ProcessMultiReplacements(paragraph, dict, null, comparisonType);
if (newParas is null || newParas.Count == 0) return false;
if (paragraph.Parent is not null)
{
ParagraphReplacer.ReplaceParagraph(paragraph, newParas);
return true;
}
return false;
}
internal static bool ReplaceWithMultiple(this Paragraph? paragraph, string oldValue, IEnumerable<ReplaceItem> newValues, StringComparison comparisonType)
{
if (paragraph is null || string.IsNullOrEmpty(oldValue) || newValues is null || newValues.Count() == 0)
return false;
var dict = new Dictionary<string, IEnumerable<ReplaceItem>> { { oldValue, newValues } };
var newParas = ProcessMultiReplacements(paragraph, null, dict, comparisonType);
if (newParas is null || newParas.Count == 0) return false;
if (paragraph.Parent is not null)
{
ParagraphReplacer.ReplaceParagraph(paragraph, newParas);
return true;
}
return false;
}
#endregion
#region ProcessParagraphWithAllReplacements (для обратной совместимости)
internal static List<Paragraph>? ProcessParagraphWithAllReplacements(
Paragraph paragraph,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> replacements,
StringComparison comparisonType)
{
return ProcessMultiReplacements(paragraph, replacements, null, comparisonType);
}
internal static List<Paragraph>? ProcessParagraphWithAllReplacements(
Paragraph paragraph,
IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>> replacements,
StringComparison comparisonType)
{
return ProcessMultiReplacements(paragraph, null, replacements, comparisonType);
}
internal static List<Paragraph>? ProcessParagraphWithAllReplacements(
Paragraph paragraph,
IEnumerable<KeyValuePair<string, string>> replacements,
StringComparison comparisonType)
{
Dictionary<string, IEnumerable<string>> dict = replacements
.Where(kvp => !string.IsNullOrEmpty(kvp.Key))
.ToDictionary(kvp => kvp.Key, kvp => (IEnumerable<string>)[kvp.Value]);
return ProcessMultiReplacements(paragraph, dict, null, comparisonType);
}
#endregion
// ---------- ВНУТРЕННЯЯ РЕАЛИЗАЦИЯ ----------
private class MatchDefinition(string key, IEnumerable<ReplaceItem> values)
{
public string Key { get; } = key;
public IEnumerable<ReplaceItem> Values { get; } = values;
}
private class Match
{
public MatchDefinition Definition { get; set; } = null!;
public int Start { get; set; }
public int End { get; set; }
}
private class RunSegment(Run run, string text, int start, int end)
{
public Run Run { get; } = run;
public string Text { get; } = text;
public int Start { get; } = start;
public int End { get; } = end;
}
private class ParagraphStructure(string fullText, List<RunSegment> segments)
{
public string FullText { get; } = fullText;
public List<RunSegment> Segments { get; } = segments;
}
private static ParagraphStructure AnalyzeParagraphStructure(List<Run> runs)
{
var segments = new List<RunSegment>();
var sb = new StringBuilder();
int pos = 0;
foreach (var run in runs)
{
string text = GetRunText(run);
if (string.IsNullOrEmpty(text)) continue;
segments.Add(new RunSegment(run, text, pos, pos + text.Length));
sb.Append(text);
pos += text.Length;
}
return new ParagraphStructure(sb.ToString(), segments);
}
private static string GetRunText(Run run)
{
var sb = new StringBuilder();
foreach (var text in run.Elements<Text>())
sb.Append(text.Text);
return sb.ToString();
}
/// <summary>Клонирует параграф, удаляя все SectionProperties и PageBreakBefore.</summary>
private static Paragraph CloneParagraphWithoutSection(Paragraph original)
{
var newPara = new Paragraph();
if (original.ParagraphProperties is not null)
{
var newProps = new ParagraphProperties();
foreach (var child in original.ParagraphProperties.ChildElements)
{
if (child is not SectionProperties && child is not PageBreakBefore)
newProps.AppendChild(child.CloneNode(true));
}
newPara.ParagraphProperties = newProps;
}
return newPara;
}
private static Paragraph? BuildRemainderParagraph(Paragraph original, ParagraphStructure structure, int position)
{
if (position >= structure.FullText.Length) return null;
var newPara = CloneParagraphWithoutSection(original);
foreach (var seg in structure.Segments)
{
if (seg.End <= position) continue;
if (seg.Start >= position)
{
newPara.AppendChild(seg.Run.CloneNode(true));
}
else if (seg.End > position)
{
var runClone = (Run)seg.Run.CloneNode(true);
foreach (var t in runClone.Elements<Text>().ToList()) t.Remove();
int offset = position - seg.Start;
string newText = seg.Text.Substring(offset);
runClone.AppendChild(new Text(newText));
newPara.AppendChild(runClone);
}
}
foreach (var run in newPara.Descendants<Run>().Where(r => !r.HasChildren).ToList())
run.Remove();
if (!newPara.ChildElements.OfType<Run>().Any() && newPara.ParagraphProperties is null)
return null;
return newPara;
}
private static void InsertFormattedRun(Paragraph para, ReplaceItem item, ParagraphStructure structure, int position)
{
var seg = structure.Segments.FirstOrDefault(s => position >= s.Start && position < s.End);
if (seg is null) return;
var textRun = new Run();
if (seg.Run.RunProperties is not null)
textRun.RunProperties = (RunProperties)seg.Run.RunProperties.CloneNode(true);
textRun.AppendChild(new Text(item.Text ?? string.Empty));
para.AppendChild(textRun);
}
/// <summary>
/// Добавляет SectionProperties к параграфу. Все значения (PageSize, PageMargin) берутся из документа.
/// Для книжных секций (addPageSize=false) Orient не устанавливается (not set).
/// </summary>
private static void AddSectionProperties(Paragraph para, PageBreakType splitValue, bool addPageSize, SectionProperties sourceSection, SectionProperties portraitSection)
{
if (para is null) return;
// Удаляем существующие секции
if (para.ParagraphProperties is not null)
{
var sections = para.ParagraphProperties.Elements<SectionProperties>().ToList();
foreach (var sec in sections) sec.Remove();
var pageBreaks = para.ParagraphProperties.Elements<PageBreakBefore>().ToList();
foreach (var pb in pageBreaks) pb.Remove();
}
para.ParagraphProperties ??= new ParagraphProperties();
var sectionProps = new SectionProperties();
// Добавляем PageSize
SectionProperties? sizeSource = addPageSize ? sourceSection : portraitSection;
var sourcePageSize = sizeSource?.GetFirstChild<PageSize>();
if (sourcePageSize is null && portraitSection is not null)
sourcePageSize = portraitSection.GetFirstChild<PageSize>();
if (sourcePageSize is null && sourceSection is not null)
sourcePageSize = sourceSection.GetFirstChild<PageSize>();
if (sourcePageSize is not null)
{
var pageSizeClone = (PageSize)sourcePageSize.CloneNode(true);
bool sourceIsLandscape = pageSizeClone.Orient?.Value == PageOrientationValues.Landscape ||
(pageSizeClone.Width?.Value > pageSizeClone.Height?.Value);
bool targetIsLandscape = (addPageSize && splitValue == PageBreakType.NewLandscapeSection);
if (targetIsLandscape && !sourceIsLandscape)
{
pageSizeClone.SwapValues();
pageSizeClone.Orient = PageOrientationValues.Landscape;
}
else if (!targetIsLandscape && sourceIsLandscape)
{
pageSizeClone.SwapValues();
if (addPageSize)
pageSizeClone.Orient = PageOrientationValues.Portrait;
else
pageSizeClone.Orient = null; // не задаём Orient (not set)
}
else
{
if (targetIsLandscape)
pageSizeClone.Orient = PageOrientationValues.Landscape;
else if (addPageSize)
pageSizeClone.Orient = PageOrientationValues.Portrait;
else
pageSizeClone.Orient = null; // not set
}
sectionProps.AppendChild(pageSizeClone);
}
// Копируем PageMargin
PageMargin? marginToUse = null;
if (addPageSize)
{
var sourceMargin = sourceSection?.GetFirstChild<PageMargin>()
?? portraitSection?.GetFirstChild<PageMargin>();
if (sourceMargin is not null)
{
var marginClone = (PageMargin)sourceMargin.CloneNode(true);
var sourceOrient = sourceSection?.GetFirstChild<PageSize>()?.Orient;
bool sourceIsLandscape = sourceOrient?.Value == PageOrientationValues.Landscape ||
(sourceSection?.GetFirstChild<PageSize>()?.Width?.Value > sourceSection?.GetFirstChild<PageSize>()?.Height?.Value);
if ((splitValue == PageBreakType.NewLandscapeSection && !sourceIsLandscape) ||
(splitValue == PageBreakType.NewPortraitSection && sourceIsLandscape))
{
marginClone.SwapBottomRight();
}
marginToUse = marginClone;
}
}
else
{
var portraitMargin = portraitSection?.GetFirstChild<PageMargin>();
if (portraitMargin is not null)
marginToUse = (PageMargin)portraitMargin.CloneNode(true);
}
if (marginToUse is not null)
sectionProps.AppendChild(marginToUse);
sectionProps.AppendChild(new SectionType { Val = SectionMarkValues.NextPage });
para.ParagraphProperties.AppendChild(sectionProps);
}
private static void MergeParagraph(Paragraph target, Paragraph source)
{
foreach (var child in source.ChildElements)
target.AppendChild(child.CloneNode(true));
}
/// <summary>Выводит структуру документа: для каждого параграфа показывает текст, наличие секции, ориентацию, размеры и поля.</summary>
private static void LogDocumentStructure(Body body, string title)
{
#if DEBUG
Debugger.Builder.AppendLine($"=== {title} ===");
var paragraphs = body.Descendants<Paragraph>().ToList();
int index = 0;
foreach (var para in paragraphs)
{
var text = para.InnerText.Replace("\n", "\\n").Replace("\r", "\\r");
var section = para.ParagraphProperties?.GetFirstChild<SectionProperties>();
string sectionInfo = "None";
if (section is not null)
{
var pageSize = section.GetFirstChild<PageSize>();
string orient = pageSize?.Orient?.ToString() ?? "not set";
string sizeInfo = "";
if (pageSize is not null)
{
sizeInfo = $" Size: W={pageSize.Width?.Value}, H={pageSize.Height?.Value}";
}
var margins = section.GetFirstChild<PageMargin>();
string marginInfo = "";
if (margins is not null)
{
marginInfo = $" Margins: Top={margins.Top?.Value}, Bottom={margins.Bottom?.Value}, Left={margins.Left?.Value}, Right={margins.Right?.Value}";
}
sectionInfo = $"Orient={orient}{sizeInfo}{marginInfo}";
}
Debugger.Builder.AppendLine($" Para {index}: Text='{text}', Section={sectionInfo}");
index++;
}
// Логируем секции из Body (если есть)
var bodySections = body.Elements<SectionProperties>().ToList();
if (bodySections.Any())
{
Debugger.Builder.AppendLine(" Body SectionProperties:");
foreach (var sec in bodySections)
{
var ps = sec.GetFirstChild<PageSize>();
var pm = sec.GetFirstChild<PageMargin>();
Debugger.Builder.AppendLine($" PageSize: Width={ps?.Width}, Height={ps?.Height}, Orient={ps?.Orient}");
Debugger.Builder.AppendLine($" PageMargin: Top={pm?.Top}, Bottom={pm?.Bottom}, Left={pm?.Left}, Right={pm?.Right}");
}
}
Debugger.Builder.AppendLine($"=== END {title} ===");
#endif
}
/// <summary>Основной алгоритм множественной замены с поддержкой разрывов и смены ориентации.</summary>
private static List<Paragraph>? ProcessMultiReplacements(
Paragraph original,
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? stringReplacements,
IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>>? itemReplacements,
StringComparison comparisonType)
{
var body = original.Ancestors<Body>().FirstOrDefault();
// 1. Сбор определений
var definitions = new List<MatchDefinition>();
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, PageBreakType.None));
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<Run>().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<Match>();
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. Получаем исходную секцию для копирования (из original или из документа)
SectionProperties? sourceSection = original.ParagraphProperties?.GetFirstChild<SectionProperties>()?.CloneNode(true) as SectionProperties;
if (sourceSection is null && body is not null)
{
sourceSection = body.Elements<SectionProperties>().LastOrDefault()?.CloneNode(true) as SectionProperties;
}
sourceSection ??= new SectionProperties();
// Получаем книжную секцию для копирования полей (из документа)
SectionProperties? portraitSection = null;
if (body is not null)
{
portraitSection = body.Elements<SectionProperties>().FirstOrDefault()?.CloneNode(true) as SectionProperties;
}
portraitSection ??= new SectionProperties();
// 5. Построение результата
var resultParas = new List<Paragraph>();
Paragraph? currentPara = null;
int currentPos = 0;
bool sectionChangeInsideGroup = false;
PageBreakType? lastOrientation = 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<Text>().ToList()) t.Remove();
string textPart = seg.Text.Substring(startOffset, endOffset - startOffset);
runClone.AppendChild(new Text(textPart));
beforePara.AppendChild(runClone);
}
}
if (beforePara.ChildElements.OfType<Run>().Any())
{
resultParas.Add(beforePara);
currentPara = beforePara;
}
}
var values = match.Definition.Values.ToList();
if (values.Count == 0) continue;
for (int vIdx = 0; vIdx < values.Count; vIdx++)
{
var item = values[vIdx];
// Создаём новый параграф для каждого элемента
var newPara = CloneParagraphWithoutSection(original);
resultParas.Add(newPara);
currentPara = newPara;
InsertFormattedRun(currentPara, item, structure, match.Start);
// Обработка смены ориентации
if (item.SplitValue == PageBreakType.NewLandscapeSection || item.SplitValue == PageBreakType.NewPortraitSection)
{
bool addPageSize = (vIdx != 0);
PageBreakType orientation = item.SplitValue;
if (vIdx != 0)
{
orientation = PageBreakType.NewLandscapeSection;
}
AddSectionProperties(currentPara, orientation, addPageSize, sourceSection, portraitSection);
lastOrientation = item.SplitValue;
sectionChangeInsideGroup = true;
}
else if (item.SplitValue == PageBreakType.PageBreak)
{
var seg = structure.Segments.FirstOrDefault(s => match.Start >= s.Start && match.Start < s.End);
var breakRun = new Run(new Break { Type = BreakValues.Page });
if (seg is not null && seg.Run.RunProperties is not null)
breakRun.RunProperties = (RunProperties)seg.Run.RunProperties.CloneNode(true);
currentPara.AppendChild(breakRun);
}
}
// Закрываем секцию, если была смена и последний параграф не имеет секции
if (sectionChangeInsideGroup && lastOrientation.HasValue && currentPara is not null)
{
if (currentPara.ParagraphProperties?.GetFirstChild<SectionProperties>() is null)
{
bool addPageSize = (lastOrientation.Value == PageBreakType.NewLandscapeSection);
AddSectionProperties(currentPara, lastOrientation.Value, addPageSize, sourceSection, portraitSection);
}
}
// Если исходная секция была landscape, внутри группы была смена, и последняя ориентация — книжная,
// добавляем landscape секцию в Body, чтобы остаток остался landscape.
if (body is not null)
{
var sourcePageSize = sourceSection.GetFirstChild<PageSize>();
if (sourcePageSize is not null)
{
bool sourceIsLandscape = sourcePageSize.Orient?.Value == PageOrientationValues.Landscape ||
(sourcePageSize.Width?.Value > sourcePageSize.Height?.Value);
bool lastIsPortrait = lastOrientation.HasValue && lastOrientation.Value == PageBreakType.NewPortraitSection;
if (sourceIsLandscape && sectionChangeInsideGroup && lastIsPortrait)
{
// Проверяем, есть ли уже секция landscape на Body
bool hasBodyLandscape = false;
foreach (var bodySec in body.Elements<SectionProperties>())
{
var ps = bodySec.GetFirstChild<PageSize>();
if (ps is not null && ps.Orient?.Value == PageOrientationValues.Landscape)
{
hasBodyLandscape = true;
break;
}
}
if (!hasBodyLandscape)
{
// Создаём секцию landscape для Body
var bodySection = new SectionProperties();
var pageSizeClone = (PageSize)sourcePageSize.CloneNode(true);
// Если ориентация не landscape, меняем
if (pageSizeClone.Orient?.Value != PageOrientationValues.Landscape)
{
pageSizeClone.SwapValues();
pageSizeClone.Orient = PageOrientationValues.Landscape;
}
bodySection.AppendChild(pageSizeClone);
var sourceMargin = sourceSection.GetFirstChild<PageMargin>();
if (sourceMargin is not null)
{
var marginClone = (PageMargin)sourceMargin.CloneNode(true);
// Если исходная секция не landscape, но мы делаем landscape, меняем поля
if (sourceSection.GetFirstChild<PageSize>()?.Orient?.Value != PageOrientationValues.Landscape)
{
marginClone.SwapBottomRight();
}
bodySection.AppendChild(marginClone);
}
bodySection.AppendChild(new SectionType { Val = SectionMarkValues.NextPage });
body.AppendChild(bodySection);
}
}
}
}
currentPos = match.End;
}
// 6. Обработка остатка текста (если есть)
if (currentPos < fullText.Length)
{
var remainderPara = BuildRemainderParagraph(original, structure, currentPos);
if (remainderPara is not null)
{
// Если внутри группы была смена, применяем последнюю ориентацию к остатку
if (sectionChangeInsideGroup && lastOrientation.HasValue)
{
var breakRun = new Run(new Break { Type = BreakValues.Page });
remainderPara.InsertAt(breakRun, 0);
bool addPageSize = (lastOrientation.Value == PageBreakType.NewLandscapeSection);
AddSectionProperties(remainderPara, lastOrientation.Value, addPageSize, sourceSection, portraitSection);
}
resultParas.Add(remainderPara);
}
}
// 7. Очистка пустых параграфов
for (int i = resultParas.Count - 1; i >= 0; i--)
{
var p = resultParas[i];
if (!p.ChildElements.OfType<Run>().Any() && p.ParagraphProperties is null)
resultParas.RemoveAt(i);
}
// Логирование
#if DEBUG
if (body is not null)
LogDocumentStructure(body, "FINAL DOCUMENT STRUCTURE");
#endif
return resultParas.Count > 0 ? resultParas : null;
}
}