626 lines
20 KiB
C#
626 lines
20 KiB
C#
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
|
||
} |