using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.CustomProperties; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.VariantTypes; using DocumentFormat.OpenXml.Wordprocessing; namespace WordVersioning.Api.Services; public interface IDocxGenerationService { /// parse content controls (SDT tags) จากไฟล์ template — เรียงตามที่ปรากฏในไฟล์ ไม่ซ้ำ /// (FR-9: detect fields อัตโนมัติตอน upload) IReadOnlyList DetectFieldTags(Stream docxStream); /// FR-10 — สร้างเอกสารจาก template: แทนที่ข้อความใน SDT แต่ละ field /// ที่ run level (รักษา run properties/formatting ของ template) แล้ว stamp /// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่ byte[] Generate(Stream templateStream, IReadOnlyDictionary values, Guid documentTrackingId); /// FR-16 — ไฟล์ร่าง (draft): แทนที่ SDT เหมือน Generate แต่ stamp custom property /// เฉพาะ `TemplateId` (GUID ของ template ต้นทาง) — ไม่ stamp SavedByAddIn/DocumentTrackingId /// เพื่อให้ add-in มองไฟล์ร่างเป็น untracked จนกว่าผู้ใช้จะกด Start Versioning byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary values, Guid templateId); } /// จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10) public class DocxGenerationService : IDocxGenerationService { /// fmtid มาตรฐานของ user-defined custom properties (ตาม MS-ODRAW/Office convention) private const string CustomPropertyFormatId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; public IReadOnlyList DetectFieldTags(Stream docxStream) { using var doc = WordprocessingDocument.Open(docxStream, false); var tags = new List(); foreach (var root in AllContentRoots(doc)) { foreach (var tag in root.Descendants()) { var key = tag.Val?.Value?.Trim(); if (!string.IsNullOrEmpty(key) && !tags.Contains(key)) tags.Add(key); } } return tags; } public byte[] Generate(Stream templateStream, IReadOnlyDictionary values, Guid documentTrackingId) { var buffer = CopyToMemory(templateStream); using (var doc = WordprocessingDocument.Open(buffer, true)) { ReplaceSdtValues(doc, values); SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid); SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString()); } // dispose เขียน package กลับลง buffer return buffer.ToArray(); } public byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary values, Guid templateId) { var buffer = CopyToMemory(templateStream); using (var doc = WordprocessingDocument.Open(buffer, true)) { ReplaceSdtValues(doc, values); // stamp ตัวเดียว (FR-16) — ไฟล์ร่างต้องเป็น untracked ในสายตา add-in SetCustomProperty(doc, AddInMetadata.TemplateIdPropertyName, templateId.ToString()); } return buffer.ToArray(); } /// ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose private static MemoryStream CopyToMemory(Stream stream) { var buffer = new MemoryStream(); stream.CopyTo(buffer); buffer.Position = 0; return buffer; } /// ชื่อไฟล์ของเอกสาร/ไฟล์ร่างที่ generate จาก template: ชื่อ template + timestamp /// — ตัดอักขระที่ filename ใช้ไม่ได้ออก (ใช้ร่วมทั้ง generate และ generate-draft) public static string BuildGeneratedFileName(string templateName, DateTimeOffset now) { var invalid = Path.GetInvalidFileNameChars(); var clean = new string(templateName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim(); if (clean.Length == 0) clean = "document"; return $"{clean}_{now:yyyyMMdd-HHmmss}.docx"; } /// แทนที่ค่าใน SDT ทุกตัวที่ tag ตรง key ใน values (ใช้ร่วมทั้ง Generate และ GenerateDraft) private static void ReplaceSdtValues(WordprocessingDocument doc, IReadOnlyDictionary values) { foreach (var root in AllContentRoots(doc)) { foreach (var tag in root.Descendants().ToList()) { var key = tag.Val?.Value?.Trim(); if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value)) continue; ReplaceSdtContent(tag, value); } } } /// ตัดอักขระที่ XML 1.0 ห้าม (เช่น vertical tab 0x0B จากการ paste ข้อความ) ออกจากค่าก่อนเขียนลงไฟล์ — /// XmlWriter โยน ArgumentException ตอน save ทั้ง package ถ้าปล่อยไว้ (XML รับได้เฉพาะ #x9/#xA/#xD/ /// [#x20-#xD7FF]/[#xE000-#xFFFD]/คู่ surrogate ครบ) — ค่าที่ตัดไปเป็นอักขระที่มองไม่เห็นอยู่แล้ว private static string SanitizeForXml(string value) { // ทางเลือกให้เร็ว: ไม่มีอักขระต้องตัดก็คืนของเดิม (กรณีทั่วไป) var hasInvalid = false; foreach (var c in value) { if (IsInvalidXmlChar(c)) { hasInvalid = true; break; } } if (!hasInvalid) return value; var sb = new System.Text.StringBuilder(value.Length); for (var i = 0; i < value.Length; i++) { var c = value[i]; if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) { sb.Append(c).Append(value[i + 1]); // คู่ surrogate ครบ = XML ใช้ได้ (เช่น อีโมจิ) i++; } else if (!IsInvalidXmlChar(c)) { sb.Append(c); } } return sb.ToString(); // lone surrogate (คู่ไม่ครบ) จัดการเป็นคู่ที่ loop ด้านบน — ที่นี่ surrogate ทุกตัวถือ invalid static bool IsInvalidXmlChar(char c) => (c < 0x20 && c is not '\t' and not '\n' and not '\r') // 0x00–0x1F ยกเว้น tab/LF/CR || char.IsSurrogate(c) || c == '\uFFFE' || c == '\uFFFF'; } /// แทนที่ข้อความใน SDT ที่ run level — เขียนค่าที่ Run ของ "ข้อความ placeholder จริง" /// (run ที่มีข้อความยาวสุด) เพื่อสืบทอด rPr ของมัน แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph /// (spec ส่วน 9 เพิ่มเติม v2 · v9: template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run — /// ตัวข้อความคือ run ที่ถือ complex script flags (w:hint="cs" + w:cs) ที่ทำให้ Word ตัดคำ/กระจายไทยถูก /// ถ้าเขียนลง run วงเล็บที่ไม่มี flags นี้ ค่ายาวๆ จะจัดหน้าผิดทั้งบรรทัด) private static void ReplaceSdtContent(Tag tag, string value) { var sdtPr = tag.Parent; // w:sdtPr var sdt = sdtPr?.Parent; // w:sdt (block หรือ run context — SDK แยก class แต่ localName เดียวกัน) var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent"); if (content is null) return; var texts = content.Descendants().ToList(); if (texts.Count == 0) { // SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run) // โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ — // เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font) var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve }); if (sdtPr?.Elements().FirstOrDefault() is { } sdtRunProperties) run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true); if (content is SdtContentBlock) { var paragraph = content.ChildElements.OfType().FirstOrDefault(); if (paragraph is null) { paragraph = new Paragraph(); content.Append(paragraph); } paragraph.AppendChild(run); } else { content.AppendChild(run); } return; } // เลือก Run ผู้บริจาค formatting = run ที่มีข้อความยาวสุด (tie → run แรก) — หลีกเลี่ยง run ปีกกา/สัญลักษณ์ // ที่ formatting มักไม่ครบ (เช่น ไม่มี complex script flags) var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First(); // เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย donorText.Text = SanitizeForXml(value); donorText.Space = SpaceProcessingModeValues.Preserve; // Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว foreach (var text in texts.Where(t => !ReferenceEquals(t, donorText))) { var run = text.Parent; text.Remove(); // Run ที่กลายเป็นโล่ง (ไม่มี Break/Tab/อื่นๆ) ลบทิ้งไม่ให้เหลือรอย formatting หลอนๆ if (run is { } && run.ChildElements.Count == 0) run.Remove(); } } /// แก้ custom property (มีอยู่แล้ว = แทนที่, ยังไม่มี = เพิ่ม) ผ่าน CustomFilePropertiesPart private static void SetCustomProperty(WordprocessingDocument doc, string name, string value) { var part = doc.CustomFilePropertiesPart; if (part is null) { part = doc.AddCustomFilePropertiesPart(); part.Properties = new Properties(); } var props = part.Properties ?? throw new InvalidOperationException("CustomFilePropertiesPart ไม่มี root Properties"); foreach (var existing in props.Elements() .Where(p => p.Name?.Value == name).ToList()) { existing.Remove(); } // pid ต้องเริ่มที่ 2 และไม่ซ้ำ — ต่อจากตัวสูงสุดที่มีอยู่ var nextPid = props.Elements() .Select(p => p.PropertyId?.Value ?? 1) .DefaultIfEmpty(1) .Max() + 1; props.Append(new CustomDocumentProperty { Name = name, FormatId = CustomPropertyFormatId, PropertyId = nextPid, VTLPWSTR = new VTLPWSTR(value), }); } /// content controls อยู่ได้ทั้ง body, header, footer — เก็บครบทุกที่ private static IEnumerable AllContentRoots(WordprocessingDocument doc) { var main = doc.MainDocumentPart ?? throw new InvalidOperationException("ไฟล์ docx ไม่มี MainDocumentPart"); if (main.Document is { } document) yield return document; foreach (var header in main.HeaderParts) { if (header.Header is { } root) yield return root; } foreach (var footer in main.FooterParts) { if (footer.Footer is { } root) yield return root; } } }