Добавьте файлы проекта.
This commit is contained in:
626
QWERTYkez.WordProcessor/WordWriter.cs
Normal file
626
QWERTYkez.WordProcessor/WordWriter.cs
Normal file
@@ -0,0 +1,626 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user