import PizzaZip from "pizzip"; import { DOMParser, XMLSerializer, type Document as XmlDocument, type Element as XmlElement, type Node as XmlNode, type Attr as XmlAttr, } from "@xmldom/xmldom"; import xpath from "xpath"; export interface ExtractedData { [tagName: string]: string; } const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const asXpathNode = (node: XmlNode): globalThis.Node => node as unknown as globalThis.Node; function createSelector() { return xpath.useNamespaces({ w: W_NS }); } /** * อ่าน Content Control (w:sdt) จาก DOCX Buffer * ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ */ export function extractContentControls( docxBuffer: Buffer, ): ExtractedData { const zip = new PizzaZip(docxBuffer); const docXmlFile = zip.file("word/document.xml"); if (!docXmlFile) { throw new Error("Invalid docx file: word/document.xml not found."); } const parser = new DOMParser(); const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); const select = createSelector(); const result: ExtractedData = {}; const sdtNodes = select( "//w:sdt", asXpathNode(doc), ) as unknown as XmlElement[]; sdtNodes.forEach((sdt) => { const tagAttr = select( "./w:sdtPr/w:tag/@w:val", asXpathNode(sdt), true, ) as unknown as XmlAttr | undefined; const tagName = tagAttr?.value; if (!tagName) return; const sdtContent = select( "./w:sdtContent", asXpathNode(sdt), true, ) as unknown as XmlElement | undefined; if (!sdtContent) { result[tagName] = ""; return; } const nodes = select( ".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym", asXpathNode(sdtContent), ) as unknown as XmlElement[]; let extractedText = ""; nodes.forEach((node) => { switch (node.nodeName) { case "w:p": if ( extractedText.length > 0 && !extractedText.endsWith("\n") ) { extractedText += "\n"; } break; case "w:t": extractedText += node.textContent || ""; break; case "w:tab": extractedText += "\t"; break; case "w:br": extractedText += "\n"; break; case "w:noBreakHyphen": extractedText += "\u2011"; break; case "w:softHyphen": extractedText += "\u00ad"; break; case "w:sym": { const charAttr = node.getAttribute("w:char"); if (charAttr) { const charCode = Number.parseInt(charAttr, 16); extractedText += String.fromCharCode(charCode); } break; } } }); result[tagName] = extractedText; }); return result; } type SdtControlType = "plain" | "rich" | "other"; type SdtStructure = "block" | "inline"; function detectSdtControlType( sdt: XmlElement, select: xpath.XPathSelect, ): { type: SdtControlType; multiLine: boolean } { const sdtPr = select( "./w:sdtPr", asXpathNode(sdt), true, ) as unknown as XmlElement | undefined; if (!sdtPr) { return { type: "other", multiLine: false }; } const textEl = select( "./w:text", asXpathNode(sdtPr), true, ) as unknown as XmlElement | undefined; if (textEl) { const multiLineAttr = select( "./@w:multiLine", asXpathNode(textEl), true, ) as unknown as XmlAttr | undefined; return { type: "plain", multiLine: multiLineAttr?.value === "1" || multiLineAttr?.value === "true", }; } const richTextEl = select( "./w:richText", asXpathNode(sdtPr), true, ) as unknown as XmlElement | undefined; if (richTextEl) { return { type: "rich", multiLine: true }; } const otherTypeTags = [ "w:date", "w:dropDownList", "w:comboBox", "w:checkbox", "w:citation", "w:group", "w:docPartObj", "w:docPartList", ]; for (const tag of otherTypeTags) { const found = select( `./${tag}`, asXpathNode(sdtPr), true, ) as unknown as XmlElement | undefined; if (found) { return { type: "other", multiLine: false }; } } return { type: "rich", multiLine: true }; } function detectSdtStructure( sdtContent: XmlElement, select: xpath.XPathSelect, ): SdtStructure { const directP = select( "./w:p", asXpathNode(sdtContent), ) as unknown as XmlElement[]; return directP.length > 0 ? "block" : "inline"; } function buildRunChildren( doc: XmlDocument, text: string, ): XmlElement[] { const nodes: XmlElement[] = []; const tokens = text.split(/(\t|\n)/); tokens.forEach((token) => { if (token === "") return; if (token === "\t") { nodes.push(doc.createElementNS(W_NS, "w:tab")); } else if (token === "\n") { nodes.push(doc.createElementNS(W_NS, "w:br")); } else { const t = doc.createElementNS(W_NS, "w:t"); t.setAttribute("xml:space", "preserve"); t.textContent = token; nodes.push(t); } }); if (nodes.length === 0) { const t = doc.createElementNS(W_NS, "w:t"); t.setAttribute("xml:space", "preserve"); t.textContent = ""; nodes.push(t); } return nodes; } function buildRun( doc: XmlDocument, text: string, rPr?: XmlElement, ): XmlElement { const run = doc.createElementNS(W_NS, "w:r"); if (rPr) { run.appendChild(rPr.cloneNode(true)); } buildRunChildren(doc, text).forEach((node) => run.appendChild(node), ); return run; } function insertInlineContent( doc: XmlDocument, sdtContent: XmlElement, select: xpath.XPathSelect, text: string, allowLineBreak: boolean, ): void { const safeText = allowLineBreak ? text : text.replace(/\n/g, " "); const existingRuns = select( "./w:r", asXpathNode(sdtContent), ) as unknown as XmlElement[]; const firstRPr = existingRuns.length > 0 ? (select( "./w:rPr", asXpathNode(existingRuns[0]), true, ) as unknown as XmlElement | undefined) : undefined; existingRuns.forEach((r) => { if (r.parentNode) r.parentNode.removeChild(r); }); sdtContent.appendChild( buildRun(doc, safeText, firstRPr), ); } function insertBlockContent( doc: XmlDocument, sdtContent: XmlElement, select: xpath.XPathSelect, text: string, allowMultiParagraph: boolean, ): void { const allParagraphs = select( "./w:p", asXpathNode(sdtContent), ) as unknown as XmlElement[]; if (allParagraphs.length === 0) { // ป้องกัน template ที่มีโครงสร้างผิดปกติ const p = doc.createElementNS(W_NS, "w:p"); sdtContent.appendChild(p); allParagraphs.push(p); } const firstP = allParagraphs[0]; const firstPPr = select( "./w:pPr", asXpathNode(firstP), true, ) as unknown as XmlElement | undefined; const firstRun = select( "./w:r", asXpathNode(firstP), true, ) as unknown as XmlElement | undefined; const firstRPr = firstRun ? (select( "./w:rPr", asXpathNode(firstRun), true, ) as unknown as XmlElement | undefined) : undefined; allParagraphs.forEach((p) => { if (p !== firstP && p.parentNode) { p.parentNode.removeChild(p); } }); const existingRuns = select( "./w:r", asXpathNode(firstP), ) as unknown as XmlElement[]; existingRuns.forEach((r) => { if (r.parentNode) r.parentNode.removeChild(r); }); if (!allowMultiParagraph) { firstP.appendChild(buildRun(doc, text, firstRPr)); return; } text.split("\n").forEach((line, index) => { let targetP: XmlElement; if (index === 0) { targetP = firstP; } else { targetP = doc.createElementNS(W_NS, "w:p"); if (firstPPr) { targetP.appendChild(firstPPr.cloneNode(true)); } sdtContent.appendChild(targetP); } const run = doc.createElementNS(W_NS, "w:r"); if (firstRPr) { run.appendChild(firstRPr.cloneNode(true)); } buildRunChildren(doc, line).forEach((node) => run.appendChild(node), ); targetP.appendChild(run); }); } function insertFallbackContent( sdtContent: XmlElement, select: xpath.XPathSelect, text: string, ): void { const textNodes = select( ".//w:t", asXpathNode(sdtContent), ) as unknown as XmlElement[]; if (textNodes.length === 0) return; textNodes[0].textContent = text; for (let i = 1; i < textNodes.length; i++) { textNodes[i].textContent = ""; } } export type TemplateData = Record; function normalizeValue(value: unknown): string { if (value === null || value === undefined) { return ""; } if (typeof value === "string") { return value; } if ( typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ) { return String(value); } // รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text return JSON.stringify(value); } /** * นำ JSON data ไปแทนค่า Content Control ใน DOCX template * คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว */ export function templateContentControls( templateBuffer: Buffer, data: TemplateData, ): Buffer { const zip = new PizzaZip(templateBuffer); const docXmlFile = zip.file("word/document.xml"); if (!docXmlFile) { throw new Error("Invalid docx file: word/document.xml not found."); } const parser = new DOMParser(); const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); const select = createSelector(); const sdtNodes = select( "//w:sdt", asXpathNode(doc), ) as unknown as XmlElement[]; sdtNodes.forEach((sdt) => { const tagAttr = select( "./w:sdtPr/w:tag/@w:val", asXpathNode(sdt), true, ) as unknown as XmlAttr | undefined; const tagName = tagAttr?.value; if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) { return; } const newValue = normalizeValue(data[tagName]); const sdtContent = select( "./w:sdtContent", asXpathNode(sdt), true, ) as unknown as XmlElement | undefined; if (!sdtContent) return; const { type: controlType, multiLine } = detectSdtControlType(sdt, select); const structure = detectSdtStructure(sdtContent, select); if (controlType === "other") { insertFallbackContent(sdtContent, select, newValue); return; } if (structure === "inline") { const allowLineBreak = controlType === "plain" ? multiLine : true; insertInlineContent( doc, sdtContent, select, newValue, allowLineBreak, ); } else { insertBlockContent( doc, sdtContent, select, newValue, controlType === "rich", ); } }); const serializer = new XMLSerializer(); zip.file( "word/document.xml", serializer.serializeToString(doc), ); return zip.generate({ type: "nodebuffer", compression: "DEFLATE", }); }