From 958bad6823e0aae7d89b57667589a3bedc9dc9a2 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 30 Jul 2026 13:18:54 +0300 Subject: [PATCH] feat: add DOCX workbench and Word automation --- .../src/DocumentAutomation.Shared/Models.cs | 71 +++++++++++++++++++ .../src/DocxWorkbench.App/MainForm.cs | 33 +++++++++ .../src/DocxWorkbench.App/Program.cs | 16 +++++ .../src/DocxWorkbench.Core/Contracts.cs | 26 +++++++ .../DocumentInspector.cs | 39 ++++++++++ .../WordDocumentService.cs | 55 ++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 DocumentAutomation/src/DocumentAutomation.Shared/Models.cs create mode 100644 DocumentAutomation/src/DocxWorkbench.App/MainForm.cs create mode 100644 DocumentAutomation/src/DocxWorkbench.App/Program.cs create mode 100644 DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs create mode 100644 DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs create mode 100644 DocumentAutomation/src/DocxWorkbench.WordInterop/WordDocumentService.cs diff --git a/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs b/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs new file mode 100644 index 0000000..4c4e12c --- /dev/null +++ b/DocumentAutomation/src/DocumentAutomation.Shared/Models.cs @@ -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 Styles { get; init; } = []; + public IReadOnlyList 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; +} diff --git a/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs b/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs new file mode 100644 index 0000000..95f69b1 --- /dev/null +++ b/DocumentAutomation/src/DocxWorkbench.App/MainForm.cs @@ -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(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> 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 }); } +} diff --git a/DocumentAutomation/src/DocxWorkbench.App/Program.cs b/DocumentAutomation/src/DocxWorkbench.App/Program.cs new file mode 100644 index 0000000..649c1cd --- /dev/null +++ b/DocumentAutomation/src/DocxWorkbench.App/Program.cs @@ -0,0 +1,16 @@ +namespace DocxWorkbench.App; + +static class Program +{ + /// + /// The main entry point for the application. + /// + [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()); + } +} diff --git a/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs b/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs new file mode 100644 index 0000000..9265e87 --- /dev/null +++ b/DocumentAutomation/src/DocxWorkbench.Core/Contracts.cs @@ -0,0 +1,26 @@ +using DocumentAutomation.Shared; + +namespace DocxWorkbench.Core; + +public interface IDocumentInspector +{ + DocumentInspectionResult Inspect(string docxPath); +} + +public interface IWordDocumentService +{ + Task BuildDocumentAsync(WordDocumentBuildRequest request, IProgress? progress, CancellationToken cancellationToken); + Task 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))!); + } +} diff --git a/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs b/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs new file mode 100644 index 0000000..eb45bca --- /dev/null +++ b/DocumentAutomation/src/DocxWorkbench.OpenXml/DocumentInspector.cs @@ -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(); + if (Regex.IsMatch(text, "2019|старый|legacy", RegexOptions.IgnoreCase)) warnings.Add("Обнаружены возможные старые проектные реквизиты; проверьте вручную."); + if (main.Document.Descendants().Any(s => s.GetFirstChild() is null)) warnings.Add("Есть секция без явной ссылки на header."); + return new DocumentInspectionResult + { + FilePath = docxPath, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, + Sections = Math.Max(1, main.Document.Descendants().Count()), + Paragraphs = main.Document.Descendants().Count(), Tables = main.Document.Descendants().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().Count(), + ContentControls = main.Document.Descendants().Count(), Comments = package.MainDocumentPart.GetPartsOfType().Count(), + HasTrackedChanges = main.Document.Descendants().Any() || main.Document.Descendants().Any(), + HasProtectedParts = main.Document.Descendants().Any(), + Styles = main.StyleDefinitionsPart?.Styles?.Elements