Files
QWERTYkez.OpenXmlProcessors/QWERTYkez.WordProcessor/MultiReplace.cs
T
2026-07-23 16:42:26 +07:00

680 lines
30 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;
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.</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)
newProps.AppendChild(child.CloneNode(true));
}
newPara.ParagraphProperties = newProps;
}
return newPara;
}
private static Paragraph? BuildRangeParagraph(Paragraph original, ParagraphStructure structure, int start, int end)
{
if (start >= end) return null;
var newPara = CloneParagraphWithoutSection(original);
foreach (var child in original.ChildElements)
{
if (child is Run run)
{
var seg = structure.Segments.FirstOrDefault(s => s.Run == run);
if (seg is null)
{
newPara.AppendChild(run.CloneNode(true));
continue;
}
if (seg.End <= start || seg.Start >= end)
continue;
if (seg.Start >= start && seg.End <= end)
{
newPara.AppendChild(run.CloneNode(true));
}
else
{
var runClone = (Run)run.CloneNode(true);
foreach (var t in runClone.Elements<Text>().ToList())
t.Remove();
int cutStart = Math.Max(start, seg.Start) - seg.Start;
int cutEnd = Math.Min(end, seg.End) - seg.Start;
string newText = seg.Text.Substring(cutStart, cutEnd - cutStart);
runClone.AppendChild(new Text(newText));
newPara.AppendChild(runClone);
}
}
else
{
newPara.AppendChild(child.CloneNode(true));
}
}
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 Paragraph? BuildAfterParagraph(Paragraph original, ParagraphStructure structure, int position)
{
if (position >= structure.FullText.Length) return null;
var newPara = CloneParagraphWithoutSection(original);
var firstTextSeg = structure.Segments.FirstOrDefault(s => s.Start >= position);
bool passedFirstText = false;
foreach (var child in original.ChildElements)
{
if (child is Run run)
{
var seg = structure.Segments.FirstOrDefault(s => s.Run == run);
if (seg is null)
{
if (passedFirstText)
newPara.AppendChild(run.CloneNode(true));
continue;
}
if (seg.Start >= position)
{
newPara.AppendChild(run.CloneNode(true));
if (seg == firstTextSeg)
passedFirstText = true;
}
else if (seg.End > position)
{
var runClone = (Run)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);
passedFirstText = true;
}
}
else
{
if (passedFirstText)
newPara.AppendChild(child.CloneNode(true));
}
}
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);
}
private static void AddSectionProperties(Paragraph para, PageBreakType splitValue)
{
if (para is null) return;
PageOrientationValues orientation = splitValue == PageBreakType.NewLandscapeSection
? PageOrientationValues.Landscape
: PageOrientationValues.Portrait;
uint width, height;
if (orientation == PageOrientationValues.Landscape)
{
width = 16838; // A4 landscape
height = 11906;
}
else
{
width = 11906; // A4 portrait
height = 16838;
}
var sectionProps = new SectionProperties(
new PageSize
{
Width = width,
Height = height,
Orient = orientation
},
new SectionType { Val = SectionMarkValues.NextPage }
);
para.ParagraphProperties ??= new ParagraphProperties();
// Удаляем все существующие SectionProperties перед добавлением новой
var existingSections = para.ParagraphProperties.Elements<SectionProperties>().ToList();
foreach (var sec in existingSections)
sec.Remove();
para.ParagraphProperties.InsertAt(sectionProps, 0);
}
private static void MergeParagraph(Paragraph target, Paragraph source)
{
foreach (var child in source.ChildElements)
target.AppendChild(child.CloneNode(true));
}
/// <summary>Основной алгоритм множественной замены с поддержкой разрывов и смены ориентации.</summary>
private static List<Paragraph>? ProcessMultiReplacements(
Paragraph original,
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? stringReplacements,
IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>>? itemReplacements,
StringComparison comparisonType)
{
// Логируем начало
Log($"=== ProcessMultiReplacements START ===");
Log($"Original text: '{original.InnerText}'");
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));
}
}
Log($"Definitions count: {definitions.Count}");
foreach (var def in definitions)
{
Log($" Key: '{def.Key}', Values: {string.Join(", ", def.Values.Select(v => $"'{v.Text}' [{v.SplitValue}]"))}");
}
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;
Log($"Full text: '{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;
Log($"Matches found: {matches.Count}");
foreach (var match in matches)
{
Log($" Match: '{match.Definition.Key}' at {match.Start}-{match.End}");
}
matches.Sort((a, b) => a.Start.CompareTo(b.Start));
// 4. Определяем секцию, которая должна следовать за original
SectionProperties? followingSection = original.ParagraphProperties?.GetFirstChild<SectionProperties>()?.CloneNode(true) as SectionProperties;
if (followingSection is null)
{
var nextPara = original.NextSibling<Paragraph>();
if (nextPara is not null)
{
followingSection = nextPara.ParagraphProperties?.GetFirstChild<SectionProperties>()?.CloneNode(true) as SectionProperties;
if (followingSection is not null)
{
var pageSize = followingSection.GetFirstChild<PageSize>();
if (pageSize is not null)
{
Log($"Following section taken from next paragraph: '{nextPara.InnerText}'");
Log($"Following section orientation: {(pageSize.Orient == PageOrientationValues.Landscape ? "landscape" : "portrait")}");
}
}
}
}
bool hasFollowingSection = followingSection is not null;
Log($"Has following section: {hasFollowingSection}");
// 5. Построение результата
var resultParas = new List<Paragraph>();
Paragraph? currentPara = null;
int currentPos = 0;
// Отложенная ориентация для следующего параграфа
PageBreakType? pendingOrientation = null;
bool pendingApplied = false;
// Флаг, была ли смена ориентации внутри группы
bool sectionChangeInsideGroup = false;
for (int i = 0; i < matches.Count; i++)
{
var match = matches[i];
Log($"--- Processing match {i}: '{match.Definition.Key}' at {match.Start}-{match.End} ---");
Log($" Values count: {match.Definition.Values.Count()}");
// Текст перед совпадением
if (currentPos < match.Start)
{
var textPart = BuildRangeParagraph(original, structure, currentPos, match.Start);
if (textPart is not null)
{
if (currentPara is null)
{
currentPara = textPart;
resultParas.Add(currentPara);
}
else
{
MergeParagraph(currentPara, textPart);
}
}
}
var values = match.Definition.Values.ToList();
if (values.Count == 0) continue;
for (int vIdx = 0; vIdx < values.Count; vIdx++)
{
var item = values[vIdx];
Log($" Processing value {vIdx}: '{item.Text}' [{item.SplitValue}]");
// Всегда создаём новый параграф для каждого элемента замены
var newPara = CloneParagraphWithoutSection(original);
resultParas.Add(newPara);
currentPara = newPara;
Log($" Created new paragraph: '{item.Text}' (placeholder)");
// Применяем отложенную ориентацию, если есть и не применена
if (pendingOrientation.HasValue && !pendingApplied)
{
AddSectionProperties(currentPara, pendingOrientation.Value);
Log($" Applied pending orientation: {pendingOrientation.Value}");
pendingOrientation = null;
pendingApplied = true;
}
// Если это первый созданный параграф и у него есть смена ориентации,
// задаём книжную, чтобы избежать наследования предыдущей секции
if (resultParas.Count == 1 && vIdx == 0 && item.SplitValue != PageBreakType.None)
{
if (currentPara.ParagraphProperties?.GetFirstChild<SectionProperties>() is null)
{
AddSectionProperties(currentPara, PageBreakType.NewPortraitSection);
Log($" Added portrait section to first paragraph (to avoid inheriting landscape)");
}
sectionChangeInsideGroup = true;
}
// Вставляем текст
InsertFormattedRun(currentPara, item, structure, match.Start);
Log($" Inserted text '{item.Text}' into paragraph");
// Обработка разрывов страниц (обычный PageBreak)
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);
Log($" Added page break");
}
// Смена ориентации – устанавливаем отложенную для следующего параграфа
else if (item.SplitValue == PageBreakType.NewLandscapeSection ||
item.SplitValue == PageBreakType.NewPortraitSection)
{
pendingOrientation = item.SplitValue;
pendingApplied = false;
sectionChangeInsideGroup = true;
Log($" Set pending orientation: {item.SplitValue} (will be applied to next paragraph)");
}
// Логируем содержимое параграфа после обработки
Log($" Current paragraph content now: '{currentPara.InnerText}'");
}
currentPos = match.End;
}
// Текст после последнего совпадения
Log($"--- Processing remainder after last match, currentPos={currentPos}, fullText.Length={fullText.Length} ---");
if (currentPos < fullText.Length)
{
var textPart = BuildAfterParagraph(original, structure, currentPos);
if (textPart is not null)
{
// Если есть отложенная ориентация и не применена, применяем к остатку
if (pendingOrientation.HasValue && !pendingApplied)
{
var newPara = CloneParagraphWithoutSection(original);
MergeParagraph(newPara, textPart);
AddSectionProperties(newPara, pendingOrientation.Value);
resultParas.Add(newPara);
Log($" Applied pending orientation to remainder: {pendingOrientation.Value}");
pendingOrientation = null;
pendingApplied = true;
}
else
{
// Если есть следующая секция (followingSection) и внутри группы была смена,
// применяем следующую секцию к остатку с явным разрывом страницы
if (hasFollowingSection && sectionChangeInsideGroup)
{
var newPara = CloneParagraphWithoutSection(original);
// Добавляем явный разрыв страницы перед остатком
var breakRun = new Run(new Break { Type = BreakValues.Page });
newPara.AppendChild(breakRun);
MergeParagraph(newPara, textPart);
if (newPara.ParagraphProperties is null)
newPara.ParagraphProperties = new ParagraphProperties();
// Удаляем все существующие секции
var existing = newPara.ParagraphProperties.Elements<SectionProperties>().ToList();
foreach (var sec in existing) sec.Remove();
newPara.ParagraphProperties.InsertAt(followingSection!.CloneNode(true), 0);
resultParas.Add(newPara);
Log($" Applied following section to remainder with page break");
}
else
{
// Иначе добавляем остаток в текущий параграф (или создаём новый)
if (currentPara is null)
{
var newPara = CloneParagraphWithoutSection(original);
MergeParagraph(newPara, textPart);
if (hasFollowingSection && !sectionChangeInsideGroup)
{
if (newPara.ParagraphProperties is null)
newPara.ParagraphProperties = new ParagraphProperties();
newPara.ParagraphProperties.InsertAt(followingSection!.CloneNode(true), 0);
Log($" Applied following section to new remainder paragraph (no section change)");
}
resultParas.Add(newPara);
currentPara = newPara;
}
else
{
if (hasFollowingSection && !sectionChangeInsideGroup)
{
if (currentPara.ParagraphProperties is null)
currentPara.ParagraphProperties = new ParagraphProperties();
currentPara.ParagraphProperties.InsertAt(followingSection!.CloneNode(true), 0);
Log($" Applied following section to existing remainder paragraph (no section change)");
}
MergeParagraph(currentPara, textPart);
}
}
}
}
}
// Если остатка нет, но была смена и есть следующая секция,
// создаём параграф со следующей секцией
if (currentPos >= fullText.Length && hasFollowingSection && sectionChangeInsideGroup)
{
var newPara = CloneParagraphWithoutSection(original);
if (newPara.ParagraphProperties is null)
newPara.ParagraphProperties = new ParagraphProperties();
newPara.ParagraphProperties.InsertAt(followingSection!.CloneNode(true), 0);
resultParas.Add(newPara);
Log($" Created empty paragraph with following section (no remainder)");
}
// Исправление: если осталась отложенная ориентация, но остатка нет,
// создаём новый параграф с этой ориентацией (чтобы начать новую секцию для последующего текста)
if (pendingOrientation.HasValue && currentPos >= fullText.Length)
{
var newPara = CloneParagraphWithoutSection(original);
AddSectionProperties(newPara, pendingOrientation.Value);
resultParas.Add(newPara);
Log($" Created empty paragraph with pending orientation: {pendingOrientation.Value} (no remainder)");
pendingOrientation = null;
}
// Очистка пустых параграфов
for (int i = resultParas.Count - 1; i >= 0; i--)
{
if (!resultParas[i].ChildElements.OfType<Run>().Any() && resultParas[i].ParagraphProperties is null)
resultParas.RemoveAt(i);
}
// Логируем результат
Log($"=== ProcessMultiReplacements END, resulting paragraphs: {resultParas.Count} ===");
for (int i = 0; i < resultParas.Count; i++)
{
var p = resultParas[i];
var hasSection = p.ParagraphProperties?.GetFirstChild<SectionProperties>() is not null;
Log($" Paragraph {i}: '{p.InnerText}' - Section: {hasSection}");
}
return resultParas.Count > 0 ? resultParas : null;
}
// Логирование
private static void Log(string message)
{
#if DEBUG
Debugger.Builder.AppendLine(message);
#endif
}
}