Files

626 lines
20 KiB
C#
Raw Permalink Normal View History

2026-06-08 14:31:31 +07:00
using QWERTYkez.WordProcessor;
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
}