Files
autodesk/DocumentAutomation/src/DocxWorkbench.OpenXml/TemplateLearningService.cs
T

74 lines
11 KiB
C#

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using DocumentAutomation.Shared;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
namespace DocxWorkbench.OpenXml;
public sealed class TemplatePackageStore : ITemplatePackageStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public void Save(string packagePath, LearnedTemplate template, string normalizedDocxPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(packagePath))!);
if (File.Exists(packagePath)) File.Delete(packagePath);
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
WriteJson(archive, "manifest.json", new { schemaVersion = "1.0", templateId = template.TemplateId, name = template.Name, sourceFileName = template.SourceFileName, sourceSha256 = template.SourceSha256, normalizedSha256 = template.NormalizedSha256, createdAt = template.CreatedAt, minimumEngineVersion = template.MinimumEngineVersion });
WriteJson(archive, "source-metadata.json", template.Source);
WriteJson(archive, "page-archetypes.json", template.PageArchetypes);
WriteJson(archive, "fixed-regions.json", template.FixedRegions.Concat(template.RepeatingRegions.Select(x => new FixedRegion { Id = x.Id, Name = x.Name, Kind = x.Kind, Pages = x.Pages, Fingerprint = x.Fingerprint, Location = x.Location })).ToArray());
WriteJson(archive, "slots.json", template.Slots);
WriteJson(archive, "field-bindings.json", template.FieldBindings);
WriteJson(archive, "mapping-rules.json", template.Suggestions);
WriteJson(archive, "validation-profile.json", new { schemaVersion = "1.0", requiredSlots = template.Slots.Where(x => x.IsRequired).Select(x => x.ContentControlTag).ToArray(), allowTrackedChanges = false, allowExternalLinks = false });
var entry = archive.CreateEntry("template.docx", CompressionLevel.Optimal); using var input = File.OpenRead(normalizedDocxPath); using var output = entry.Open(); input.CopyTo(output);
}
public (LearnedTemplate Template, string TemplateDocxPath, string WorkingDirectory) Open(string packagePath)
{
if (!File.Exists(packagePath)) throw new FileNotFoundException("Template package was not found.", packagePath);
var work = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "template-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(work); ZipFile.ExtractToDirectory(packagePath, work);
var manifest = ReadJson<Manifest>(Path.Combine(work, "manifest.json")); if (manifest.SchemaVersion != "1.0") throw new InvalidDataException($"Unsupported template schema: {manifest.SchemaVersion}");
var template = new LearnedTemplate { TemplateId = manifest.TemplateId, Name = manifest.Name, SourceFileName = manifest.SourceFileName, SourceSha256 = manifest.SourceSha256, NormalizedSha256 = manifest.NormalizedSha256, CreatedAt = manifest.CreatedAt, MinimumEngineVersion = manifest.MinimumEngineVersion, Source = ReadJson<SourceDocumentStructure>(Path.Combine(work, "source-metadata.json")), PageArchetypes = ReadJson<List<PageArchetype>>(Path.Combine(work, "page-archetypes.json")), FixedRegions = ReadJson<List<FixedRegion>>(Path.Combine(work, "fixed-regions.json")).Where(x => x.Kind != TemplateRegionKind.Repeating).ToArray(), RepeatingRegions = ReadJson<List<FixedRegion>>(Path.Combine(work, "fixed-regions.json")).Where(x => x.Kind == TemplateRegionKind.Repeating).Select(x => new RepeatingRegion { Id = x.Id, Name = x.Name, Pages = x.Pages, Fingerprint = x.Fingerprint, Kind = x.Kind, Location = x.Location }).ToArray(), Slots = ReadJson<List<TemplateSlot>>(Path.Combine(work, "slots.json")), FieldBindings = ReadJson<List<TemplateFieldBinding>>(Path.Combine(work, "field-bindings.json")), Suggestions = ReadJson<List<TemplateLearningSuggestion>>(Path.Combine(work, "mapping-rules.json")) };
var docx = Path.Combine(work, "template.docx"); if (!File.Exists(docx)) throw new InvalidDataException("Template package does not contain template.docx."); return (template, docx, work);
}
private static void WriteJson<T>(ZipArchive archive, string name, T value) { var entry = archive.CreateEntry(name, CompressionLevel.Optimal); using var writer = new StreamWriter(entry.Open()); writer.Write(JsonSerializer.Serialize(value, JsonOptions)); }
private static T ReadJson<T>(string path) => JsonSerializer.Deserialize<T>(File.ReadAllText(path), JsonOptions) ?? throw new InvalidDataException($"Invalid template package entry: {Path.GetFileName(path)}");
private sealed record Manifest(string SchemaVersion, string TemplateId, string Name, string SourceFileName, string SourceSha256, string NormalizedSha256, string CreatedAt, string MinimumEngineVersion);
}
public sealed class TemplateLearningService : ITemplateLearningService
{
private readonly IWordDocumentNormalizer _normalizer;
private readonly ITemplatePackageStore _packages;
private readonly IDocumentInspector _inspector;
public TemplateLearningService(IWordDocumentNormalizer normalizer, ITemplatePackageStore packages, IDocumentInspector? inspector = null) { _normalizer = normalizer; _packages = packages; _inspector = inspector ?? new OpenXmlDocumentInspector(); }
public async Task<LearnedTemplate> LearnAsync(TemplateLearningRequest request, IProgress<WordOperationProgress>? progress, CancellationToken cancellationToken)
{
if (!File.Exists(request.SourcePath)) throw new FileNotFoundException("Template source was not found.", request.SourcePath);
var normalized = await _normalizer.NormalizeAsync(new DocumentNormalizationRequest { SourcePath = request.SourcePath, WorkingDirectory = Path.Combine(Path.GetTempPath(), "DocumentAutomation", "learning-" + Guid.NewGuid().ToString("N")) }, progress, cancellationToken);
try
{
progress?.Report(new WordOperationProgress { Stage = "AnalyzeTemplate", Message = "Inspecting normalized template" });
EnsureBodyMarker(normalized.NormalizedPath); var normalizedHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(normalized.NormalizedPath))); var inspection = _inspector.Inspect(normalized.NormalizedPath) with { FilePath = "template.docx" }; var pages = Math.Max(1, normalized.PageCount ?? inspection.Sections); var archetypes = CreateArchetypes(pages, inspection); var repeated = FindRepeatedRegions(inspection); var slots = request.CreateDefaultSlots ? DefaultSlots() : [];
var source = new SourceDocumentStructure { SourcePath = Path.GetFileName(request.SourcePath), Sha256 = normalized.SourceSha256, NormalizedPath = "template.docx", NormalizedSha256 = normalizedHash, PageCount = pages, SectionCount = inspection.Sections, Stories = ["wdMainTextStory", "wdPrimaryHeaderStory", "wdFirstPageHeaderStory", "wdEvenPagesHeaderStory", "wdPrimaryFooterStory", "wdFootnotesStory", "wdEndnotesStory", "wdCommentsStory", "wdTextFrameStory"], RepeatedFingerprints = repeated.Select(x => x.Fingerprint).ToArray(), Inspection = inspection };
var learned = new LearnedTemplate { TemplateId = Guid.NewGuid().ToString("D"), Name = request.TemplateName, SourceFileName = Path.GetFileName(request.SourcePath), SourceSha256 = normalized.SourceSha256, NormalizedSha256 = normalizedHash, CreatedAt = DateTimeOffset.Now.ToString("O"), Source = source, PageArchetypes = archetypes, FixedRegions = [], RepeatingRegions = repeated, Slots = slots, FieldBindings = [], Suggestions = BuildSuggestions(inspection, slots) };
_packages.Save(request.OutputPackagePath, learned, normalized.NormalizedPath); return learned;
}
finally { TryDeleteDirectory(normalized.NormalizedPath, normalized.SourcePath); }
}
private static void EnsureBodyMarker(string path) { using var doc = WordprocessingDocument.Open(path, true); var main = doc.MainDocumentPart ?? throw new InvalidDataException("Main document part is missing."); var body = main.Document.Body ?? throw new InvalidDataException("Document body is missing."); if (main.Document.InnerText.Contains("{{DOC_BODY}}", StringComparison.Ordinal)) return; body.AppendChild(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); }
private static IReadOnlyList<PageArchetype> CreateArchetypes(int pages, DocumentInspectionResult inspection) { var result = new List<PageArchetype> { new() { Id = "FirstPage", Name = "FirstPage", IsFirstPage = true, IsRepeating = false, ExamplePages = [1], FixedRegionIds = [], SlotIds = [] } }; if (pages > 1) result.Add(new PageArchetype { Id = "RepeatingPage", Name = "RepeatingPage", IsRepeating = true, ExamplePages = Enumerable.Range(2, pages - 1).ToArray(), FixedRegionIds = [], SlotIds = [] }); return result; }
private static IReadOnlyList<RepeatingRegion> FindRepeatedRegions(DocumentInspectionResult inspection) => inspection.SectionAssignments.GroupBy(x => string.Join("|", x.Headers.Concat(x.Footers))).Where(x => x.Count() > 1 && x.Key.Length > 0).Select((x, i) => new RepeatingRegion { Id = "region-" + (i + 1), Name = "Repeated header/footer", Pages = x.Select(y => y.Section).ToArray(), Fingerprint = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(x.Key))), Kind = TemplateRegionKind.Repeating, Location = "section assignments" }).ToArray();
private static IReadOnlyList<TemplateSlot> DefaultSlots() => new[] { "PROJECT.CODE", "PROJECT.NAME", "PROJECT.OBJECT", "DOCUMENT.CODE", "DOCUMENT.TITLE", "DOCUMENT.STAGE", "DOCUMENT.SHEET", "DOCUMENT.SHEET_COUNT", "REGISTER.CONTENTS", "REGISTER.MAIN_DRAWING_SETS", "REGISTER.DRAWINGS", "REGISTER.REFERENCE_DOCUMENTS", "DOCUMENT.GENERAL_NOTES", "DOCUMENT.MAIN_BODY" }.Select(x => new TemplateSlot { Id = x, Name = x, Kind = x == "DOCUMENT.MAIN_BODY" ? TemplateSlotKind.MainBody : TemplateSlotKind.PlainText, ContentControlTag = x, IsRequired = x is "DOCUMENT.CODE" or "DOCUMENT.TITLE" or "DOCUMENT.MAIN_BODY", Occurrences = [] }).ToArray();
private static IReadOnlyList<TemplateLearningSuggestion> BuildSuggestions(DocumentInspectionResult inspection, IReadOnlyList<TemplateSlot> slots) => [new TemplateLearningSuggestion { SuggestionType = "PageArchetype", SourceLocation = "document", ProposedName = inspection.Sections > 1 ? "FirstPage + RepeatingPage" : "SinglePage", Confidence = 0.5, Reason = "Initial deterministic proposal based on document sections." }];
private static void TryDeleteDirectory(string normalized, string source) { var directory = Path.GetDirectoryName(normalized); if (directory is not null && !Path.GetFullPath(source).Equals(Path.GetFullPath(normalized), StringComparison.OrdinalIgnoreCase)) try { if (Directory.Exists(directory)) Directory.Delete(directory, true); } catch { } }
}