eCourt-Doc-Edit/backend/WordVersioning.Api/Services/DocxGenerationService.cs

263 lines
14 KiB
C#
Raw Normal View History

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);
/// <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)
{
var buffer = CopyToMemory(templateStream);
2026-08-19 12:15:20 +07:00
using (var doc = WordprocessingDocument.Open(buffer, true))
{
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();
}
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-09-10 12:07:50 +07:00
/// <summary>ตัดอักขระที่ XML 1.0 ห้าม (เช่น vertical tab 0x0B จากการ paste ข้อความ) ออกจากค่าก่อนเขียนลงไฟล์ —
/// XmlWriter โยน ArgumentException ตอน save ทั้ง package ถ้าปล่อยไว้ (XML รับได้เฉพาะ #x9/#xA/#xD/
/// [#x20-#xD7FF]/[#xE000-#xFFFD]/คู่ surrogate ครบ) — ค่าที่ตัดไปเป็นอักขระที่มองไม่เห็นอยู่แล้ว</summary>
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';
}
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
/// <summary>แทนที่ข้อความใน SDT ที่ <b>run level</b> — เขียนค่าที่ 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 นี้ ค่ายาวๆ จะจัดหน้าผิดทั้งบรรทัด)</summary>
2026-08-19 12:15:20 +07:00
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)
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
2026-09-10 12:07:50 +07:00
var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve });
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
if (sdtPr?.Elements<RunProperties>().FirstOrDefault() is { } sdtRunProperties)
run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true);
2026-08-19 12:15:20 +07:00
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;
}
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
// เลือก Run ผู้บริจาค formatting = run ที่มีข้อความยาวสุด (tie → run แรก) — หลีกเลี่ยง run ปีกกา/สัญลักษณ์
// ที่ formatting มักไม่ครบ (เช่น ไม่มี complex script flags)
var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First();
2026-08-19 12:15:20 +07:00
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
// เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
2026-09-10 12:07:50 +07:00
donorText.Text = SanitizeForXml(value);
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
donorText.Space = SpaceProcessingModeValues.Preserve;
// Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว
foreach (var text in texts.Where(t => !ReferenceEquals(t, donorText)))
2026-08-19 12:15:20 +07:00
{
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;
}
}
}