feat: add DOCX workbench and Word automation
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DocumentAutomation.Shared;
|
||||
|
||||
public sealed record DocumentInspectionResult
|
||||
{
|
||||
public required string FilePath { get; init; }
|
||||
public long FileSize { get; init; }
|
||||
public DateTime LastWriteTimeUtc { get; init; }
|
||||
public int Sections { get; init; }
|
||||
public int Paragraphs { get; init; }
|
||||
public int Tables { get; init; }
|
||||
public int Images { get; init; }
|
||||
public int Headers { get; init; }
|
||||
public int Footers { get; init; }
|
||||
public bool HasPageFields { get; init; }
|
||||
public bool HasNumPagesFields { get; init; }
|
||||
public bool HasSectionPagesFields { get; init; }
|
||||
public int Bookmarks { get; init; }
|
||||
public int ContentControls { get; init; }
|
||||
public int Comments { get; init; }
|
||||
public bool HasTrackedChanges { get; init; }
|
||||
public bool HasProtectedParts { get; init; }
|
||||
public IReadOnlyList<string> Styles { get; init; } = [];
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record WordDocumentBuildRequest
|
||||
{
|
||||
public required string TemplatePath { get; init; }
|
||||
public required string BodyPath { get; init; }
|
||||
public required string OutputDocxPath { get; init; }
|
||||
public string? RulesProfilePath { get; init; }
|
||||
public bool PreserveBodyFormatting { get; init; } = true;
|
||||
public bool PreservePageBreaks { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record WordDocumentResult
|
||||
{
|
||||
public required string OutputDocxPath { get; init; }
|
||||
public string? OutputPdfPath { get; init; }
|
||||
public required string LogPath { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
public required string InsertionPoint { get; init; }
|
||||
}
|
||||
|
||||
public sealed record WordPreviewResult
|
||||
{
|
||||
public required string PdfPath { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
public enum RuleEvaluationStatus { Pass, Fail, NotApplicable, NeedsReview, Error }
|
||||
|
||||
public sealed record RuleEvaluationResult
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required RuleEvaluationStatus Status { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? ClauseId { get; init; }
|
||||
public int? SourcePage { get; init; }
|
||||
public string? DocumentLocation { get; init; }
|
||||
public bool CanAutoFix { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OllamaSettings
|
||||
{
|
||||
public string BaseUrl { get; init; } = "http://192.168.200.112:11434";
|
||||
public string Model { get; init; } = "";
|
||||
public int TimeoutSeconds { get; init; } = 120;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
using DocxWorkbench.OpenXml;
|
||||
using DocxWorkbench.WordInterop;
|
||||
|
||||
namespace DocxWorkbench.App;
|
||||
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
private readonly TextBox _template = new() { Width = 520 }; private readonly TextBox _body = new() { Width = 520 }; private readonly TextBox _output = new() { Width = 520 }; private readonly TextBox _log = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill }; private readonly Label _status = new() { AutoSize = true, Text = "Готово" }; private readonly IDocumentInspector _inspector = new DocumentInspector(); private readonly IWordDocumentService _word = new WordDocumentService();
|
||||
public MainForm()
|
||||
{
|
||||
Text = "DOCX Workbench"; Width = 980; Height = 680; StartPosition = FormStartPosition.CenterScreen;
|
||||
var grid = new TableLayoutPanel { Dock = DockStyle.Top, Height = 145, ColumnCount = 3, RowCount = 4, Padding = new Padding(8) }; grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110));
|
||||
AddRow(grid, 0, "Шаблон DOCX", _template, BrowseTemplate); AddRow(grid, 1, "Тело документа", _body, BrowseBody); AddRow(grid, 2, "Выходной DOCX", _output, BrowseOutput);
|
||||
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; buttons.Controls.Add(Button("Анализировать", async (_, _) => await InspectAsync())); buttons.Controls.Add(Button("Сформировать", async (_, _) => await BuildAsync())); buttons.Controls.Add(Button("Проверить", async (_, _) => await InspectOutputAsync())); buttons.Controls.Add(Button("Открыть DOCX", (_, _) => Open(_output.Text))); buttons.Controls.Add(Button("Открыть PDF", (_, _) => Open(Path.ChangeExtension(_output.Text, ".pdf")))); grid.Controls.Add(buttons, 1, 3); grid.SetColumnSpan(buttons, 2);
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill }; foreach (var title in new[] { "Предпросмотр шаблона", "Предпросмотр тела", "Структура", "Результат", "Нарушения", "Журнал" }) { var page = new TabPage(title); if (title == "Журнал") page.Controls.Add(_log); tabs.TabPages.Add(page); }
|
||||
Controls.Add(tabs); Controls.Add(grid); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
|
||||
}
|
||||
private static void AddRow(TableLayoutPanel p, int row, string title, TextBox box, EventHandler browse) { p.Controls.Add(new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); p.Controls.Add(box, 1, row); var b = new Button { Text = "Обзор...", Width = 95 }; b.Click += browse; p.Controls.Add(b, 2, row); }
|
||||
private static Button Button(string text, EventHandler handler) { var b = new Button { Text = text, AutoSize = true }; b.Click += handler; return b; }
|
||||
private void BrowseTemplate(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "DOCX|*.docx" }; if (d.ShowDialog() == DialogResult.OK) _template.Text = d.FileName; }
|
||||
private void BrowseBody(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "DOCX/TXT|*.docx;*.txt" }; if (d.ShowDialog() == DialogResult.OK) _body.Text = d.FileName; }
|
||||
private void BrowseOutput(object? s, EventArgs e) { using var d = new SaveFileDialog { Filter = "DOCX|*.docx", DefaultExt = "docx" }; if (d.ShowDialog() == DialogResult.OK) _output.Text = d.FileName; }
|
||||
private async Task InspectAsync() => await RunAsync("Анализ", () => Task.Run(() => { var a = _inspector.Inspect(_template.Text); var b = _inspector.Inspect(_body.Text); return (object)new { Template = a, Body = b }; }));
|
||||
private async Task InspectOutputAsync() => await RunAsync("Проверка результата", () => Task.Run(() => (object)_inspector.Inspect(_output.Text)));
|
||||
private async Task BuildAsync() => await RunAsync("Формирование", async () => { var result = await _word.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = _template.Text, BodyPath = _body.Text, OutputDocxPath = _output.Text }, new Progress<string>(x => _status.Text = x), CancellationToken.None); var pdf = await _word.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); return (object)new { result, pdf }; });
|
||||
private async Task RunAsync(string name, Func<Task<object>> action) { try { _status.Text = name + "..."; var value = await action(); _log.AppendText(JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); _status.Text = name + ": готово"; } catch (Exception ex) { _log.AppendText($"{name}: {ex}\r\n"); _status.Text = name + ": ошибка"; MessageBox.Show(ex.Message, name, MessageBoxButtons.OK, MessageBoxIcon.Error); } }
|
||||
private static void Open(string path) { if (File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DocxWorkbench.App;
|
||||
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
// To customize application configuration such as set high DPI settings or default font,
|
||||
// see https://aka.ms/applicationconfiguration.
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using DocumentAutomation.Shared;
|
||||
|
||||
namespace DocxWorkbench.Core;
|
||||
|
||||
public interface IDocumentInspector
|
||||
{
|
||||
DocumentInspectionResult Inspect(string docxPath);
|
||||
}
|
||||
|
||||
public interface IWordDocumentService
|
||||
{
|
||||
Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken);
|
||||
Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public static class DocumentInputValidation
|
||||
{
|
||||
public static void Validate(WordDocumentBuildRequest request)
|
||||
{
|
||||
if (!File.Exists(request.TemplatePath)) throw new FileNotFoundException("Шаблон DOCX не найден.", request.TemplatePath);
|
||||
if (!File.Exists(request.BodyPath)) throw new FileNotFoundException("Файл тела документа не найден.", request.BodyPath);
|
||||
if (!request.TemplatePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Шаблон должен быть DOCX.");
|
||||
if (Path.GetFullPath(request.TemplatePath).Equals(Path.GetFullPath(request.OutputDocxPath), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Нельзя перезаписывать исходный шаблон.");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(request.OutputDocxPath))!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using DocxWorkbench.Core;
|
||||
|
||||
namespace DocxWorkbench.OpenXml;
|
||||
|
||||
public sealed class DocumentInspector : IDocumentInspector
|
||||
{
|
||||
public DocumentInspectionResult Inspect(string docxPath)
|
||||
{
|
||||
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX не найден.", docxPath);
|
||||
var info = new FileInfo(docxPath);
|
||||
using var package = WordprocessingDocument.Open(docxPath, false);
|
||||
var main = package.MainDocumentPart ?? throw new InvalidDataException("В DOCX отсутствует основной документ.");
|
||||
var body = main.Document.Body;
|
||||
var text = main.Document.InnerText;
|
||||
var headers = main.HeaderParts.Count();
|
||||
var footers = main.FooterParts.Count();
|
||||
var warnings = new List<string>();
|
||||
if (Regex.IsMatch(text, "2019|старый|legacy", RegexOptions.IgnoreCase)) warnings.Add("Обнаружены возможные старые проектные реквизиты; проверьте вручную.");
|
||||
if (main.Document.Descendants<SectionProperties>().Any(s => s.GetFirstChild<HeaderReference>() is null)) warnings.Add("Есть секция без явной ссылки на header.");
|
||||
return new DocumentInspectionResult
|
||||
{
|
||||
FilePath = docxPath, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc,
|
||||
Sections = Math.Max(1, main.Document.Descendants<SectionProperties>().Count()),
|
||||
Paragraphs = main.Document.Descendants<Paragraph>().Count(), Tables = main.Document.Descendants<Table>().Count(),
|
||||
Images = main.ImageParts.Count(), Headers = headers, Footers = footers,
|
||||
HasPageFields = text.Contains("PAGE", StringComparison.OrdinalIgnoreCase), HasNumPagesFields = text.Contains("NUMPAGES", StringComparison.OrdinalIgnoreCase),
|
||||
HasSectionPagesFields = text.Contains("SECTIONPAGES", StringComparison.OrdinalIgnoreCase), Bookmarks = main.Document.Descendants<BookmarkStart>().Count(),
|
||||
ContentControls = main.Document.Descendants<SdtElement>().Count(), Comments = package.MainDocumentPart.GetPartsOfType<WordprocessingCommentsPart>().Count(),
|
||||
HasTrackedChanges = main.Document.Descendants<Inserted>().Any() || main.Document.Descendants<Deleted>().Any(),
|
||||
HasProtectedParts = main.Document.Descendants<DocumentProtection>().Any(),
|
||||
Styles = main.StyleDefinitionsPart?.Styles?.Elements<Style>().Select(s => s.StyleId?.Value ?? "").Where(s => s.Length > 0).Distinct().Order().ToArray() ?? [],
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using DocumentAutomation.Shared;
|
||||
using DocxWorkbench.Core;
|
||||
using Word = Microsoft.Office.Interop.Word;
|
||||
|
||||
namespace DocxWorkbench.WordInterop;
|
||||
|
||||
public sealed class WordDocumentService : IWordDocumentService
|
||||
{
|
||||
public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken)
|
||||
=> Task.Run(() => Build(request, progress, cancellationToken), cancellationToken);
|
||||
|
||||
public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken)
|
||||
=> Task.Run(() => ExportPdf(docxPath, outputPdfPath, cancellationToken), cancellationToken);
|
||||
|
||||
private static WordDocumentResult Build(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token)
|
||||
{
|
||||
DocumentInputValidation.Validate(request); var started = Stopwatch.StartNew();
|
||||
var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp);
|
||||
var working = Path.Combine(temp, "working.docx"); File.Copy(request.TemplatePath, working, true);
|
||||
Word.Application? app = null; Word.Document? document = null; string insertion = "none"; int? pid = null;
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested(); progress?.Report("Запуск Word COM"); app = new Word.Application { Visible = false, DisplayAlerts = Word.WdAlertLevel.wdAlertsNone }; pid = GetProcessId(app);
|
||||
document = app.Documents.Open(working, ReadOnly: false, AddToRecentFiles: false, Visible: false);
|
||||
progress?.Report("Поиск точки вставки DOC_BODY"); var range = FindInsertionRange(document, out insertion);
|
||||
if (range is null) throw new InvalidOperationException("В шаблоне не найдена точка вставки DOC_BODY.");
|
||||
token.ThrowIfCancellationRequested(); range.InsertFile(request.BodyPath, ConfirmConversions: false, Link: false, Attachment: false);
|
||||
progress?.Report("Обновление полей и пагинация"); document.Repaginate(); document.Fields.Update();
|
||||
for (var i = 1; i <= document.Sections.Count; i++) { var section = document.Sections[i]; foreach (Word.HeaderFooter hf in section.Headers) hf.Range.Fields.Update(); foreach (Word.HeaderFooter hf in section.Footers) hf.Range.Fields.Update(); }
|
||||
document.SaveAs2(request.OutputDocxPath, Word.WdSaveFormat.wdFormatXMLDocument);
|
||||
var logPath = Path.ChangeExtension(request.OutputDocxPath, ".generation.json"); File.WriteAllText(logPath, JsonSerializer.Serialize(new { operationId = Guid.NewGuid(), template = request.TemplatePath, body = request.BodyPath, output = request.OutputDocxPath, insertionPoint = insertion, wordPid = pid, durationMs = started.ElapsedMilliseconds, completedAtUtc = DateTime.UtcNow }, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion };
|
||||
}
|
||||
finally { if (document is not null) { try { document.Close(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(document); } if (app is not null) { try { app.Quit(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(app); } try { Directory.Delete(temp, true); } catch { } }
|
||||
}
|
||||
|
||||
private static WordPreviewResult ExportPdf(string docxPath, string outputPdfPath, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX не найден.", docxPath); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPdfPath))!); var sw = Stopwatch.StartNew(); Word.Application? app = null; Word.Document? doc = null;
|
||||
try { token.ThrowIfCancellationRequested(); app = new Word.Application { Visible = false, DisplayAlerts = Word.WdAlertLevel.wdAlertsNone }; doc = app.Documents.Open(docxPath, ReadOnly: true, AddToRecentFiles: false, Visible: false); doc.ExportAsFixedFormat(outputPdfPath, Word.WdExportFormat.wdExportFormatPDF); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; }
|
||||
finally { if (doc is not null) { try { doc.Close(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(doc); } if (app is not null) { try { app.Quit(Word.WdSaveOptions.wdDoNotSaveChanges); } catch { } Marshal.FinalReleaseComObject(app); } }
|
||||
}
|
||||
|
||||
private static Word.Range? FindInsertionRange(Word.Document document, out string point)
|
||||
{
|
||||
foreach (Word.ContentControl control in document.ContentControls) if (string.Equals(control.Tag, "DOC_BODY", StringComparison.OrdinalIgnoreCase) || string.Equals(control.Title, "DOC_BODY", StringComparison.OrdinalIgnoreCase)) { point = "ContentControl:DOC_BODY"; return control.Range; }
|
||||
if (document.Bookmarks.Exists("DOC_BODY")) { point = "Bookmark:DOC_BODY"; return document.Bookmarks["DOC_BODY"].Range; }
|
||||
var search = document.Content; search.Find.ClearFormatting(); search.Find.Text = "{{DOC_BODY}}"; if (search.Find.Execute()) { point = "Marker:{{DOC_BODY}}"; search.Text = ""; return search; } point = "none"; return null;
|
||||
}
|
||||
|
||||
private static int? GetProcessId(Word.Application app) { try { return Process.GetProcessesByName("WINWORD").OrderByDescending(p => p.StartTime).FirstOrDefault()?.Id; } catch { return null; } }
|
||||
}
|
||||
Reference in New Issue
Block a user