Files
QWERTYkez.OpenXmlProcessors/QWERTYkez.WordProcessor/WordWriter.cs
2026-06-05 15:58:03 +07:00

626 lines
20 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.
using QWERTYkez.WordProcessor.Builders;
namespace QWERTYkez.WordProcessor;
/// <summary>
/// Предоставляет потокобезопасный процессор для чтения и записи документов Word (DOCX) формата.
/// <para>Наследует от <see cref="WordReader"/> и добавляет операции изменения документа.</para>
/// </summary>
internal sealed class WordWriter : WordReader, IWordWriter
{
private bool _isModified = false;
internal WordWriter() { }
#region Factory Methods
internal static WordWriter? CreateFromData(ReadOnlyMemory<byte> data, string destinationPath)
{
if (data.IsEmpty || string.IsNullOrEmpty(destinationPath))
return null;
var ms = new MemoryStream();
try
{
// Копируем данные в MemoryStream
ms.Write(data.ToArray(), 0, data.Length);
ms.Position = 0;
var doc = WordprocessingDocument.Open(ms, true, new OpenSettings { AutoSave = false });
if (doc.MainDocumentPart?.Document?.Body is { } body)
{
return new WordWriter
{
_ms = ms,
_doc = doc,
_body = body,
FilePath = destinationPath,
_originalSourcePath = null // нет исходного файла
};
}
doc.Dispose();
}
catch { }
ms?.Dispose();
return null;
}
internal static new WordWriter? CreateFromData(ReadOnlyMemory<byte> data)
{
if (data.IsEmpty)
return null;
var ms = new MemoryStream();
try
{
ms.Write(data.ToArray(), 0, data.Length);
ms.Position = 0;
var doc = WordprocessingDocument.Open(ms, true, new OpenSettings { AutoSave = false });
if (doc.MainDocumentPart?.Document?.Body is { } body)
{
return new WordWriter
{
_ms = ms,
_doc = doc,
_body = body,
FilePath = null, // нет привязки к файлу
_originalSourcePath = null
};
}
doc.Dispose();
}
catch { }
ms.Dispose();
return null;
}
internal static WordWriter? CreateInternal(FileInfo sourceFile, string? destinationPath = null!)
{
if (sourceFile is null || !sourceFile.Exists) return null;
var ms = new MemoryStream();
try
{
using (var file = new FileStream(sourceFile.FullName,
FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
file.CopyTo(ms);
}
ms.Position = 0;
var doc = WordprocessingDocument.Open(ms, isEditable: true,
new OpenSettings { AutoSave = false });
if (doc.MainDocumentPart?.Document?.Body is { } body)
{
var processor = new WordWriter
{
_ms = ms,
_doc = doc,
_body = body,
_originalSourcePath = sourceFile.FullName,
FilePath = destinationPath ?? sourceFile.FullName
};
return processor;
}
doc?.Dispose();
}
catch { }
ms?.Dispose();
return null;
}
/// <summary>
/// Создаёт новый пустой документ Word.
/// </summary>
/// <param name="destinationPath">Путь, по которому будет сохранён документ (необязательный).</param>
/// <returns>Экземпляр <see cref="WordWriter"/> для редактирования нового документа.</returns>
internal static WordWriter CreateNew(string? destinationPath = null)
{
var ms = new MemoryStream();
try
{
// Создаём документ, НЕ используем using
var doc = WordprocessingDocument.Create(ms, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document();
var body = new Body();
mainPart.Document.AppendChild(body);
var writer = new WordWriter
{
_ms = ms,
_doc = doc,
_body = body,
FilePath = destinationPath,
_originalSourcePath = null
};
return writer;
}
catch
{
ms.Dispose();
throw;
}
}
#endregion
#region Properties
/// <summary>
/// Получает значение, указывающее, будет ли документ сохранен с перезаписью исходного файла.
/// </summary>
public bool WillOverwriteSource => FilePath == _originalSourcePath;
#endregion
#region Replace text
public void ReplaceString(string oldValue, params string[] newValues) => ReplaceString(oldValue, (IEnumerable<string>)newValues);
public void ReplaceString(string oldValue, IEnumerable<string> 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.Replace] START: '{oldValue}' -> [{string.Join(", ", newValues)}] (comparison: {StringComparison.OrdinalIgnoreCase})");
#endif
lock (_syncLock)
{
// 1. Основной текст
_body.Replace(oldValue, newValues, StringComparison.OrdinalIgnoreCase);
// 2. Таблицы в основном тексте
TableTextProcessor.ReplaceInTables(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase);
// 3. Колонтитулы
HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, oldValue, newValues, StringComparison.OrdinalIgnoreCase);
_isModified = true;
}
}
public void ReplaceString(IDictionary<string, string> replacements) =>
/* */ReplaceString((IEnumerable<KeyValuePair<string, string>>)replacements);
public void ReplaceString(IEnumerable<KeyValuePair<string, string>> replacements)
{
ThrowIfDisposed();
if (replacements is null) return;
lock (_syncLock)
{
// 1. Основной текст
_body.Replace(replacements, StringComparison.OrdinalIgnoreCase);
// 2. Таблицы в основном тексте
foreach (var kvp in replacements)
{
if (!string.IsNullOrEmpty(kvp.Key))
{
TableTextProcessor.ReplaceInTables(_body, kvp.Key, [kvp.Value ?? string.Empty], StringComparison.OrdinalIgnoreCase);
}
}
HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, replacements, StringComparison.OrdinalIgnoreCase);
_isModified = true;
}
}
public void ReplaceString(IDictionary<string, IEnumerable<string>> replacements) =>
/* */ReplaceString((IEnumerable<KeyValuePair<string, IEnumerable<string>>>)replacements);
public void ReplaceString(IEnumerable<KeyValuePair<string, IEnumerable<string>>> replacements)
{
ThrowIfDisposed();
if (replacements is null) return;
lock (_syncLock)
{
// 1. Основной текст
_body.Replace(replacements, StringComparison.OrdinalIgnoreCase);
// 2. Таблицы в основном тексте
TableTextProcessor.ReplaceInTables(_body, replacements, StringComparison.OrdinalIgnoreCase);
// 3. Колонтитулы
HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, replacements, StringComparison.OrdinalIgnoreCase);
_isModified = true;
}
}
#endregion
#region Replace ReplaceItem
public void ReplaceItem(string oldValue, params ReplaceItem[] newValues) => ReplaceItem(oldValue, (IEnumerable<ReplaceItem>)newValues);
public void ReplaceItem(string oldValue, IEnumerable<ReplaceItem> 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.Replace] START: '{oldValue}' -> [{string.Join(", ", newValues)}] (comparison: {StringComparison.OrdinalIgnoreCase})");
#endif
lock (_syncLock)
{
// 1. Основной текст
_body.Replace(oldValue, newValues, StringComparison.OrdinalIgnoreCase);
var texts = newValues.Select(val => val.Text).ToArray();
// 2. Таблицы в основном тексте
TableTextProcessor.ReplaceInTables(_body, oldValue, texts, StringComparison.OrdinalIgnoreCase);
// 3. Колонтитулы
HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, oldValue, texts, StringComparison.OrdinalIgnoreCase);
_isModified = true;
}
}
public void ReplaceItem(IDictionary<string, ReplaceItem> replacements) =>
/* */ReplaceItem((IEnumerable<KeyValuePair<string, ReplaceItem>>)replacements);
public void ReplaceItem(IEnumerable<KeyValuePair<string, ReplaceItem>> replacements)
{
ThrowIfDisposed();
if (replacements is null) return;
lock (_syncLock)
{
// 1. Основной текст
_body.Replace(replacements, StringComparison.OrdinalIgnoreCase);
var texts = replacements.ToDictionary(val => val.Key, val => val.Value.Text);
// 2. Таблицы в основном тексте
foreach (var kvp in texts)
{
if (!string.IsNullOrEmpty(kvp.Key))
{
TableTextProcessor.ReplaceInTables(_body, kvp.Key, kvp.Value, StringComparison.OrdinalIgnoreCase);
}
}
// 3. Колонтитулы
HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, texts, StringComparison.OrdinalIgnoreCase);
_isModified = true;
}
}
public void ReplaceItem(IDictionary<string, IEnumerable<ReplaceItem>> replacements) =>
/* */ReplaceItem((IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>>)replacements);
public void ReplaceItem(IEnumerable<KeyValuePair<string, IEnumerable<ReplaceItem>>> replacements)
{
ThrowIfDisposed();
if (replacements is null) return;
lock (_syncLock)
{
// 1. Основной текст
_body.Replace(replacements, StringComparison.OrdinalIgnoreCase);
var texts = replacements.ToDictionary(val => val.Key, val => val.Value.Select(val => val.Text));
// 2. Таблицы в основном тексте
TableTextProcessor.ReplaceInTables(_body, texts, StringComparison.OrdinalIgnoreCase);
// 3. Колонтитулы
HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, texts, StringComparison.OrdinalIgnoreCase);
_isModified = true;
}
}
#endregion
/// <summary>
/// Добавляет новый параграф с указанным текстом в конец документа.
/// </summary>
public void AddParagraph(string text, bool preserveFormatting = true)
{
ThrowIfDisposed();
if (_body is null)
return;
lock (_syncLock)
{
var paragraph = new Paragraph();
var run = new Run();
if (preserveFormatting && _body.Elements<Paragraph>().FirstOrDefault() is { } firstPara)
{
if (firstPara.ParagraphProperties is not null)
{
paragraph.ParagraphProperties = firstPara.ParagraphProperties.CloneNode(true) as ParagraphProperties;
}
if (firstPara.Elements<Run>().FirstOrDefault()?.RunProperties is not null)
{
run.RunProperties = firstPara.Elements<Run>().First().RunProperties?.CloneNode(true) as RunProperties;
}
}
string processedText = text.Replace(' ', '\u00A0');
run.AppendChild(new Text(processedText));
paragraph.AppendChild(run);
_body.AppendChild(paragraph);
_isModified = true;
}
}
#region Replace to table
/// <summary>
/// Заменяет параграф, содержащий указанный текст, на таблицу, созданную с помощью TableBuilder
/// </summary>
/// <param name="oldValue">Текст для поиска в параграфах</param>
/// <param name="buildTable">Действие для настройки таблицы через TableBuilder</param>
public void ReplaceToTable(string oldValue, Action<ITable> buildTable)
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(oldValue))
throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue));
if (buildTable is null) return;
#if DEBUG
Debug.WriteLine($"[DEBUG] [WordProcessor.ReplaceToTable] Looking for '{oldValue}' to replace with custom table");
#endif
lock (_syncLock)
{
// Используем метод расширения для Body с TableBuilder
ReplaceToTableExt.ReplaceParagraphsContainingTextToTable(_body, oldValue, buildTable);
_isModified = true;
}
}
#endregion
#region Replace to text
/// <summary>
/// Заменяет параграф, содержащий указанный текст, на таблицу, созданную с помощью TextBuilder
/// </summary>
/// <param name="oldValue">Текст для поиска в параграфах</param>
/// <param name="buildText">Действие для настройки таблицы через TextBuilder</param>
public void ReplaceToText(string oldValue, Action<IText> 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] Looking for '{oldValue}' to replace with custom text");
#endif
lock (_syncLock)
{
// Используем метод расширения для Body с TextBuilder
ReplaceToTextExt.ReplaceParagraphsContainingTextToText(_body, oldValue, buildText);
_isModified = true;
}
}
#endregion
#region Save Operations
/// <summary>
/// Сохраняет документ в файл, указанный при создании процессора.
/// </summary>
public void Save()
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(FilePath))
throw new InvalidOperationException("Cannot save - no file path specified");
SaveTo(FilePath!);
}
/// <summary>
/// Сохраняет документ в указанный файл.
/// </summary>
public void SaveTo(string path)
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(path))
throw new ArgumentException("Path cannot be null or empty", nameof(path));
lock (_syncLock)
{
try
{
if (_ms is not null)
{
_doc.Save();
_ms.Position = 0;
using var fileStream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 65536);
_ms.CopyTo(fileStream);
_isModified = false;
}
}
catch (Exception ex)
{
throw new IOException($"Failed to save document to '{path}'", ex);
}
}
}
internal byte[] GetDocumentBytes()
{
lock (_syncLock)
{
_doc.Save();
_ms.Position = 0;
return _ms.ToArray();
}
}
public void SaveTo(Stream outputStream)
{
lock (_syncLock)
{
_doc.Save();
_ms.Position = 0;
_ms.CopyTo(outputStream);
}
}
/// <summary>
/// Пытается сохранить документ в указанный файл.
/// </summary>
public bool TrySaveTo(string path, out Exception? error)
{
error = null;
if (!IsValid || string.IsNullOrEmpty(path))
{
error = new InvalidOperationException("Processor is not valid or path is empty");
return false;
}
try
{
SaveTo(path);
return true;
}
catch (Exception ex)
{
error = ex;
#if DEBUG
Debug.WriteLine($"[DEBUG] Failed to save to '{path}': {ex.Message}");
#endif
return false;
}
}
/// <summary>
/// Асинхронно сохраняет документ в указанный файл.
/// </summary>
public async Task SaveToAsync(string path, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(path))
throw new ArgumentException("Path cannot be null or empty", nameof(path));
if (_ms is null)
throw new InvalidOperationException("Memory stream is not available");
byte[] buffer;
lock (_syncLock)
{
_doc.Save();
_ms.Position = 0;
buffer = _ms.ToArray();
}
using var fileStream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await fileStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken);
}
/// <summary> Создаёт read-only процессор из текущего документа. </summary>
internal WordReader ToReader()
{
lock (_syncLock)
{
_doc.Save();
_ms.Position = 0;
var data = _ms.ToArray();
return WordReader.CreateFromData(data) ?? throw new InvalidOperationException("Failed to create reader");
}
}
#endregion
#region Dispose Pattern
protected override void Dispose(bool disposing)
{
if (_disposed)
return;
lock (_syncLock)
{
if (disposing)
{
if (_isModified && !string.IsNullOrEmpty(FilePath))
{
try
{
_doc.Save();
Directory.CreateDirectory(Path.GetDirectoryName(FilePath));
_ms.Position = 0;
using var fileStream = new FileStream(
FilePath!,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920);
_ms.CopyTo(fileStream);
}
catch (Exception ex)
{
#if DEBUG
Debug.WriteLine($"[DEBUG] Auto-save failed during Dispose: {ex.Message}");
#endif
}
}
}
base.Dispose(disposing);
}
}
#endregion
}