using System.Text.RegularExpressions; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using A = DocumentFormat.OpenXml.Drawing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; using PIC = DocumentFormat.OpenXml.Drawing.Pictures; namespace GB5Shared.DocumentMerge; public interface IWordMergeEngine { Task MergeAsync( byte[] templateBytes, IReadOnlyDictionary fields, IReadOnlyDictionary>>? tables, CancellationToken ct = default); } /// /// DocumentFormat.OpenXml-based Word (.docx) merge engine — the modernized replacement for the /// legacy MergeOpenxmlFreameBLL (see plan's Phase 2 "What exists" section for the gap list this /// fixes): real single-document repeat regions for table rows (clone the OOXML row N times in /// place, not the legacy's clone-and-split-into-separate-files hack), conditional block markers, /// and image-merge-field support. /// /// Placeholder syntax: ##FieldName## (continuity with the legacy engine's convention, per /// the plan's UX decision to keep business users' existing muscle memory). /// - Plain field: ##FieldName## -> replaced with fields["FieldName"] /// - Repeat region: a table row containing ##REPEAT:TableName## is cloned once per row in /// tables["TableName"], each clone's placeholders resolved against that /// row's own field dictionary (merged over the global fields) /// - Conditional block: ##IF:FieldName## ... ##ENDIF## — the paragraphs between (inclusive of /// the marker paragraphs, which have their marker text stripped) are kept /// only if fields["FieldName"] is truthy; otherwise removed entirely. Works /// across paragraphs and inside table cells (paragraph-sequential scan). /// - Image field: ##IMG:FieldName## — replaced with an embedded picture when /// fields["FieldName"] is a non-empty byte[]; otherwise removed. /// /// OOXML gotcha this engine handles deliberately: Word frequently splits a single visible /// "##Field##" token across multiple <w:r> runs (spell-check boundaries, revision marks). /// Naive per-run text search misses these. This engine concatenates all run text within a /// paragraph, performs substitution on the combined string, then — only if something actually /// changed — replaces all of that paragraph's runs with a single new run (carrying the first /// run's formatting) holding the substituted text. Paragraphs with no matching tokens are left /// completely untouched (their original run/formatting structure is preserved byte-for-byte). /// public sealed class WordMergeEngine : IWordMergeEngine { private static readonly Regex TokenRegex = new(@"##(?[A-Za-z0-9_.]+)##", RegexOptions.Compiled); private static readonly Regex IfStartRegex = new(@"##IF:(?[A-Za-z0-9_.]+)##", RegexOptions.Compiled); private static readonly Regex ImgTokenRegex = new(@"##IMG:(?[A-Za-z0-9_.]+)##", RegexOptions.Compiled); private const string EndIfToken = "##ENDIF##"; private const string RepeatPrefix = "##REPEAT:"; public Task MergeAsync( byte[] templateBytes, IReadOnlyDictionary fields, IReadOnlyDictionary>>? tables, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); using var workStream = new MemoryStream(); workStream.Write(templateBytes, 0, templateBytes.Length); workStream.Position = 0; using (var doc = WordprocessingDocument.Open(workStream, true)) { var mainPart = doc.MainDocumentPart ?? throw new InvalidOperationException("Word template has no MainDocumentPart — is this a valid .docx file?"); var body = mainPart.Document.Body ?? throw new InvalidOperationException("Word template's MainDocumentPart has no document Body."); // 1. Expand repeat regions FIRST — operates on whole rows before any text // substitution runs, so per-row field values are applied to each cloned row. if (tables is { Count: > 0 }) { foreach (var (tableName, rows) in tables) ExpandRepeatRows(body, tableName, rows, fields); } // 2. Resolve conditional blocks — removes/keeps whole paragraph ranges. ResolveConditionals(body, fields); // 3. Replace image tokens — must run before the generic field pass strips the token text. ReplaceImageTokens(mainPart, body, fields); // 4. Replace all remaining plain ##Field## placeholders (body-wide, including headers/ // footers are intentionally out of scope for v1 — templates should keep merge // fields in the document body). foreach (var paragraph in body.Descendants().ToList()) ReplaceTokensInParagraph(paragraph, fields); mainPart.Document.Save(); } var result = new DocumentMergeResultDTO { Content = workStream.ToArray(), ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", FileName = "merged.docx" }; return Task.FromResult(result); } // ── Repeat regions (real per-row clone, one document) ──────────────────────────────────── private static void ExpandRepeatRows( Body body, string tableName, IReadOnlyList> rows, IReadOnlyDictionary globalFields) { var marker = $"{RepeatPrefix}{tableName}##"; var templateRow = body.Descendants() .FirstOrDefault(tr => tr.InnerText.Contains(marker, StringComparison.OrdinalIgnoreCase)); if (templateRow is null) return; // No such repeat region in this template — not an error, just a no-op. var parentTable = templateRow.Parent ?? throw new InvalidOperationException($"Repeat-region row for '{tableName}' has no parent table."); if (rows.Count == 0) { templateRow.Remove(); return; } OpenXmlElement insertAfter = templateRow; for (var i = 0; i < rows.Count; i++) { var clone = (TableRow)templateRow.CloneNode(true); var merged = new Dictionary(globalFields, StringComparer.OrdinalIgnoreCase); foreach (var kv in rows[i]) merged[kv.Key] = kv.Value; foreach (var paragraph in clone.Descendants().ToList()) ReplaceTokensInParagraph(paragraph, merged, marker); parentTable.InsertAfter(clone, insertAfter); insertAfter = clone; } templateRow.Remove(); // the original template row has been fully replaced by its clones } // ── Conditional blocks ──────────────────────────────────────────────────────────────────── private static void ResolveConditionals(Body body, IReadOnlyDictionary fields) { var paragraphs = body.Descendants().ToList(); var toRemove = new List(); Paragraph? blockStart = null; string? blockField = null; List blockParagraphs = []; void FinishBlock() { var truthy = IsTruthy(blockField!, fields); if (!truthy) { toRemove.AddRange(blockParagraphs); } else { StripMarkerText(blockParagraphs[0], $"##IF:{blockField}##"); StripMarkerText(blockParagraphs[^1], EndIfToken); } blockStart = null; blockField = null; blockParagraphs = []; } foreach (var p in paragraphs) { var text = p.InnerText; if (blockStart is null) { var ifMatch = IfStartRegex.Match(text); if (!ifMatch.Success) continue; blockStart = p; blockField = ifMatch.Groups["name"].Value; blockParagraphs = [p]; if (text.Contains(EndIfToken, StringComparison.OrdinalIgnoreCase)) FinishBlock(); // single-paragraph conditional (##IF:X## ... ##ENDIF## on one line) continue; } blockParagraphs.Add(p); if (text.Contains(EndIfToken, StringComparison.OrdinalIgnoreCase)) FinishBlock(); } foreach (var p in toRemove) p.Remove(); } // ── Image merge fields ──────────────────────────────────────────────────────────────────── private static void ReplaceImageTokens( MainDocumentPart mainPart, Body body, IReadOnlyDictionary fields) { foreach (var paragraph in body.Descendants().ToList()) { var text = paragraph.InnerText; var matches = ImgTokenRegex.Matches(text); if (matches.Count == 0) continue; foreach (Match m in matches) { var fieldName = m.Groups["name"].Value; if (fields.TryGetValue(fieldName, out var val) && val is byte[] { Length: > 0 } imageBytes) AppendImageRun(mainPart, paragraph, imageBytes); } // Strip the raw ##IMG:X## token text regardless of whether an image was placed — // a missing/invalid image value should degrade to "no image", never leak the token. StripAllMatches(paragraph, ImgTokenRegex); } } private static void AppendImageRun(MainDocumentPart mainPart, Paragraph paragraph, byte[] imageBytes) { var imagePartType = LooksLikePng(imageBytes) ? ImagePartType.Png : ImagePartType.Jpeg; var imagePart = mainPart.AddImagePart(imagePartType); using (var stream = new MemoryStream(imageBytes)) imagePart.FeedData(stream); var relId = mainPart.GetIdOfPart(imagePart); // Default 150x150px rendering box (EMUs: 1px @ 96dpi = 9525 EMU) — templates that need a // different size should crop/scale the source image; this keeps the engine simple. const long extentEmu = 150L * 9525L; var element = new Drawing( new DW.Inline( new DW.Extent { Cx = extentEmu, Cy = extentEmu }, new DW.EffectExtent { LeftEdge = 0L, TopEdge = 0L, RightEdge = 0L, BottomEdge = 0L }, new DW.DocProperties { Id = 1U, Name = "MergedImage" }, new DW.NonVisualGraphicFrameDrawingProperties( new A.GraphicFrameLocks { NoChangeAspect = true }), new A.Graphic( new A.GraphicData( new PIC.Picture( new PIC.NonVisualPictureProperties( new PIC.NonVisualDrawingProperties { Id = 0U, Name = "MergedImage" }, new PIC.NonVisualPictureDrawingProperties()), new PIC.BlipFill( new A.Blip { Embed = relId }, new A.Stretch(new A.FillRectangle())), new PIC.ShapeProperties( new A.Transform2D( new A.Offset { X = 0L, Y = 0L }, new A.Extents { Cx = extentEmu, Cy = extentEmu }), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }) ) ) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }) ) { DistanceFromTop = 0U, DistanceFromBottom = 0U, DistanceFromLeft = 0U, DistanceFromRight = 0U }); var imageRun = new Run(element); paragraph.Append(imageRun); } private static bool LooksLikePng(byte[] bytes) => bytes.Length > 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47; // ── Field-token substitution (paragraph-consolidation to survive split runs) ────────────── private static void ReplaceTokensInParagraph( Paragraph paragraph, IReadOnlyDictionary fields, string? literalMarkerToStrip = null) { var runs = paragraph.Descendants().ToList(); if (runs.Count == 0) return; var original = string.Concat(runs.Select(r => string.Concat(r.Descendants().Select(t => t.Text)))); if (string.IsNullOrEmpty(original)) return; var combined = original; if (!string.IsNullOrEmpty(literalMarkerToStrip)) combined = combined.Replace(literalMarkerToStrip, string.Empty, StringComparison.OrdinalIgnoreCase); combined = TokenRegex.Replace(combined, m => fields.TryGetValue(m.Groups["name"].Value, out var v) ? FormatValue(v) : m.Value); if (combined == original) return; // nothing to change — leave the paragraph's original runs/formatting untouched var firstRunProps = runs[0].RunProperties?.CloneNode(true) as RunProperties; foreach (var r in runs) r.Remove(); var newRun = new Run(); if (firstRunProps is not null) newRun.RunProperties = firstRunProps; newRun.Append(new Text(combined) { Space = SpaceProcessingModeValues.Preserve }); paragraph.Append(newRun); } private static void StripMarkerText(Paragraph paragraph, string marker) => ReplaceTokensInParagraph(paragraph, new Dictionary(), marker); private static void StripAllMatches(Paragraph paragraph, Regex pattern) { var runs = paragraph.Descendants().ToList(); if (runs.Count == 0) return; var original = string.Concat(runs.Select(r => string.Concat(r.Descendants().Select(t => t.Text)))); if (string.IsNullOrEmpty(original)) return; var stripped = pattern.Replace(original, string.Empty); if (stripped == original) return; var firstRunProps = runs[0].RunProperties?.CloneNode(true) as RunProperties; foreach (var r in runs) r.Remove(); if (string.IsNullOrEmpty(stripped)) return; // nothing left to render but the (now appended) image run var newRun = new Run(); if (firstRunProps is not null) newRun.RunProperties = firstRunProps; newRun.Append(new Text(stripped) { Space = SpaceProcessingModeValues.Preserve }); paragraph.PrependChild(newRun); } // ── Shared helpers ──────────────────────────────────────────────────────────────────────── private static bool IsTruthy(string fieldName, IReadOnlyDictionary fields) { if (!fields.TryGetValue(fieldName, out var v) || v is null) return false; return v switch { bool b => b, string s => !string.IsNullOrWhiteSpace(s) && s != "0" && !s.Equals("false", StringComparison.OrdinalIgnoreCase), int i => i != 0, long l => l != 0, decimal d => d != 0, double db => db != 0, _ => true }; } private static string FormatValue(object? value) => value switch { null => string.Empty, string s => s, DateTime dt => dt.ToString("dd-MMM-yyyy"), bool b => b ? "Yes" : "No", _ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty }; }