2026-08-19 12:15:20 +07:00
|
|
|
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
|
|
|
|
|
{
|
|
|
|
|
/// <summary>parse content controls (SDT tags) จากไฟล์ template — เรียงตามที่ปรากฏในไฟล์ ไม่ซ้ำ
|
|
|
|
|
/// (FR-9: detect fields อัตโนมัติตอน upload)</summary>
|
|
|
|
|
IReadOnlyList<string> DetectFieldTags(Stream docxStream);
|
|
|
|
|
|
|
|
|
|
/// <summary>FR-10 — สร้างเอกสารจาก template: แทนที่ข้อความใน SDT แต่ละ field
|
|
|
|
|
/// <b>ที่ run level</b> (รักษา run properties/formatting ของ template) แล้ว stamp
|
|
|
|
|
/// Custom Properties (SavedByAddIn + DocumentTrackingId) — คืนเป็น bytes ของไฟล์ใหม่</summary>
|
|
|
|
|
byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId);
|
2026-09-10 10:51:13 +07:00
|
|
|
|
|
|
|
|
/// <summary>FR-16 — ไฟล์ร่าง (draft): แทนที่ SDT เหมือน Generate แต่ stamp custom property
|
|
|
|
|
/// <b>เฉพาะ `TemplateId`</b> (GUID ของ template ต้นทาง) — ไม่ stamp SavedByAddIn/DocumentTrackingId
|
|
|
|
|
/// เพื่อให้ add-in มองไฟล์ร่างเป็น untracked จนกว่าผู้ใช้จะกด Start Versioning</summary>
|
|
|
|
|
byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid templateId);
|
2026-08-19 12:15:20 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>จัดการ docx ด้วย OpenXML SDK — ใช้ทั้งตอน detect fields และตอน generate (FR-9, FR-10)</summary>
|
|
|
|
|
public class DocxGenerationService : IDocxGenerationService
|
|
|
|
|
{
|
|
|
|
|
/// <summary>fmtid มาตรฐานของ user-defined custom properties (ตาม MS-ODRAW/Office convention)</summary>
|
|
|
|
|
private const string CustomPropertyFormatId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
|
|
|
|
|
|
|
|
|
|
public IReadOnlyList<string> DetectFieldTags(Stream docxStream)
|
|
|
|
|
{
|
|
|
|
|
using var doc = WordprocessingDocument.Open(docxStream, false);
|
|
|
|
|
var tags = new List<string>();
|
|
|
|
|
foreach (var root in AllContentRoots(doc))
|
|
|
|
|
{
|
|
|
|
|
foreach (var tag in root.Descendants<Tag>())
|
|
|
|
|
{
|
|
|
|
|
var key = tag.Val?.Value?.Trim();
|
|
|
|
|
if (!string.IsNullOrEmpty(key) && !tags.Contains(key))
|
|
|
|
|
tags.Add(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return tags;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public byte[] Generate(Stream templateStream, IReadOnlyDictionary<string, string> values, Guid documentTrackingId)
|
|
|
|
|
{
|
2026-09-10 10:51:13 +07:00
|
|
|
var buffer = CopyToMemory(templateStream);
|
2026-08-19 12:15:20 +07:00
|
|
|
using (var doc = WordprocessingDocument.Open(buffer, true))
|
|
|
|
|
{
|
2026-09-10 10:51:13 +07:00
|
|
|
ReplaceSdtValues(doc, values);
|
2026-08-19 12:15:20 +07:00
|
|
|
|
|
|
|
|
SetCustomProperty(doc, AddInMetadata.SavedByAddInPropertyName, AddInMetadata.AddInSignatureGuid);
|
|
|
|
|
SetCustomProperty(doc, AddInMetadata.DocumentTrackingIdPropertyName, documentTrackingId.ToString());
|
|
|
|
|
} // dispose เขียน package กลับลง buffer
|
|
|
|
|
|
|
|
|
|
return buffer.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 10:51:13 +07:00
|
|
|
public byte[] GenerateDraft(Stream templateStream, IReadOnlyDictionary<string, string> 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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>ก๊อปเข้า memory ก่อน — MinIO stream ไม่ seek ได้ และ package จะเขียนกลับลง stream นี้ตอน dispose</summary>
|
|
|
|
|
private static MemoryStream CopyToMemory(Stream stream)
|
|
|
|
|
{
|
|
|
|
|
var buffer = new MemoryStream();
|
|
|
|
|
stream.CopyTo(buffer);
|
|
|
|
|
buffer.Position = 0;
|
|
|
|
|
return buffer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>ชื่อไฟล์ของเอกสาร/ไฟล์ร่างที่ generate จาก template: ชื่อ template + timestamp
|
|
|
|
|
/// — ตัดอักขระที่ filename ใช้ไม่ได้ออก (ใช้ร่วมทั้ง generate และ generate-draft)</summary>
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>แทนที่ค่าใน SDT ทุกตัวที่ tag ตรง key ใน values (ใช้ร่วมทั้ง Generate และ GenerateDraft)</summary>
|
|
|
|
|
private static void ReplaceSdtValues(WordprocessingDocument doc, IReadOnlyDictionary<string, string> values)
|
|
|
|
|
{
|
|
|
|
|
foreach (var root in AllContentRoots(doc))
|
|
|
|
|
{
|
|
|
|
|
foreach (var tag in root.Descendants<Tag>().ToList())
|
|
|
|
|
{
|
|
|
|
|
var key = tag.Val?.Value?.Trim();
|
|
|
|
|
if (string.IsNullOrEmpty(key) || !values.TryGetValue(key, out var value))
|
|
|
|
|
continue;
|
|
|
|
|
ReplaceSdtContent(tag, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 12:15:20 +07:00
|
|
|
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เก็บ Run เดิมไว้ตัวแรก (formatting ตาม template)
|
|
|
|
|
/// แล้วลบ Text ที่เหลือ ไม่ทับทั้ง paragraph (spec ส่วน 9 เพิ่มเติม v2)</summary>
|
|
|
|
|
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<Text>().ToList();
|
|
|
|
|
if (texts.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
|
|
|
|
|
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
|
|
|
|
|
if (content is SdtContentBlock)
|
|
|
|
|
{
|
|
|
|
|
var paragraph = content.ChildElements.OfType<Paragraph>().FirstOrDefault();
|
|
|
|
|
if (paragraph is null)
|
|
|
|
|
{
|
|
|
|
|
paragraph = new Paragraph();
|
|
|
|
|
content.Append(paragraph);
|
|
|
|
|
}
|
|
|
|
|
paragraph.AppendChild(run);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
content.AppendChild(run);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// เขียนที่ Text แรก (อยู่ใน Run แรก — สืบทอด rPr ของ run นั้น) แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
|
|
|
|
|
texts[0].Text = value;
|
|
|
|
|
texts[0].Space = SpaceProcessingModeValues.Preserve;
|
|
|
|
|
|
|
|
|
|
// Text ที่เหลือ (จากการ split run เดิม เช่น rsid/formatting ปนกัน) ลบทิ้ง — field = ค่าเดียว
|
|
|
|
|
foreach (var text in texts.Skip(1))
|
|
|
|
|
{
|
|
|
|
|
var run = text.Parent;
|
|
|
|
|
text.Remove();
|
|
|
|
|
// Run ที่กลายเป็นโล่ง (ไม่มี Break/Tab/อื่นๆ) ลบทิ้งไม่ให้เหลือรอย formatting หลอนๆ
|
|
|
|
|
if (run is { } && run.ChildElements.Count == 0)
|
|
|
|
|
run.Remove();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>แก้ custom property (มีอยู่แล้ว = แทนที่, ยังไม่มี = เพิ่ม) ผ่าน CustomFilePropertiesPart</summary>
|
|
|
|
|
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<CustomDocumentProperty>()
|
|
|
|
|
.Where(p => p.Name?.Value == name).ToList())
|
|
|
|
|
{
|
|
|
|
|
existing.Remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pid ต้องเริ่มที่ 2 และไม่ซ้ำ — ต่อจากตัวสูงสุดที่มีอยู่
|
|
|
|
|
var nextPid = props.Elements<CustomDocumentProperty>()
|
|
|
|
|
.Select(p => p.PropertyId?.Value ?? 1)
|
|
|
|
|
.DefaultIfEmpty(1)
|
|
|
|
|
.Max() + 1;
|
|
|
|
|
|
|
|
|
|
props.Append(new CustomDocumentProperty
|
|
|
|
|
{
|
|
|
|
|
Name = name,
|
|
|
|
|
FormatId = CustomPropertyFormatId,
|
|
|
|
|
PropertyId = nextPid,
|
|
|
|
|
VTLPWSTR = new VTLPWSTR(value),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>content controls อยู่ได้ทั้ง body, header, footer — เก็บครบทุกที่</summary>
|
|
|
|
|
private static IEnumerable<OpenXmlPartRootElement> 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|