fix: restore DOCX workbench runtime path

This commit is contained in:
codex
2026-07-30 14:00:31 +03:00
parent dab90f0780
commit 77a62d4564
13 changed files with 381 additions and 89 deletions
@@ -2,6 +2,57 @@ using System.Text.Json.Serialization;
namespace DocumentAutomation.Shared;
public sealed record InspectionWarning
{
public required string Code { get; init; }
public required string Message { get; init; }
public string? Part { get; init; }
public string? Location { get; init; }
public string? MatchedValue { get; init; }
public string? DetectionRule { get; init; }
}
public sealed record DocumentFieldInfo
{
public required string Type { get; init; }
public required string Part { get; init; }
public int? Section { get; init; }
public string? Instruction { get; init; }
}
public sealed record DocumentImageSummary
{
public int RasterImages { get; init; }
public int DrawingObjects { get; init; }
public int VmlShapes { get; init; }
public int ExternalImages { get; init; }
}
public sealed record DocumentStyleInfo
{
public required string StyleId { get; init; }
public string? Name { get; init; }
public string? Type { get; init; }
public bool IsDefault { get; init; }
public string? BasedOn { get; init; }
public string? LinkedStyle { get; init; }
public int UsageCount { get; init; }
}
public sealed record SectionPartAssignment
{
public required int Section { get; init; }
public IReadOnlyList<string> Headers { get; init; } = [];
public IReadOnlyList<string> Footers { get; init; } = [];
public bool LinkToPrevious { get; init; }
}
public sealed record InsertionPointInfo
{
public required string Type { get; init; }
public string? Location { get; init; }
}
public sealed record DocumentInspectionResult
{
public required string FilePath { get; init; }
@@ -11,18 +62,23 @@ public sealed record DocumentInspectionResult
public int Paragraphs { get; init; }
public int Tables { get; init; }
public int Images { get; init; }
public DocumentImageSummary ImageSummary { get; init; } = new();
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 IReadOnlyList<DocumentFieldInfo> Fields { 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 IReadOnlyList<DocumentStyleInfo> StyleDetails { get; init; } = [];
public IReadOnlyList<SectionPartAssignment> SectionAssignments { get; init; } = [];
public InsertionPointInfo InsertionPoint { get; init; } = new() { Type = "None" };
public IReadOnlyList<InspectionWarning> Warnings { get; init; } = [];
public IReadOnlyList<string> Styles => StyleDetails.Select(x => x.StyleId).ToArray();
}
public sealed record WordDocumentBuildRequest
@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
@@ -10,24 +9,47 @@ 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();
private readonly TextBox _template = new() { Width = 520 }; private readonly TextBox _body = new() { Width = 520 }; private readonly TextBox _output = new() { Width = 520 }; private readonly RichTextBox _structure = new() { Dock = DockStyle.Fill, ReadOnly = true }; private readonly RichTextBox _bodyPreview = new() { Dock = DockStyle.Fill, ReadOnly = true }; private readonly TextBox _log = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true }; private readonly TextBox _technicalError = new() { Multiline = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, ReadOnly = true };
private readonly Label _status = new() { AutoSize = true, Text = "Ready" }; private readonly Button _analyze; private readonly Button _build; private readonly Button _validate; private readonly Button _openDocx; private readonly Button _openPdf; private readonly WorkbenchStateController _state = new(); private readonly IDocumentInspector _docx = new OpenXmlDocumentInspector(); private readonly IDocumentInspector _txt = new TextDocumentInspector(); private readonly IWordDocumentService _word = new WordDocumentService(); private DocumentInspectionResult? _templateResult; private DocumentInspectionResult? _bodyResult; private string? _builtDocx; private string? _builtPdf;
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;
Text = "DOCX Workbench"; Width = 1100; Height = 720; 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, "Template DOCX", _template, BrowseTemplate); AddRow(grid, 1, "Document body", _body, BrowseBody); AddRow(grid, 2, "Output DOCX", _output, BrowseOutput);
var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; _analyze = Button("Analyze", async (_, _) => await AnalyzeAsync()); _build = Button("Build", async (_, _) => await BuildAsync()); _validate = Button("Validate", async (_, _) => await ValidateAsync()); _openDocx = Button("Open DOCX", (_, _) => Open(_builtDocx)); _openPdf = Button("Open PDF", (_, _) => Open(_builtPdf)); buttons.Controls.AddRange([_analyze, _build, _validate, _openDocx, _openPdf]); grid.Controls.Add(buttons, 1, 3); grid.SetColumnSpan(buttons, 2);
var tabs = new TabControl { Dock = DockStyle.Fill }; AddTab(tabs, "Template structure", _structure); AddTab(tabs, "Body preview", _bodyPreview); AddTab(tabs, "Result", new Label { Text = "The result becomes available after a successful build.", AutoSize = true }); AddTab(tabs, "Violations", new Label { Text = "No validation has been run.", AutoSize = true }); AddTab(tabs, "Technical error", _technicalError); AddTab(tabs, "Log", _log); Controls.Add(tabs); Controls.Add(grid); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
foreach (var box in new[] { _template, _body, _output }) box.TextChanged += (_, _) => InputsChanged(); RefreshButtons();
}
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 }); }
private static void AddTab(TabControl tabs, string title, Control content) { var tab = new TabPage(title); tab.Controls.Add(content); tabs.TabPages.Add(tab); }
private static void AddRow(TableLayoutPanel panel, int row, string title, TextBox box, EventHandler browse) { panel.Controls.Add(new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row); panel.Controls.Add(box, 1, row); var button = new Button { Text = "Browse...", Width = 95 }; button.Click += browse; panel.Controls.Add(button, 2, row); }
private static Button Button(string text, EventHandler handler) { var button = new Button { Text = text, AutoSize = true }; button.Click += handler; return button; }
private void BrowseTemplate(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "DOCX|*.docx" }; if (dialog.ShowDialog() == DialogResult.OK) _template.Text = dialog.FileName; }
private void BrowseBody(object? sender, EventArgs e) { using var dialog = new OpenFileDialog { Filter = "DOCX or TXT|*.docx;*.txt" }; if (dialog.ShowDialog() == DialogResult.OK) _body.Text = dialog.FileName; }
private void BrowseOutput(object? sender, EventArgs e) { using var dialog = new SaveFileDialog { Filter = "DOCX|*.docx", DefaultExt = "docx" }; if (dialog.ShowDialog() == DialogResult.OK) _output.Text = dialog.FileName; }
private async Task AnalyzeAsync()
{
_templateResult = null; _bodyResult = null; _builtDocx = null; _builtPdf = null; _state.InputsSelected(File.Exists(_template.Text) && File.Exists(_body.Text)); RefreshButtons(); _status.Text = "Analyzing...";
var template = await InspectPathAsync(_template.Text); var body = await InspectPathAsync(_body.Text); if (template.Error is null) { _templateResult = template.Result; ShowStructure(template.Result); } if (body.Error is null) { _bodyResult = body.Result; ShowBody(body.Result, _body.Text); } LogInspection("Template", _template.Text, template); LogInspection("Body", _body.Text, body); var insertion = _templateResult?.InsertionPoint.Type != "None"; _state.AnalysisCompleted(_templateResult is not null, _bodyResult is not null, insertion); RefreshButtons(); _status.Text = _state.State == WorkbenchState.ReadyToBuild ? "Ready to build" : "Analysis completed with errors"; if (!insertion && _templateResult is not null) AppendLog("No DOC_BODY insertion point found. Word was not started.");
}
private Task<(DocumentInspectionResult? Result, Exception? Error, string Inspector)> InspectPathAsync(string path) => Task.Run(() => { try { if (path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) return (_docx.Inspect(path), (Exception?)null, "OpenXmlDocumentInspector"); if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) return (_txt.Inspect(path), (Exception?)null, "TextDocumentInspector"); throw new UnsupportedDocumentFormatException($"Unsupported format {Path.GetExtension(path)}. Choose DOCX or TXT."); } catch (Exception ex) { return ((DocumentInspectionResult?)null, ex, path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "TextDocumentInspector" : "OpenXmlDocumentInspector"); } });
private void ShowStructure(DocumentInspectionResult? result) { if (result is null) return; _structure.Text = JsonSerializer.Serialize(new { result.FilePath, result.Sections, result.Paragraphs, result.Tables, result.Headers, result.Footers, result.Fields, result.ImageSummary, result.SectionAssignments, result.InsertionPoint, result.StyleDetails, result.Warnings }, new JsonSerializerOptions { WriteIndented = true }); }
private void ShowBody(DocumentInspectionResult? result, string path) { if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) _bodyPreview.Text = File.ReadAllText(path); else if (result is not null) _bodyPreview.Text = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); }
private void LogInspection(string label, string path, (DocumentInspectionResult? Result, Exception? Error, string Inspector) value) { AppendLog(JsonSerializer.Serialize(new { label, path, inspector = value.Inspector, result = value.Result, error = value.Error?.Message }, new JsonSerializerOptions { WriteIndented = true })); if (value.Error is not null) _technicalError.Text = value.Error.ToString(); }
private async Task BuildAsync()
{
if (!_state.CanBuild) return; _state.BeginBuild(); RefreshButtons(); _status.Text = "Building..."; try { 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); _builtDocx = result.OutputDocxPath; _builtPdf = pdf.PdfPath; _state.BuildSucceeded(); AppendLog(JsonSerializer.Serialize(new { result, pdf }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Build succeeded"; } catch (Exception ex) { _state.BuildFailed(); _technicalError.Text = ex.ToString(); AppendLog("Build failed: " + ex.Message); _status.Text = "Build failed"; MessageBox.Show(ex.Message, "Build failed", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { RefreshButtons(); }
}
private async Task ValidateAsync()
{
if (!_state.CanValidate || _builtDocx is null || !File.Exists(_builtDocx)) return; _state.BeginValidation(); RefreshButtons(); _status.Text = "Validating..."; try { var result = await Task.Run(() => _docx.Inspect(_builtDocx)); _state.ValidationCompleted(true); AppendLog(JsonSerializer.Serialize(new { label = "Output", result }, new JsonSerializerOptions { WriteIndented = true })); _status.Text = "Validation succeeded"; } catch (Exception ex) { _state.ValidationCompleted(false); _technicalError.Text = ex.ToString(); AppendLog("Validation failed: " + ex.Message); _status.Text = "Validation failed"; MessageBox.Show(ex.Message, "Validation failed", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { RefreshButtons(); }
}
private void InputsChanged() { _templateResult = null; _bodyResult = null; _builtDocx = null; _builtPdf = null; _state.InputsSelected(File.Exists(_template.Text) && File.Exists(_body.Text)); RefreshButtons(); }
private void RefreshButtons() { _analyze.Enabled = File.Exists(_template.Text) && File.Exists(_body.Text); _build.Enabled = _state.CanBuild; _validate.Enabled = _state.CanValidate && _builtDocx is not null && File.Exists(_builtDocx); _openDocx.Enabled = _state.CanOpenDocx && _builtDocx is not null && File.Exists(_builtDocx); _openPdf.Enabled = _state.CanOpenPdf && _builtPdf is not null && File.Exists(_builtPdf); }
private void AppendLog(string text) => _log.AppendText(text + Environment.NewLine);
private static void Open(string? path) { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
}
@@ -7,6 +7,27 @@ public interface IDocumentInspector
DocumentInspectionResult Inspect(string docxPath);
}
public sealed class UnsupportedDocumentFormatException(string message) : InvalidOperationException(message);
public enum WorkbenchState { Empty, InputsSelected, Analyzed, ReadyToBuild, Building, BuildSucceeded, BuildFailed, Validating, ValidationSucceeded, ValidationFailed }
public sealed class WorkbenchStateController
{
public WorkbenchState State { get; private set; } = WorkbenchState.Empty;
public bool CanBuild => State == WorkbenchState.ReadyToBuild;
public bool CanValidate => State == WorkbenchState.BuildSucceeded;
public bool CanOpenDocx { get; private set; }
public bool CanOpenPdf { get; private set; }
public void InputsSelected(bool selected) { State = selected ? WorkbenchState.InputsSelected : WorkbenchState.Empty; if (!selected) ClearArtifacts(); }
public void AnalysisCompleted(bool templateOk, bool bodyOk, bool insertionPointFound) { State = templateOk && bodyOk && insertionPointFound ? WorkbenchState.ReadyToBuild : WorkbenchState.Analyzed; ClearArtifacts(); }
public void BeginBuild() { if (!CanBuild) throw new InvalidOperationException("Build is unavailable in the current state."); State = WorkbenchState.Building; ClearArtifacts(); }
public void BuildSucceeded() { State = WorkbenchState.BuildSucceeded; CanOpenDocx = true; CanOpenPdf = true; }
public void BuildFailed() { State = WorkbenchState.BuildFailed; ClearArtifacts(); }
public void BeginValidation() { if (!CanValidate) throw new InvalidOperationException("Validation is available only after a successful build."); State = WorkbenchState.Validating; }
public void ValidationCompleted(bool success) => State = success ? WorkbenchState.ValidationSucceeded : WorkbenchState.ValidationFailed;
private void ClearArtifacts() { CanOpenDocx = false; CanOpenPdf = false; }
}
public interface IWordDocumentService
{
Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken);
@@ -1,39 +1,72 @@
using System.Text.RegularExpressions;
using DocumentAutomation.Shared;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
namespace DocxWorkbench.OpenXml;
public sealed class DocumentInspector : IDocumentInspector
public sealed class OpenXmlDocumentInspector : IDocumentInspector
{
private static readonly string[] KnownFields = ["PAGE", "NUMPAGES", "SECTIONPAGES", "FILENAME"];
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.");
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath);
if (!docxPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Open XML inspector accepts DOCX only, received {Path.GetExtension(docxPath)}.");
var info = new FileInfo(docxPath); using var package = WordprocessingDocument.Open(docxPath, false); var main = package.MainDocumentPart ?? throw new InvalidDataException("The DOCX has no main document part."); var roots = GetRoots(main); var fields = roots.SelectMany(x => FindFields(x.Root, x.Part, x.Section)).ToArray(); var images = CountImages(main, roots);
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
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 = images.RasterImages, ImageSummary = images,
Headers = main.HeaderParts.Count(), Footers = main.FooterParts.Count(), HasPageFields = fields.Any(x => x.Type == "PAGE"), HasNumPagesFields = fields.Any(x => x.Type == "NUMPAGES"), HasSectionPagesFields = fields.Any(x => x.Type == "SECTIONPAGES"), Fields = fields,
Bookmarks = main.Document.Descendants<BookmarkStart>().Count(), ContentControls = main.Document.Descendants<SdtElement>().Count(), Comments = main.GetPartsOfType<WordprocessingCommentsPart>().Count(), HasTrackedChanges = main.Document.Descendants<Inserted>().Any() || main.Document.Descendants<Deleted>().Any(), HasProtectedParts = main.Document.Descendants<DocumentProtection>().Any(),
StyleDetails = GetStyles(main), SectionAssignments = GetSectionAssignments(main), InsertionPoint = FindInsertionPoint(main.Document), Warnings = FindWarnings(roots)
};
}
private static IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> GetRoots(MainDocumentPart main)
{
var roots = new List<(OpenXmlPartRootElement, string, int?)> { (main.Document, "MainDocumentPart", null) }; roots.AddRange(main.HeaderParts.Select((p, i) => ((OpenXmlPartRootElement)p.Header, $"HeaderPart[{i + 1}]", (int?)null))); roots.AddRange(main.FooterParts.Select((p, i) => ((OpenXmlPartRootElement)p.Footer, $"FooterPart[{i + 1}]", (int?)null))); if (main.DocumentSettingsPart?.Settings is not null) roots.Add((main.DocumentSettingsPart.Settings, "SettingsPart", null)); if (main.FootnotesPart?.Footnotes is not null) roots.Add((main.FootnotesPart.Footnotes, "FootnotesPart", null)); if (main.EndnotesPart?.Endnotes is not null) roots.Add((main.EndnotesPart.Endnotes, "EndnotesPart", null)); foreach (var comments in main.GetPartsOfType<WordprocessingCommentsPart>()) if (comments.Comments is not null) roots.Add((comments.Comments, "CommentsPart", null)); return roots;
}
private static IEnumerable<DocumentFieldInfo> FindFields(OpenXmlElement root, string part, int? section)
{
foreach (var simple in root.Descendants<SimpleField>()) { var type = NormalizeField(simple.Instruction?.Value); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = simple.Instruction?.Value }; }
var active = new List<string>(); foreach (var element in root.Descendants()) { if (element is FieldChar begin && begin.FieldCharType?.Value == FieldCharValues.Begin) active.Add(""); else if (element is FieldCode code && active.Count > 0) active[^1] += " " + code.Text; else if (element is FieldChar end && end.FieldCharType?.Value == FieldCharValues.End && active.Count > 0) { var raw = active[^1]; active.RemoveAt(active.Count - 1); var type = NormalizeField(raw); if (type is not null) yield return new DocumentFieldInfo { Type = type, Part = part, Section = section, Instruction = raw.Trim() }; } }
}
private static string? NormalizeField(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var normalized = Regex.Replace(value, @"\s+", " ").Trim(); var name = Regex.Match(normalized, @"(?i)^(PAGE|NUMPAGES|SECTIONPAGES|FILENAME)\b").Value.ToUpperInvariant(); return KnownFields.Contains(name) ? name : null; }
private static DocumentImageSummary CountImages(MainDocumentPart main, IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots)
{
var imageParts = new HashSet<OpenXmlPart>(); var drawings = roots.Sum(x => x.Root.Descendants<Drawing>().Count()); var shapes = roots.Sum(x => x.Root.Descendants<DocumentFormat.OpenXml.Vml.Shape>().Count()); var external = 0;
foreach (var image in main.GetPartsOfType<ImagePart>()) imageParts.Add(image); foreach (var header in main.HeaderParts) { foreach (var image in header.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += header.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); } foreach (var footer in main.FooterParts) { foreach (var image in footer.GetPartsOfType<ImagePart>()) imageParts.Add(image); external += footer.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); }
external += main.ExternalRelationships.Count(x => x.Uri?.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) == true); return new DocumentImageSummary { RasterImages = imageParts.Count, DrawingObjects = drawings, VmlShapes = shapes, ExternalImages = external };
}
private static IReadOnlyList<DocumentStyleInfo> GetStyles(MainDocumentPart main)
{
var usage = main.Document.Descendants<ParagraphStyleId>().GroupBy(x => x.Val?.Value ?? "").ToDictionary(x => x.Key, x => x.Count()); return main.StyleDefinitionsPart?.Styles?.Elements<Style>().Select(style => new DocumentStyleInfo { StyleId = style.StyleId?.Value ?? "", Name = style.StyleName?.Val?.Value, Type = style.Type is null ? null : style.Type.Value.ToString(), IsDefault = style.Default?.Value == true, BasedOn = style.BasedOn?.Val?.Value, UsageCount = usage.GetValueOrDefault(style.StyleId?.Value ?? "") }).Where(x => x.StyleId.Length > 0).ToArray() ?? [];
}
private static IReadOnlyList<SectionPartAssignment> GetSectionAssignments(MainDocumentPart main)
{
var result = new List<SectionPartAssignment>(); var sections = main.Document.Body?.Elements<SectionProperties>().ToArray() ?? []; for (var i = 0; i < sections.Length; i++) { var section = sections[i]; var headers = section.Elements<HeaderReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); var footers = section.Elements<FooterReference>().Select(x => $"{x.Type?.Value}:{PartName(main, x.Id?.Value)}").ToArray(); result.Add(new SectionPartAssignment { Section = i + 1, Headers = headers, Footers = footers, LinkToPrevious = i > 0 && headers.Length == 0 && footers.Length == 0 }); } return result;
}
private static string PartName(MainDocumentPart main, string? id) { if (id is null) return "missing"; try { return main.GetPartById(id).Uri.ToString(); } catch { return "unresolved:" + id; } }
private static InsertionPointInfo FindInsertionPoint(Document root) { if (root.Descendants<SdtElement>().Any(x => string.Equals(x.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "ContentControl", Location = "MainDocumentPart" }; if (root.Descendants<BookmarkStart>().Any(x => string.Equals(x.Name?.Value, "DOC_BODY", StringComparison.OrdinalIgnoreCase))) return new InsertionPointInfo { Type = "Bookmark", Location = "MainDocumentPart" }; if (root.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "MainDocumentPart" }; return new InsertionPointInfo { Type = "None" }; }
private static IReadOnlyList<InspectionWarning> FindWarnings(IReadOnlyList<(OpenXmlPartRootElement Root, string Part, int? Section)> roots) { var result = new List<InspectionWarning>(); foreach (var root in roots) foreach (Match match in Regex.Matches(root.Root.InnerText, @"(?i)\b(2019|legacy|старый)\b")) result.Add(new InspectionWarning { Code = "PossibleLegacyProjectData", Message = "A value matched the legacy-project heuristic.", Part = root.Part, Location = "text", MatchedValue = match.Value, DetectionRule = "word-boundary: 2019|legacy|старый" }); return result; }
}
public sealed class TextDocumentInspector : IDocumentInspector
{
public DocumentInspectionResult Inspect(string path) { if (!File.Exists(path)) throw new FileNotFoundException("TXT was not found.", path); if (!path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"TXT inspector accepts TXT only, received {Path.GetExtension(path)}."); var info = new FileInfo(path); return new DocumentInspectionResult { FilePath = path, FileSize = info.Length, LastWriteTimeUtc = info.LastWriteTimeUtc, Sections = 1, Paragraphs = File.ReadAllLines(path).Length, InsertionPoint = new InsertionPointInfo { Type = "None" } }; }
}
public sealed class DocumentInspector : IDocumentInspector
{
private readonly OpenXmlDocumentInspector _inner = new();
public DocumentInspectionResult Inspect(string docxPath) => _inner.Inspect(docxPath);
}
@@ -9,7 +9,6 @@
<ItemGroup>
<ProjectReference Include="..\DocumentAutomation.Shared\DocumentAutomation.Shared.csproj" />
<ProjectReference Include="..\DocxWorkbench.Core\DocxWorkbench.Core.csproj" />
<PackageReference Include="Microsoft.Office.Interop.Word" Version="15.0.4797.1004" />
</ItemGroup>
</Project>
@@ -0,0 +1,40 @@
using System.Collections.Concurrent;
namespace DocxWorkbench.WordInterop;
internal sealed class StaWordQueue : IDisposable
{
private readonly BlockingCollection<Action> _jobs = new();
private readonly Thread _thread;
public StaWordQueue()
{
_thread = new Thread(Run) { IsBackground = true, Name = "DocumentAutomation.Word.STA" };
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
public Task<T> Enqueue<T>(Func<T> operation, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
_jobs.Add(() =>
{
try { cancellationToken.ThrowIfCancellationRequested(); completion.SetResult(operation()); }
catch (OperationCanceledException ex) { completion.SetCanceled(ex.CancellationToken); }
catch (Exception ex) { completion.SetException(ex); }
}, cancellationToken);
return completion.Task;
}
private void Run()
{
foreach (var job in _jobs.GetConsumingEnumerable()) job();
}
public void Dispose()
{
_jobs.CompleteAdding();
if (Thread.CurrentThread != _thread) _thread.Join(TimeSpan.FromSeconds(5));
_jobs.Dispose();
}
}
@@ -1,55 +1,97 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using DocumentAutomation.Shared;
using DocxWorkbench.Core;
using Word = Microsoft.Office.Interop.Word;
namespace DocxWorkbench.WordInterop;
public sealed class WordDocumentService : IWordDocumentService
public sealed class WordNotInstalledException(string message) : InvalidOperationException(message);
public sealed class WordAutomationException(string message, Exception? inner = null) : InvalidOperationException(message, inner);
public sealed class WordDocumentService : IWordDocumentService, IDisposable
{
private readonly StaWordQueue _queue = new();
public Task<WordDocumentResult> BuildDocumentAsync(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken cancellationToken)
=> Task.Run(() => Build(request, progress, cancellationToken), cancellationToken);
=> _queue.Enqueue(() => BuildOnSta(request, progress, cancellationToken), cancellationToken);
public Task<WordPreviewResult> ExportPdfAsync(string docxPath, string outputPdfPath, CancellationToken cancellationToken)
=> Task.Run(() => ExportPdf(docxPath, outputPdfPath, cancellationToken), cancellationToken);
=> _queue.Enqueue(() => ExportPdfOnSta(docxPath, outputPdfPath, cancellationToken), cancellationToken);
private static WordDocumentResult Build(WordDocumentBuildRequest request, IProgress<string>? progress, CancellationToken token)
private static WordDocumentResult BuildOnSta(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;
DocumentInputValidation.Validate(request);
if (!request.BodyPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) && !request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) throw new UnsupportedDocumentFormatException($"Unsupported body format: {Path.GetExtension(request.BodyPath)}. Use DOCX or TXT.");
var insertion = TemplateInsertionPointReader.Find(request.TemplatePath);
if (insertion.Type == "None") throw new WordAutomationException("No DOC_BODY insertion point was found. Add a DOC_BODY content control, DOC_BODY bookmark, or {{DOC_BODY}} marker.");
var started = Stopwatch.StartNew(); var temp = Path.Combine(Path.GetTempPath(), "DocumentAutomation", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); var workingTemplate = Path.Combine(temp, "template.docx"); var stagedOutput = Path.Combine(temp, "result.docx"); File.Copy(request.TemplatePath, workingTemplate, true);
dynamic? application = null; dynamic? documents = null; dynamic? document = null; dynamic? range = null; var proxies = new List<object?>(); int? wordPid = 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 };
token.ThrowIfCancellationRequested(); progress?.Report("Checking Word.Application COM registration"); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); wordPid = TryGetPid(application); progress?.Report($"Word COM started (PID {wordPid?.ToString() ?? "unknown"})");
documents = Own(application.Documents, proxies); document = Own(documents.Open(workingTemplate, false, false, false), proxies) ?? throw new WordAutomationException("Word could not open the working template copy.");
range = Own(FindInsertionRange(document, insertion.Type, proxies), proxies) ?? throw new WordAutomationException("The insertion point disappeared while opening the template.");
token.ThrowIfCancellationRequested(); progress?.Report($"Inserting {Path.GetExtension(request.BodyPath).ToUpperInvariant()} body"); if (request.BodyPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) range.Text = File.ReadAllText(request.BodyPath).Replace("\r\n", "\r").Replace('\n', '\r'); else InsertDocxFile(range, request.BodyPath);
token.ThrowIfCancellationRequested(); progress?.Report("Updating fields and pagination"); document.Repaginate(); document.Fields.Update(); UpdateHeadersFooters(document, proxies); document.SaveAs2(stagedOutput, 12); CommitOutput(stagedOutput, request.OutputDocxPath);
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.Type, wordPid, staThread = Thread.CurrentThread.ManagedThreadId, staApartment = Thread.CurrentThread.GetApartmentState().ToString(), durationMs = started.ElapsedMilliseconds, completedAtUtc = DateTime.UtcNow }, new JsonSerializerOptions { WriteIndented = true }));
return new WordDocumentResult { OutputDocxPath = request.OutputDocxPath, LogPath = logPath, Duration = started.Elapsed, InsertionPoint = insertion.Type };
}
catch (WordNotInstalledException) { throw; }
catch (WordAutomationException) { throw; }
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("Word automation failed. See technical details in the operation log.", ex); }
finally
{
try { if (document is not null) document.Close(0); } catch { }
try { if (application is not null) application.Quit(0); } catch { }
ReleaseAll(proxies); TryDeleteDirectory(temp);
}
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)
private static WordPreviewResult ExportPdfOnSta(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); } }
if (!File.Exists(docxPath)) throw new FileNotFoundException("DOCX was not found.", docxPath); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPdfPath))!); var sw = Stopwatch.StartNew(); dynamic? application = null; dynamic? documents = null; dynamic? document = null; var proxies = new List<object?>();
try { token.ThrowIfCancellationRequested(); var wordType = Type.GetTypeFromProgID("Word.Application", false) ?? throw new WordNotInstalledException("Microsoft Word is not installed or is not registered in COM."); application = Own(Activator.CreateInstance(wordType), proxies) ?? throw new WordAutomationException("Could not create Word.Application."); application.Visible = false; application.DisplayAlerts = 0; TrySetAutomationSecurity(application); documents = Own(application.Documents, proxies); document = Own(documents.Open(docxPath, true, false, false), proxies) ?? throw new WordAutomationException("Word could not open the DOCX."); document.ExportAsFixedFormat(outputPdfPath, 17); return new WordPreviewResult { PdfPath = outputPdfPath, Duration = sw.Elapsed }; }
catch (WordNotInstalledException) { throw; }
catch (WordAutomationException) { throw; }
catch (Exception ex) when (ex is not OperationCanceledException) { throw new WordAutomationException("PDF export through Word failed.", ex); }
finally { try { if (document is not null) document.Close(0); } catch { } try { if (application is not null) application.Quit(0); } catch { } ReleaseAll(proxies); }
}
private static Word.Range? FindInsertionRange(Word.Document document, out string point)
private static dynamic? FindInsertionRange(dynamic document, string type, List<object?> proxies)
{
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;
if (type == "ContentControl") { dynamic controls = Own(document.ContentControls, proxies); for (var i = 1; i <= controls.Count; i++) { dynamic control = Own(controls[i], proxies); if (string.Equals((string?)control.Tag, "DOC_BODY", StringComparison.OrdinalIgnoreCase) || string.Equals((string?)control.Title, "DOC_BODY", StringComparison.OrdinalIgnoreCase)) return control.Range; } }
if (type == "Bookmark") return document.Bookmarks["DOC_BODY"].Range;
dynamic search = Own(document.Content, proxies); search.Find.ClearFormatting(); search.Find.Text = "{{DOC_BODY}}"; if (search.Find.Execute()) { search.Text = ""; return search; } return null;
}
private static int? GetProcessId(Word.Application app) { try { return Process.GetProcessesByName("WINWORD").OrderByDescending(p => p.StartTime).FirstOrDefault()?.Id; } catch { return null; } }
private static void UpdateHeadersFooters(dynamic document, List<object?> proxies)
{
dynamic sections = Own(document.Sections, proxies); for (var i = 1; i <= sections.Count; i++) { dynamic section = Own(sections[i], proxies); UpdateCollection(section.Headers, proxies); UpdateCollection(section.Footers, proxies); }
void UpdateCollection(dynamic collection, List<object?> proxies) { dynamic owned = WordDocumentService.Own(collection, proxies); for (var i = 1; i <= owned.Count; i++) { dynamic item = WordDocumentService.Own(owned[i], proxies); dynamic range = WordDocumentService.Own(item.Range, proxies); range.Fields.Update(); } }
}
private static void TrySetAutomationSecurity(dynamic application) { try { application.AutomationSecurity = 3; application.ScreenUpdating = false; } catch { } }
private static void InsertDocxFile(dynamic range, string path) { range.GetType().InvokeMember("InsertFile", BindingFlags.InvokeMethod | BindingFlags.OptionalParamBinding, null, range, new object[] { path, Type.Missing, Type.Missing, Type.Missing, Type.Missing }); }
private static int? TryGetPid(dynamic application) { try { return Process.GetProcessById((int)application.Hwnd).Id; } catch { return null; } }
private static T? Own<T>(T? value, List<object?> proxies) where T : class { if (value is not null) proxies.Add(value); return value; }
private static void ReleaseAll(IEnumerable<object?> proxies) { foreach (var proxy in proxies.Reverse()) try { if (proxy is not null && Marshal.IsComObject(proxy)) Marshal.FinalReleaseComObject(proxy); } catch { } }
private static void CommitOutput(string staged, string output) { var full = Path.GetFullPath(output); if (File.Exists(full)) File.Copy(full, full + ".bak-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"), true); File.Copy(staged, full, true); }
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } }
public void Dispose() => _queue.Dispose();
}
internal static class TemplateInsertionPointReader
{
public static InsertionPointInfo Find(string docxPath)
{
using var archive = ZipFile.OpenRead(docxPath); var entry = archive.GetEntry("word/document.xml") ?? throw new InvalidDataException("DOCX document.xml is missing."); using var reader = new StreamReader(entry.Open()); var xml = reader.ReadToEnd();
if (Regex.IsMatch(xml, @"w:tag[^>]+w:val=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "ContentControl", Location = "word/document.xml" };
if (Regex.IsMatch(xml, @"w:bookmarkStart[^>]+w:name=""DOC_BODY""", RegexOptions.IgnoreCase)) return new InsertionPointInfo { Type = "Bookmark", Location = "word/document.xml" };
if (xml.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return new InsertionPointInfo { Type = "TextMarker", Location = "word/document.xml" };
return new InsertionPointInfo { Type = "None" };
}
}