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>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 12:07:50 +07:00
parent 70f71f4c5c
commit b9b227926d
8 changed files with 96 additions and 7 deletions

View file

@ -108,6 +108,47 @@ public class DocxGenerationService : IDocxGenerationService
}
}
/// <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 —
@ -127,7 +168,7 @@ public class DocxGenerationService : IDocxGenerationService
// SDT ไม่มีข้อความเลย (เช่น ที่ว่างมา) — สร้าง Run ใหม่ใน paragraph แรก (block) หรือใน content ตรงๆ (run)
// โดยสืบทอด rPr จาก sdtPr ถ้ามี (คือ formatting ที่ผู้แต่ง template กำหนดให้เนื้อหาของ control นี้ —
// เช่นเดียวกับที่ Word ใช้ตอนผู้ใช้พิมพ์ลง control ไม่ใช่ปล่อยเป็น default font)
var run = new Run(new Text(value) { Space = SpaceProcessingModeValues.Preserve });
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)
@ -152,7 +193,7 @@ public class DocxGenerationService : IDocxGenerationService
var donorText = texts.OrderByDescending(t => t.Text?.Length ?? 0).First();
// เขียนที่ Text ของ donor แล้วเก็บ space ไว้กรณีค่ามีช่องว่างหัว/ท้าย
donorText.Text = value;
donorText.Text = SanitizeForXml(value);
donorText.Space = SpaceProcessingModeValues.Preserve;
// Text อื่นทั้งหมด (รวม donor ถ้ามีหลาย Text ใน run เดียวกัน ไม่เกิด) ลบทิ้ง — field = ค่าเดียว