using System.Text; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace WordVersioning.Api.IntegrationTests; /// สร้าง/อ่านไฟล์ .docx สำหรับ test — ไม่ต้องพึ่ง binary file ใน repo /// สร้างได้ทั้ง template ที่มี content controls (SDT) หลายรูปแบบและไฟล์เปล่าไม่มี SDT public static class TestDocx { public const string DocxContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; /// สร้าง template มาตรฐาน: SDT แบบ run เดียว / multi-run คนละ formatting / block-level /// + tag ซ้ำอีกจุด (detect ต้อง dedup) — ครอบคลุมรูปแบบเดียวกับที่ใช้ตรวจ Phase 3 ตอน dev /// /// ⚠️ run-level SDT (SdtRun) ต้องอยู่ "ใน" paragraph เท่านั้น (ตาม OOXML schema เหมือนที่ Word author ให้) /// — วางเป็นลูกตรงของ Body แล้วตอน reload จะกลายเป็น SdtBlock + OpenXmlUnknownElement ทั้งชุด /// ทำให้ generate เข้า branch ผิด (append แทนที่จะ replace) public static byte[] BuildTemplateDocx() { var buffer = new MemoryStream(); using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document)) { var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body( new Paragraph(new Run(new Text("สัญญาจ้างงานฉบับทดสอบ"))), // SDT run-level เดี่ยว — รูปแบบที่พบบ่อยสุด new Paragraph(SdtRun("employee_name", new Run( new RunProperties(new Bold()), new Text("[ชื่อพนักงาน]")))), // SDT multi-run คนละ formatting — generate เขียนค่าที่ run ข้อความยาวสุด (v9) และลบ text ของอีก run // ⚠️ สอง run ยาวเท่ากัน (8/8 อักขระ) โดยเจตนา: tie → run แรก (bold) ชนะเพราะ OrderByDescending // เสถียร — GenerateTests/DraftTests ใช้จุดนี้เป็น regression guard ของพฤติกรรม tie ห้ามแก้ความยาว new Paragraph(SdtRun("position", new Run( new RunProperties(new Bold()), new Text("[ตำแหน่ง")), new Run( new RunProperties(new Italic()), new Text("หน้าที่]")))), // SDT block-level (ทั้งย่อหน้า) — ตัวนี้เท่านั้นที่เป็นลูกตรงของ Body ได้ new SdtBlock( new SdtProperties(new Tag { Val = "salary" }), new SdtContentBlock(new Paragraph(new Run(new Text("[เงินเดือน]"))))), new Paragraph(SdtRun("start_date", new Run(new Text("[วันเริ่มงาน]")))), // tag ซ้ำ (อีกจุดของเอกสาร) — detect ต้องได้ครั้งเดียว new Paragraph(SdtRun("employee_name", new Run(new Text("[ชื่อพนักงานอีกครั้ง]")))), new Paragraph(new Run(new Text("จบเอกสาร"))))); } return buffer.ToArray(); } /// template ตัวแปร: มี employee_name + position เท่านั้น — ใช้ตอน replace ไฟล์ (tag เพิ่ม/หาย) public static byte[] BuildReplacementTemplateDocx() { var buffer = new MemoryStream(); using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document)) { var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body( new Paragraph(new Run(new Text("สัญญาฉบับแก้ไข"))), new Paragraph(SdtRun("employee_name", new Run(new Text("[ชื่อ]")))), new Paragraph(SdtRun("note", new Run(new Text("[หมายเหตุ]")))))); } return buffer.ToArray(); } /// ไฟล์ .docx ไม่มี content control เลย — จำลองเอกสารธรรมดาที่ track เองผ่าน add-in public static byte[] BuildPlainDocx(string text = "เอกสารทั่วไปไม่มี content control") { var buffer = new MemoryStream(); using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document)) { var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text(text))))); } return buffer.ToArray(); } /// template แบบที่พบใน template ไทยจริง (เช่น T14): placeholder แตกเป็น [ / ข้อความไทย / ] /// หลาย run โดย run ข้อความไทยถือ complex script flags (w:hint="cs" + w:cs) ต่างจาก run ปีกกา /// — และมี SDT เปล่าที่มี rPr อยู่ใน sdtPr เท่านั้น (formatting ที่ผู้แต่งกำหนดให้เนื้อหา) public static byte[] BuildThaiTemplateDocx() { RunFonts ThaiFonts(bool hintCs) => new() { Ascii = "TH SarabunPSK", HighAnsi = "TH SarabunPSK", ComplexScript = "TH SarabunPSK", Hint = hintCs ? FontTypeHintValues.ComplexScript : null, }; // แบบ T14 หน้ารับส่ง: [ / รายละเอียด / ] — ค่าที่แทนที่ต้องได้ formatting ของ run ข้อความไทย var detail = new Paragraph(new SdtRun( new SdtProperties(new Tag { Val = "detail1" }), new SdtContentRun( new Run( new RunProperties(ThaiFonts(false), new FontSize { Val = "36" }), new Text("[")), new Run( new RunProperties(ThaiFonts(true), new FontSize { Val = "36" }, new ComplexScript()), new Text("รายละเอียด")), new Run( new RunProperties(ThaiFonts(false), new FontSize { Val = "36" }), new Text("]"))))); // SDT เปล่า: formatting อยู่ที่ sdtPr/rPr เท่านั้น — run ใหม่ต้องสืบทอดมา ไม่ใช่ default font var remark = new Paragraph(new SdtRun( new SdtProperties( new RunProperties(ThaiFonts(true), new FontSize { Val = "36" }), new Tag { Val = "remark1" }), new SdtContentRun())); var buffer = new MemoryStream(); using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document)) { var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(detail, remark)); } return buffer.ToArray(); } /// อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate public static string ReadSdtText(byte[] docx, string tag) { using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false); var main = doc.MainDocumentPart!; foreach (var root in new OpenXmlPartRootElement?[] { main.Document } .Concat(main.HeaderParts.Select(h => (OpenXmlPartRootElement?)h.Header)) .Concat(main.FooterParts.Select(f => (OpenXmlPartRootElement?)f.Footer))) { if (root is null) continue; foreach (var sdtTag in root.Descendants()) { if (sdtTag.Val?.Value != tag) continue; var sdt = sdtTag.Parent?.Parent; var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent"); if (content is null) return ""; var sb = new StringBuilder(); foreach (var text in content.Descendants()) sb.Append(text.Text); return sb.ToString(); } } return ""; } /// อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate /// รวม multi-run เหลือ run ที่ถือค่าแล้วยังรักษา run properties (bold/italic) ของ run นั้นไว้ (v9: run ข้อความยาวสุด) public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag) { using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false); var root = doc.MainDocumentPart!.Document!; foreach (var sdtTag in root.Descendants()) { if (sdtTag.Val?.Value != tag) continue; var sdt = sdtTag.Parent?.Parent; var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent"); return content?.Descendants() .Select(run => ( string.Concat(run.Descendants().Select(t => t.Text)), run.RunProperties?.Bold is not null, run.RunProperties?.Italic is not null)) .ToList() ?? []; } return []; } /// อ่าน custom property (VTLPWSTR) — null เมื่อไม่มี property นั้น /// ใช้ตรวจ SavedByAddIn / DocumentTrackingId ที่ backend stamp ลงไฟล์ (FR-10) public static string? ReadCustomProperty(byte[] docx, string name) { using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false); var props = doc.CustomFilePropertiesPart?.Properties; return props?.Elements() .FirstOrDefault(p => p.Name?.Value == name) ?.VTLPWSTR?.Text; } /// ข้อมูล run หนึ่งตัวใน SDT (ตาม tag): ข้อความรวม + hint=cs ของ rFonts + flag complex script (w:cs) /// + ฟอนต์ ascii — ใช้ตรวจว่าค่าที่แทนที่ได้ formatting ของ run ข้อความไทยจริง (FR-16 v9) public record SdtRunInfo(string Text, bool CsHint, bool ComplexScript, string? AsciiFont); public static List ReadSdtRunInfo(byte[] docx, string tag) { using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false); var main = doc.MainDocumentPart!; foreach (var root in new OpenXmlPartRootElement?[] { main.Document } .Concat(main.HeaderParts.Select(h => (OpenXmlPartRootElement?)h.Header)) .Concat(main.FooterParts.Select(f => (OpenXmlPartRootElement?)f.Footer))) { if (root is null) continue; foreach (var sdtTag in root.Descendants()) { if (sdtTag.Val?.Value != tag) continue; var sdt = sdtTag.Parent?.Parent; var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent"); return content?.Descendants() .Select(run => new SdtRunInfo( string.Concat(run.Descendants().Select(t => t.Text)), run.RunProperties?.RunFonts?.Hint?.Value == FontTypeHintValues.ComplexScript, run.RunProperties?.ComplexScript is not null, run.RunProperties?.RunFonts?.Ascii?.Value)) .ToList() ?? []; } } return []; } private static SdtRun SdtRun(string tag, params Run[] runs) => new( new SdtProperties(new Tag { Val = tag }), new SdtContentRun(runs)); }