using QWERTYkez.WordProcessor;
namespace QWERTYkez.WordProcessor;
///
/// Предоставляет потокобезопасный процессор для чтения и записи документов Word (DOCX) формата.
/// Наследует от и добавляет операции изменения документа.
///
internal sealed class WordWriter : WordReader, IWordWriter
{
private bool _isModified = false;
internal WordWriter() { }
#region Factory Methods
internal static WordWriter? CreateFromData(ReadOnlyMemory 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 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;
}
///
/// Создаёт новый пустой документ Word.
///
/// Путь, по которому будет сохранён документ (необязательный).
/// Экземпляр для редактирования нового документа.
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
///
/// Получает значение, указывающее, будет ли документ сохранен с перезаписью исходного файла.
///
public bool WillOverwriteSource => FilePath == _originalSourcePath;
#endregion
#region Replace text
public void ReplaceString(string oldValue, params string[] newValues) => ReplaceString(oldValue, (IEnumerable)newValues);
public void ReplaceString(string oldValue, IEnumerable 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 replacements) =>
/* */ReplaceString((IEnumerable>)replacements);
public void ReplaceString(IEnumerable> 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> replacements) =>
/* */ReplaceString((IEnumerable>>)replacements);
public void ReplaceString(IEnumerable>> 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)newValues);
public void ReplaceItem(string oldValue, IEnumerable 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 replacements) =>
/* */ReplaceItem((IEnumerable>)replacements);
public void ReplaceItem(IEnumerable> 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> replacements) =>
/* */ReplaceItem((IEnumerable>>)replacements);
public void ReplaceItem(IEnumerable>> 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
///
/// Добавляет новый параграф с указанным текстом в конец документа.
///
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().FirstOrDefault() is { } firstPara)
{
if (firstPara.ParagraphProperties is not null)
{
paragraph.ParagraphProperties = firstPara.ParagraphProperties.CloneNode(true) as ParagraphProperties;
}
if (firstPara.Elements().FirstOrDefault()?.RunProperties is not null)
{
run.RunProperties = firstPara.Elements().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
///
/// Заменяет параграф, содержащий указанный текст, на таблицу, созданную с помощью TableBuilder
///
/// Текст для поиска в параграфах
/// Действие для настройки таблицы через TableBuilder
public void ReplaceToTable(string oldValue, Action 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
///
/// Заменяет параграф, содержащий указанный текст, на таблицу, созданную с помощью TextBuilder
///
/// Текст для поиска в параграфах
/// Действие для настройки таблицы через TextBuilder
public void ReplaceToText(string oldValue, Action 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
///
/// Сохраняет документ в файл, указанный при создании процессора.
///
public void Save()
{
ThrowIfDisposed();
if (string.IsNullOrEmpty(FilePath))
throw new InvalidOperationException("Cannot save - no file path specified");
SaveTo(FilePath!);
}
///
/// Сохраняет документ в указанный файл.
///
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);
}
}
///
/// Пытается сохранить документ в указанный файл.
///
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;
}
}
///
/// Асинхронно сохраняет документ в указанный файл.
///
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);
}
/// Создаёт read-only процессор из текущего документа.
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
}