eCourt-Doc-Edit/backend/WordVersioning.Api/Services/DocxGenerationService.cs
Suphonchai Phoonsawat b9b227926d Hotfix: strip invalid XML control chars from field values (FR-16)
ผู้ใช้ paste ข้อความที่มี vertical tab (0x0B) เข้าฟอร์ม — XML 1.0 ห้าม
อักขระนี้ ทำให้ XmlWriter โยน ArgumentException ตอน save package และ
หลุดเป็น 500 stack เต็มจอ (catch เดิมครอบแค่ OpenXmlPackageException)

- DocxGenerationService.SanitizeForXml: ตัดอักขระที่ XML 1.0 ห้าม
  (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — คู่
  surrogate ครบ เช่น อีโมจิ คงไว้) ที่จุดเขียนค่าทั้งสองของ
  ReplaceSdtContent → ครอบทั้ง generate และ generate-draft
- Controllers: broad catch (ยกเว้น cancellation) → 502 ข้อความอ่านออก
  แทน 500 ดิบ สำหรับ exception ที่ไม่ได้ตั้งใจจาก OpenXML
- Test: Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500 (draft +
  generate v0) — reviewer พิสูจน์แล้วว่า fail จริงบนโค้ดก่อนแก้
- backend 85/85; QA reviewer PASS (1 round, minors 3: แก้ 1 ปฏิเสธ 2
  พร้อมเหตุผล — บันทึกใน acceptance-review.md)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:07:50 +07:00

262 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
/// <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);
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<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);
}
}
}
/// <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';
}
/// <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>
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)
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
var run = new Run(new Text(SanitizeForXml(value)) { Space = SpaceProcessingModeValues.Preserve });
if (sdtPr?.Elements<RunProperties>().FirstOrDefault() is { } sdtRunProperties)
run.RunProperties = (RunProperties)sdtRunProperties.CloneNode(true);
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;
}
// เลือก 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();
}
}
/// <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;
}
}
}