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

496 lines
21 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;
/// <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;
body.Replace(new Dictionary<string, IEnumerable<string>> { { oldValue, newValues } }, 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;
body.Replace(new Dictionary<string, IEnumerable<ReplaceItem>> { { oldValue, newValues } }, 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;
ReplaceInBody(body, replacements, null, comparisonType);
}
internal static void Replace(this Body body, IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>> replacements, StringComparison comparisonType)
{
if (body is null || replacements is null) return;
ReplaceInBody(body, null, replacements, comparisonType);
}
private static void ReplaceInBody(
Body body,
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? stringReplacements,
IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>>? itemReplacements,
StringComparison comparisonType)
{
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, stringReplacements, itemReplacements, comparisonType);
if (newParas is { 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?.Parent is null || string.IsNullOrEmpty(oldValue) || newValues is null || !newValues.Any())
return false;
var dict = new Dictionary<string, IEnumerable<string>> { { oldValue, newValues } };
var newParas = ProcessMultiReplacements(paragraph, dict, null, comparisonType);
if (newParas is { Count: > 0 })
{
ParagraphReplacer.ReplaceParagraph(paragraph, newParas);
return true;
}
return false;
}
internal static bool ReplaceWithMultiple(this Paragraph? paragraph, string oldValue, IEnumerable<ReplaceItem> newValues, StringComparison comparisonType)
{
if (paragraph?.Parent is null || string.IsNullOrEmpty(oldValue) || newValues is null || !newValues.Any())
return false;
var dict = new Dictionary<string, IEnumerable<ReplaceItem>> { { oldValue, newValues } };
var newParas = ProcessMultiReplacements(paragraph, null, dict, comparisonType);
if (newParas is { Count: > 0 })
{
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)
=> ProcessMultiReplacements(paragraph, replacements, null, comparisonType);
internal static List<Paragraph>? ProcessParagraphWithAllReplacements(
Paragraph paragraph,
IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>> replacements,
StringComparison comparisonType)
=> ProcessMultiReplacements(paragraph, null, replacements, comparisonType);
internal static List<Paragraph>? ProcessParagraphWithAllReplacements(
Paragraph paragraph,
IEnumerable<KeyValuePair<string, string>> replacements,
StringComparison comparisonType)
{
var 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; init; } = null!;
public int Start { get; init; }
public int End { get; init; }
}
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>(runs.Count);
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 null) return newPara;
var newProps = new ParagraphProperties();
foreach (var child in original.ParagraphProperties.ChildElements)
{
if (child is not SectionProperties and not PageBreakBefore)
newProps.AppendChild(child.CloneNode(true));
}
if (newProps.HasChildren)
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
{
var runClone = (Run)seg.Run.CloneNode(true);
runClone.RemoveAllChildren<Text>(); // Упрощенная очистка
int offset = position - seg.Start;
runClone.AppendChild(new Text(seg.Text.Substring(offset)));
newPara.AppendChild(runClone);
}
}
// Удаляем пустые Run-ы без вложений
newPara.Elements<Run>().Where(r => !r.HasChildren).ToList().ForEach(r => r.Remove());
return !newPara.Elements<Run>().Any() && newPara.ParagraphProperties is null ? null : 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) берутся из документа.
/// </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)
{
para.ParagraphProperties.RemoveAllChildren<SectionProperties>();
para.ParagraphProperties.RemoveAllChildren<PageBreakBefore>();
}
para.ParagraphProperties ??= new ParagraphProperties();
var sectionProps = new SectionProperties();
// Добавляем PageSize
var sizeSource = addPageSize ? sourceSection : portraitSection;
var sourcePageSize = sizeSource?.GetFirstChild<PageSize>()
?? portraitSection?.GetFirstChild<PageSize>()
?? 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();
pageSizeClone.Orient = addPageSize ? PageOrientationValues.Portrait : null;
}
else
{
pageSizeClone.Orient = targetIsLandscape ? PageOrientationValues.Landscape : (addPageSize ? PageOrientationValues.Portrait : null);
}
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 sourceIsLndscape = sourceSection?.GetFirstChild<PageSize>()?.Orient?.Value == PageOrientationValues.Landscape ||
sourceSection?.GetFirstChild<PageSize>()?.Width?.Value > sourceSection?.GetFirstChild<PageSize>()?.Height?.Value;
if ((splitValue == PageBreakType.NewLandscapeSection && !sourceIsLndscape) ||
(splitValue == PageBreakType.NewPortraitSection && sourceIsLndscape))
{
marginClone.SwapBottomRight();
}
marginToUse = marginClone;
}
}
else if (portraitSection?.GetFirstChild<PageMargin>() is { } portraitMargin)
{
marginToUse = (PageMargin)portraitMargin.CloneNode(true);
}
if (marginToUse is not null)
sectionProps.AppendChild(marginToUse);
sectionProps.AppendChild(new SectionType { Val = SectionMarkValues.NextPage });
para.ParagraphProperties.AppendChild(sectionProps);
}
/// <summary>Основной алгоритм множественной замены с поддержкой разрывов и смены ориентации.</summary>
private static List<Paragraph>? ProcessMultiReplacements(
Paragraph original,
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? stringReplacements,
IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>>? itemReplacements,
StringComparison comparisonType)
{
var definitions = new List<MatchDefinition>();
if (stringReplacements is not null)
{
definitions.AddRange(stringReplacements
.Where(kvp => !string.IsNullOrEmpty(kvp.Key) && kvp.Value != null && kvp.Value.Any())
.Select(kvp => new MatchDefinition(kvp.Key, kvp.Value.Select(v => new ReplaceItem(v, PageBreakType.None)))));
}
if (itemReplacements is not null)
{
definitions.AddRange(itemReplacements
.Where(kvp => !string.IsNullOrEmpty(kvp.Key) && kvp.Value != null && kvp.Value.Any())
.Select(kvp => new MatchDefinition(kvp.Key, kvp.Value)));
}
if (definitions.Count == 0) return null;
var runs = original.Elements<Run>().ToList();
if (runs.Count == 0) return null;
var structure = AnalyzeParagraphStructure(runs);
string fullText = structure.FullText;
if (fullText.Length == 0) return null;
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));
var body = original.Ancestors<Body>().FirstOrDefault();
var sourceSection = original.ParagraphProperties?.GetFirstChild<SectionProperties>()?.CloneNode(true) as SectionProperties
?? body?.Elements<SectionProperties>().LastOrDefault()?.CloneNode(true) as SectionProperties
?? new SectionProperties();
var portraitSection = body?.Elements<SectionProperties>().FirstOrDefault()?.CloneNode(true) as SectionProperties
?? new SectionProperties();
var resultParas = new List<Paragraph>();
Paragraph? currentPara = null;
int currentPos = 0;
bool sectionChangeInsideGroup = false;
PageBreakType? lastOrientation = null;
foreach (var match in matches)
{
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);
runClone.RemoveAllChildren<Text>();
runClone.AppendChild(new Text(seg.Text.Substring(startOffset, endOffset - startOffset)));
beforePara.AppendChild(runClone);
}
}
if (beforePara.Elements<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];
currentPara = CloneParagraphWithoutSection(original);
resultParas.Add(currentPara);
InsertFormattedRun(currentPara, item, structure, match.Start);
if (item.SplitValue is PageBreakType.NewLandscapeSection or PageBreakType.NewPortraitSection)
{
bool addPageSize = vIdx != 0;
var orientation = vIdx != 0 ? PageBreakType.NewLandscapeSection : item.SplitValue;
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?.Run.RunProperties is not null)
breakRun.RunProperties = (RunProperties)seg.Run.RunProperties.CloneNode(true);
currentPara.AppendChild(breakRun);
}
}
if (sectionChangeInsideGroup && lastOrientation.HasValue && currentPara?.ParagraphProperties?.GetFirstChild<SectionProperties>() is null)
{
AddSectionProperties(currentPara!, lastOrientation.Value, lastOrientation.Value == PageBreakType.NewLandscapeSection, sourceSection, portraitSection);
}
if (body is not null && sourceSection.GetFirstChild<PageSize>() is { } sourcePageSize)
{
bool sourceIsLandscape = sourcePageSize.Orient?.Value == PageOrientationValues.Landscape || sourcePageSize.Width?.Value > sourcePageSize.Height?.Value;
bool lastIsPortrait = lastOrientation == PageBreakType.NewPortraitSection;
if (sourceIsLandscape && sectionChangeInsideGroup && lastIsPortrait && !body.Elements<SectionProperties>().Any(s => s.GetFirstChild<PageSize>()?.Orient?.Value == PageOrientationValues.Landscape))
{
var bodySection = new SectionProperties();
var pageSizeClone = (PageSize)sourcePageSize.CloneNode(true);
if (pageSizeClone.Orient?.Value != PageOrientationValues.Landscape)
{
pageSizeClone.SwapValues();
pageSizeClone.Orient = PageOrientationValues.Landscape;
}
bodySection.AppendChild(pageSizeClone);
if (sourceSection.GetFirstChild<PageMargin>() is { } sourceMargin)
{
var marginClone = (PageMargin)sourceMargin.CloneNode(true);
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;
}
if (currentPos < fullText.Length && BuildRemainderParagraph(original, structure, currentPos) is { } remainderPara)
{
if (sectionChangeInsideGroup && lastOrientation.HasValue)
{
remainderPara.InsertAt(new Run(new Break { Type = BreakValues.Page }), 0);
AddSectionProperties(remainderPara, lastOrientation.Value, lastOrientation.Value == PageBreakType.NewLandscapeSection, sourceSection, portraitSection);
}
resultParas.Add(remainderPara);
}
resultParas.RemoveAll(p => !p.Elements<Run>().Any() && p.ParagraphProperties is null);
return resultParas.Count > 0 ? resultParas : null;
}
}