2026-08-20 13:51:42 +07:00
|
|
|
using System.Text;
|
|
|
|
|
using DocumentFormat.OpenXml;
|
|
|
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
|
|
|
using DocumentFormat.OpenXml.Wordprocessing;
|
|
|
|
|
|
|
|
|
|
namespace WordVersioning.Api.IntegrationTests;
|
|
|
|
|
|
|
|
|
|
/// <summary>สร้าง/อ่านไฟล์ .docx สำหรับ test — ไม่ต้องพึ่ง binary file ใน repo
|
|
|
|
|
/// สร้างได้ทั้ง template ที่มี content controls (SDT) หลายรูปแบบและไฟล์เปล่าไม่มี SDT</summary>
|
|
|
|
|
public static class TestDocx
|
|
|
|
|
{
|
|
|
|
|
public const string DocxContentType =
|
|
|
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
|
|
|
|
|
|
|
|
/// <summary>สร้าง 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)</summary>
|
|
|
|
|
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("[ชื่อพนักงาน]")))),
|
|
|
|
|
|
2026-09-10 11:49:16 +07:00
|
|
|
// SDT multi-run คนละ formatting — generate เขียนค่าที่ run ข้อความยาวสุด (v9) และลบ text ของอีก run
|
|
|
|
|
// ⚠️ สอง run ยาวเท่ากัน (8/8 อักขระ) โดยเจตนา: tie → run แรก (bold) ชนะเพราะ OrderByDescending
|
|
|
|
|
// เสถียร — GenerateTests/DraftTests ใช้จุดนี้เป็น regression guard ของพฤติกรรม tie ห้ามแก้ความยาว
|
2026-08-20 13:51:42 +07:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>template ตัวแปร: มี employee_name + position เท่านั้น — ใช้ตอน replace ไฟล์ (tag เพิ่ม/หาย)</summary>
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>ไฟล์ .docx ไม่มี content control เลย — จำลองเอกสารธรรมดาที่ track เองผ่าน add-in</summary>
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 11:49:16 +07:00
|
|
|
/// <summary>template แบบที่พบใน template ไทยจริง (เช่น T14): placeholder แตกเป็น [ / ข้อความไทย / ]
|
|
|
|
|
/// หลาย run โดย run ข้อความไทยถือ complex script flags (w:hint="cs" + w:cs) ต่างจาก run ปีกกา
|
|
|
|
|
/// — และมี SDT เปล่าที่มี rPr อยู่ใน sdtPr เท่านั้น (formatting ที่ผู้แต่งกำหนดให้เนื้อหา)</summary>
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 13:51:42 +07:00
|
|
|
/// <summary>อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate</summary>
|
|
|
|
|
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<Tag>())
|
|
|
|
|
{
|
|
|
|
|
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<Text>())
|
|
|
|
|
sb.Append(text.Text);
|
|
|
|
|
return sb.ToString();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate
|
2026-09-10 11:49:16 +07:00
|
|
|
/// รวม multi-run เหลือ run ที่ถือค่าแล้วยังรักษา run properties (bold/italic) ของ run นั้นไว้ (v9: run ข้อความยาวสุด)</summary>
|
2026-08-20 13:51:42 +07:00
|
|
|
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<Tag>())
|
|
|
|
|
{
|
|
|
|
|
if (sdtTag.Val?.Value != tag) continue;
|
|
|
|
|
var sdt = sdtTag.Parent?.Parent;
|
|
|
|
|
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
|
|
|
|
|
return content?.Descendants<Run>()
|
|
|
|
|
.Select(run => (
|
|
|
|
|
string.Concat(run.Descendants<Text>().Select(t => t.Text)),
|
|
|
|
|
run.RunProperties?.Bold is not null,
|
|
|
|
|
run.RunProperties?.Italic is not null))
|
|
|
|
|
.ToList() ?? [];
|
|
|
|
|
}
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>อ่าน custom property (VTLPWSTR) — null เมื่อไม่มี property นั้น
|
|
|
|
|
/// ใช้ตรวจ SavedByAddIn / DocumentTrackingId ที่ backend stamp ลงไฟล์ (FR-10)</summary>
|
|
|
|
|
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<DocumentFormat.OpenXml.CustomProperties.CustomDocumentProperty>()
|
|
|
|
|
.FirstOrDefault(p => p.Name?.Value == name)
|
|
|
|
|
?.VTLPWSTR?.Text;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 11:49:16 +07:00
|
|
|
/// <summary>ข้อมูล run หนึ่งตัวใน SDT (ตาม tag): ข้อความรวม + hint=cs ของ rFonts + flag complex script (w:cs)
|
|
|
|
|
/// + ฟอนต์ ascii — ใช้ตรวจว่าค่าที่แทนที่ได้ formatting ของ run ข้อความไทยจริง (FR-16 v9)</summary>
|
|
|
|
|
public record SdtRunInfo(string Text, bool CsHint, bool ComplexScript, string? AsciiFont);
|
|
|
|
|
|
|
|
|
|
public static List<SdtRunInfo> 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<Tag>())
|
|
|
|
|
{
|
|
|
|
|
if (sdtTag.Val?.Value != tag) continue;
|
|
|
|
|
var sdt = sdtTag.Parent?.Parent;
|
|
|
|
|
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
|
|
|
|
|
return content?.Descendants<Run>()
|
|
|
|
|
.Select(run => new SdtRunInfo(
|
|
|
|
|
string.Concat(run.Descendants<Text>().Select(t => t.Text)),
|
|
|
|
|
run.RunProperties?.RunFonts?.Hint?.Value == FontTypeHintValues.ComplexScript,
|
|
|
|
|
run.RunProperties?.ComplexScript is not null,
|
|
|
|
|
run.RunProperties?.RunFonts?.Ascii?.Value))
|
|
|
|
|
.ToList() ?? [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 13:51:42 +07:00
|
|
|
private static SdtRun SdtRun(string tag, params Run[] runs) => new(
|
|
|
|
|
new SdtProperties(new Tag { Val = tag }),
|
|
|
|
|
new SdtContentRun(runs));
|
|
|
|
|
}
|