1264 lines
32 KiB
TypeScript
1264 lines
32 KiB
TypeScript
|
|
import createReport from 'docx-templates';
|
||
|
|
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";
|
||
|
|
const WORD_NS =
|
||
|
|
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||
|
|
|
||
|
|
export interface ExtractedData {
|
||
|
|
[tagName: string]: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const asXpathNode = (node: XmlNode): globalThis.Node =>
|
||
|
|
node as unknown as globalThis.Node;
|
||
|
|
|
||
|
|
function createSelector() {
|
||
|
|
return xpath.useNamespaces({ w: WORD_NS });
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ฟังก์ชันสำหรับดึงข้อมูลจาก docx ออกมาเป็น JSON โดยอิงจาก Bookmark (w:bookmarkStart)
|
||
|
|
*/
|
||
|
|
export function extractBookmark(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 docXmlText: string = docXmlFile.asText();
|
||
|
|
|
||
|
|
// 3. Parse ข้อความ XML ให้เป็น DOM Object
|
||
|
|
const parser = new DOMParser();
|
||
|
|
const doc = parser.parseFromString(docXmlText, "text/xml");
|
||
|
|
|
||
|
|
// 4. ตั้งค่า Namespace ของ Word XML เพื่อใช้ค้นหาด้วย XPath
|
||
|
|
const select = xpath.useNamespaces({
|
||
|
|
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||
|
|
});
|
||
|
|
|
||
|
|
const result: ExtractedData = {};
|
||
|
|
|
||
|
|
// ค้นหา Bookmark หรือ Tag ที่ใช้แบ่ง Section
|
||
|
|
// const bookmarks = select('//w:bookmarkStart', doc as unknown as globalThis.Node) as Element[];
|
||
|
|
const bookmarks = select(
|
||
|
|
"//w:bookmarkStart",
|
||
|
|
doc as unknown as globalThis.Node
|
||
|
|
) as unknown as XmlElement[];
|
||
|
|
|
||
|
|
bookmarks.forEach((bookmark: XmlElement) => {
|
||
|
|
const sectionName = bookmark.getAttribute('w:name');
|
||
|
|
const bookmarkId = bookmark.getAttribute('w:id');
|
||
|
|
|
||
|
|
// กรองเอาเฉพาะ Bookmark ที่เราสนใจ (ไม่เอาพวกระบบสร้างเองอย่าง _GoBack)
|
||
|
|
if (sectionName && !sectionName.startsWith('_')) {
|
||
|
|
let extractedText = "";
|
||
|
|
let currentNode: XmlNode | null = bookmark.nextSibling;
|
||
|
|
|
||
|
|
// วนลูปอ่าน Node ถัดไปเรื่อยๆ จนกว่าจะเจอจุดสิ้นสุดของ Bookmark
|
||
|
|
while (currentNode) {
|
||
|
|
// ถ้าเจอจุดจบของ Bookmark ตัวเอง ให้หยุด
|
||
|
|
if (
|
||
|
|
currentNode.nodeType === 1 && // Element node
|
||
|
|
(currentNode as XmlElement).nodeName === 'w:bookmarkEnd' &&
|
||
|
|
(currentNode as XmlElement).getAttribute('w:id') === bookmarkId
|
||
|
|
) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ดึง Text (w:t) ที่อยู่ภายใน Node ปัจจุบัน
|
||
|
|
const textNodes = select('.//w:t/text()', currentNode as unknown as globalThis.Node) as unknown as XmlNode[];
|
||
|
|
textNodes.forEach((textNode: XmlNode) => {
|
||
|
|
if (textNode.nodeValue) {
|
||
|
|
extractedText += textNode.nodeValue;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
currentNode = currentNode.nextSibling;
|
||
|
|
}
|
||
|
|
|
||
|
|
// บันทึกลงผลลัพธ์ JSON
|
||
|
|
result[sectionName] = extractedText.trim();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* อ่าน 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\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(WORD_NS, "w:tab"));
|
||
|
|
} else if (token === "\n") {
|
||
|
|
nodes.push(doc.createElementNS(WORD_NS, "w:br"));
|
||
|
|
} else {
|
||
|
|
const t = doc.createElementNS(WORD_NS, "w:t");
|
||
|
|
t.setAttribute("xml:space", "preserve");
|
||
|
|
t.textContent = token;
|
||
|
|
nodes.push(t);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (nodes.length === 0) {
|
||
|
|
const t = doc.createElementNS(WORD_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(WORD_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),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ในฟังก์ชัน insertBlockContent()
|
||
|
|
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) {
|
||
|
|
const p = doc.createElementNS(WORD_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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ลบ Run เดิมออกจากย่อหน้าแรก
|
||
|
|
const existingRuns = select(
|
||
|
|
"./w:r",
|
||
|
|
asXpathNode(firstP),
|
||
|
|
) as unknown as XmlElement[];
|
||
|
|
|
||
|
|
existingRuns.forEach((r) => {
|
||
|
|
if (r.parentNode) r.parentNode.removeChild(r);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!allowMultiParagraph) {
|
||
|
|
// ถ้าไม่ใช่ออกแบบให้ข้ามย่อหน้าได้ ให้เปลี่ยน \n ทั้งหมดเป็น Space หรือ buildRun ปกติ
|
||
|
|
firstP.appendChild(buildRun(doc, text.replace(/\n+/g, "\n"), firstRPr));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// แยกด้วย \n\n สำหรับ Paragraph Break (w:p)
|
||
|
|
const paragraphs = text.split("\n\n");
|
||
|
|
|
||
|
|
paragraphs.forEach((pText, index) => {
|
||
|
|
let targetP: XmlElement;
|
||
|
|
|
||
|
|
if (index === 0) {
|
||
|
|
targetP = firstP;
|
||
|
|
} else {
|
||
|
|
targetP = doc.createElementNS(WORD_NS, "w:p");
|
||
|
|
if (firstPPr) {
|
||
|
|
targetP.appendChild(firstPPr.cloneNode(true));
|
||
|
|
}
|
||
|
|
sdtContent.appendChild(targetP);
|
||
|
|
}
|
||
|
|
|
||
|
|
const run = doc.createElementNS(WORD_NS, "w:r");
|
||
|
|
if (firstRPr) {
|
||
|
|
run.appendChild(firstRPr.cloneNode(true));
|
||
|
|
}
|
||
|
|
|
||
|
|
// pText จะมี \n เดี่ยวๆ หลงเหลืออยู่ ซึ่ง buildRunChildren จะแปลงเป็น w:br ให้โดยอัตโนมัติ
|
||
|
|
buildRunChildren(doc, pText).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 PropData = Record<string, unknown>;
|
||
|
|
export type DocumentTemplate = {info?:PropData,dc?:PropData,cc:PropData,};
|
||
|
|
|
||
|
|
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|Uint8Array<ArrayBufferLike>,
|
||
|
|
data: PropData,
|
||
|
|
): 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",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
const CUSTOM_NS =
|
||
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties";
|
||
|
|
|
||
|
|
const VT_NS =
|
||
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
|
||
|
|
|
||
|
|
const REL_NS =
|
||
|
|
"http://schemas.openxmlformats.org/package/2006/relationships";
|
||
|
|
|
||
|
|
const CONTENT_TYPES_NS =
|
||
|
|
"http://schemas.openxmlformats.org/package/2006/content-types";
|
||
|
|
|
||
|
|
const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
|
||
|
|
|
||
|
|
const CUSTOM_REL_TYPE =
|
||
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
|
||
|
|
|
||
|
|
const CUSTOM_CONTENT_TYPE =
|
||
|
|
"application/vnd.openxmlformats-officedocument.custom-properties+xml";
|
||
|
|
|
||
|
|
const CUSTOM_FMTID =
|
||
|
|
"{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
|
||
|
|
|
||
|
|
const CUSTOM_PATH = "docProps/custom.xml";
|
||
|
|
|
||
|
|
type CustomPropertyValue =
|
||
|
|
| string
|
||
|
|
| number
|
||
|
|
| boolean
|
||
|
|
| Date
|
||
|
|
| null
|
||
|
|
| undefined;
|
||
|
|
|
||
|
|
function parseXml(xml: string, fileName: string): XmlDocument {
|
||
|
|
const errors: string[] = [];
|
||
|
|
|
||
|
|
const parser = new DOMParser();
|
||
|
|
|
||
|
|
const doc = parser.parseFromString(
|
||
|
|
xml,
|
||
|
|
"application/xml",
|
||
|
|
) as XmlDocument;
|
||
|
|
|
||
|
|
if (errors.length > 0 || !doc.documentElement) {
|
||
|
|
throw new Error(
|
||
|
|
`Invalid ${fileName}: ${errors.join("; ")}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return doc;
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertRoot(
|
||
|
|
doc: XmlDocument,
|
||
|
|
namespace: string,
|
||
|
|
localName: string,
|
||
|
|
fileName: string,
|
||
|
|
): XmlElement {
|
||
|
|
const root = doc.documentElement;
|
||
|
|
|
||
|
|
if (root===null ||
|
||
|
|
root.localName !== localName ||
|
||
|
|
root.namespaceURI !== namespace
|
||
|
|
) {
|
||
|
|
throw new Error(`Invalid root element in ${fileName}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return root;
|
||
|
|
}
|
||
|
|
|
||
|
|
function createCustomXml(): XmlDocument {
|
||
|
|
const xml = [
|
||
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||
|
|
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
|
||
|
|
].join("");
|
||
|
|
|
||
|
|
return parseXml(xml, CUSTOM_PATH);
|
||
|
|
}
|
||
|
|
|
||
|
|
function createValueElement(
|
||
|
|
doc: XmlDocument,
|
||
|
|
value: unknown,
|
||
|
|
): XmlElement {
|
||
|
|
let tagName: string;
|
||
|
|
let text: string;
|
||
|
|
|
||
|
|
if (value == null) {
|
||
|
|
// null และ undefined เก็บเป็น String ว่าง
|
||
|
|
tagName = "vt:lpwstr";
|
||
|
|
text = "";
|
||
|
|
} else if (typeof value === "string") {
|
||
|
|
tagName = "vt:lpwstr";
|
||
|
|
text = value;
|
||
|
|
} else if (typeof value === "boolean") {
|
||
|
|
tagName = "vt:bool";
|
||
|
|
text = value ? "true" : "false";
|
||
|
|
} else if (typeof value === "number") {
|
||
|
|
if (!Number.isFinite(value)) {
|
||
|
|
throw new TypeError(
|
||
|
|
`Invalid numeric property value: ${value}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Number.isInteger(value)) {
|
||
|
|
if (!Number.isSafeInteger(value)) {
|
||
|
|
throw new TypeError(
|
||
|
|
`Integer property exceeds safe integer range: ${value}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
tagName = "vt:i8";
|
||
|
|
} else {
|
||
|
|
tagName = "vt:r8";
|
||
|
|
}
|
||
|
|
|
||
|
|
text = String(value);
|
||
|
|
} else if (value instanceof Date) {
|
||
|
|
if (Number.isNaN(value.getTime())) {
|
||
|
|
throw new TypeError("Invalid Date property value");
|
||
|
|
}
|
||
|
|
|
||
|
|
tagName = "vt:filetime";
|
||
|
|
text = value.toISOString();
|
||
|
|
} else {
|
||
|
|
throw new TypeError(
|
||
|
|
`Unsupported custom property type: ${
|
||
|
|
Object.prototype.toString.call(value)
|
||
|
|
}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const element = doc.createElementNS(VT_NS, tagName);
|
||
|
|
|
||
|
|
element.appendChild(doc.createTextNode(text));
|
||
|
|
|
||
|
|
return element;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureCustomRelationship(zip: PizzaZip): void {
|
||
|
|
const fileName = "_rels/.rels";
|
||
|
|
const file = zip.file(fileName);
|
||
|
|
|
||
|
|
if (!file) {
|
||
|
|
throw new Error(`DOCX is missing ${fileName}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const doc = parseXml(file.asText(), fileName);
|
||
|
|
|
||
|
|
const root = assertRoot(
|
||
|
|
doc,
|
||
|
|
REL_NS,
|
||
|
|
"Relationships",
|
||
|
|
fileName,
|
||
|
|
);
|
||
|
|
|
||
|
|
const relationships = Array.from(
|
||
|
|
root.getElementsByTagNameNS(REL_NS, "Relationship"),
|
||
|
|
) as XmlElement[];
|
||
|
|
|
||
|
|
const existing = relationships.find(
|
||
|
|
(item) => item.getAttribute("Type") === CUSTOM_REL_TYPE,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (existing) {
|
||
|
|
if (existing.getAttribute("Target") !== CUSTOM_PATH) {
|
||
|
|
throw new Error(
|
||
|
|
"Existing custom-properties relationship has an unexpected Target",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ids = new Set(
|
||
|
|
relationships.map((item) => item.getAttribute("Id")),
|
||
|
|
);
|
||
|
|
|
||
|
|
let index = 1;
|
||
|
|
let relationshipId = `rId${index}`;
|
||
|
|
|
||
|
|
while (ids.has(relationshipId)) {
|
||
|
|
index++;
|
||
|
|
relationshipId = `rId${index}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const relationship = doc.createElementNS(
|
||
|
|
REL_NS,
|
||
|
|
"Relationship",
|
||
|
|
);
|
||
|
|
|
||
|
|
relationship.setAttribute("Id", relationshipId);
|
||
|
|
relationship.setAttribute("Type", CUSTOM_REL_TYPE);
|
||
|
|
relationship.setAttribute("Target", CUSTOM_PATH);
|
||
|
|
|
||
|
|
root.appendChild(relationship);
|
||
|
|
|
||
|
|
zip.file(
|
||
|
|
fileName,
|
||
|
|
new XMLSerializer().serializeToString(doc),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureCustomContentType(zip: PizzaZip): void {
|
||
|
|
const fileName = "[Content_Types].xml";
|
||
|
|
const file = zip.file(fileName);
|
||
|
|
|
||
|
|
if (!file) {
|
||
|
|
throw new Error(`DOCX is missing ${fileName}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const doc = parseXml(file.asText(), fileName);
|
||
|
|
|
||
|
|
const root = assertRoot(
|
||
|
|
doc,
|
||
|
|
CONTENT_TYPES_NS,
|
||
|
|
"Types",
|
||
|
|
fileName,
|
||
|
|
);
|
||
|
|
|
||
|
|
const overrides = Array.from(
|
||
|
|
root.getElementsByTagNameNS(
|
||
|
|
CONTENT_TYPES_NS,
|
||
|
|
"Override",
|
||
|
|
),
|
||
|
|
) as XmlElement[];
|
||
|
|
|
||
|
|
const existing = overrides.find(
|
||
|
|
(item) => item.getAttribute("PartName") === "/docProps/custom.xml",
|
||
|
|
);
|
||
|
|
|
||
|
|
if (existing) {
|
||
|
|
existing.setAttribute("ContentType", CUSTOM_CONTENT_TYPE);
|
||
|
|
} else {
|
||
|
|
const override = doc.createElementNS(
|
||
|
|
CONTENT_TYPES_NS,
|
||
|
|
"Override",
|
||
|
|
);
|
||
|
|
|
||
|
|
override.setAttribute(
|
||
|
|
"PartName",
|
||
|
|
"/docProps/custom.xml",
|
||
|
|
);
|
||
|
|
|
||
|
|
override.setAttribute(
|
||
|
|
"ContentType",
|
||
|
|
CUSTOM_CONTENT_TYPE,
|
||
|
|
);
|
||
|
|
|
||
|
|
root.appendChild(override);
|
||
|
|
}
|
||
|
|
|
||
|
|
zip.file(
|
||
|
|
fileName,
|
||
|
|
new XMLSerializer().serializeToString(doc),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
/** how to use
|
||
|
|
const templateBuffer = readFileSync("template.docx");
|
||
|
|
const outputBuffer = setDocPropsCustom(templateBuffer, {
|
||
|
|
DocumentCode: "DOC-2026-001",
|
||
|
|
Department: "ฝ่ายเทคโนโลยีสารสนเทศ",
|
||
|
|
Revision: 2,
|
||
|
|
IsApproved: true,
|
||
|
|
PublishedAt: new Date(),
|
||
|
|
});
|
||
|
|
writeFileSync("output.docx", outputBuffer);
|
||
|
|
*/
|
||
|
|
export function setCustomDocProps(
|
||
|
|
docxBuffer: Buffer | Uint8Array<ArrayBufferLike>,
|
||
|
|
data: Record<string, unknown>,
|
||
|
|
): Buffer {
|
||
|
|
const zip = new PizzaZip(docxBuffer);
|
||
|
|
|
||
|
|
// อ่าน Custom Properties เดิม หรือสร้างใหม่หากไม่มี
|
||
|
|
const customFile = zip.file(CUSTOM_PATH);
|
||
|
|
|
||
|
|
const doc = customFile
|
||
|
|
? parseXml(customFile.asText(), CUSTOM_PATH)
|
||
|
|
: createCustomXml();
|
||
|
|
|
||
|
|
const root = assertRoot(
|
||
|
|
doc,
|
||
|
|
CUSTOM_NS,
|
||
|
|
"Properties",
|
||
|
|
CUSTOM_PATH,
|
||
|
|
);
|
||
|
|
|
||
|
|
// รับรองว่ามี Namespace vt สำหรับ Value Elements
|
||
|
|
root.setAttributeNS(XMLNS_NS, "xmlns:vt", VT_NS);
|
||
|
|
|
||
|
|
const properties = Array.from(
|
||
|
|
root.getElementsByTagNameNS(CUSTOM_NS, "property"),
|
||
|
|
) as XmlElement[];
|
||
|
|
|
||
|
|
const propertyMap = new Map<string, XmlElement>();
|
||
|
|
const usedPids = new Set<number>();
|
||
|
|
|
||
|
|
for (const property of properties) {
|
||
|
|
const name = property.getAttribute("name");
|
||
|
|
const pidText = property.getAttribute("pid");
|
||
|
|
const pid = Number(pidText);
|
||
|
|
|
||
|
|
if (!name) {
|
||
|
|
throw new Error(
|
||
|
|
"Custom property is missing its name attribute",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (propertyMap.has(name)) {
|
||
|
|
throw new Error(`Duplicate custom property name: ${name}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (pidText === null ||
|
||
|
|
!/^[1-9]\d*$/.test(pidText) ||
|
||
|
|
!Number.isSafeInteger(pid) ||
|
||
|
|
pid < 2
|
||
|
|
) {
|
||
|
|
throw new Error(
|
||
|
|
`Invalid custom property pid for "${name}": ${pidText}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (usedPids.has(pid)) {
|
||
|
|
throw new Error(`Duplicate custom property pid: ${pid}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
propertyMap.set(name, property);
|
||
|
|
usedPids.add(pid);
|
||
|
|
}
|
||
|
|
|
||
|
|
let nextPid = 2;
|
||
|
|
|
||
|
|
for (const [name, value] of Object.entries(data)) {
|
||
|
|
if (!name.trim()) {
|
||
|
|
throw new Error("Custom property name must not be empty");
|
||
|
|
}
|
||
|
|
|
||
|
|
let property = propertyMap.get(name);
|
||
|
|
|
||
|
|
if (!property) {
|
||
|
|
while (usedPids.has(nextPid)) {
|
||
|
|
nextPid++;
|
||
|
|
}
|
||
|
|
|
||
|
|
property = doc.createElementNS(
|
||
|
|
CUSTOM_NS,
|
||
|
|
"property",
|
||
|
|
);
|
||
|
|
|
||
|
|
property.setAttribute("fmtid", CUSTOM_FMTID);
|
||
|
|
property.setAttribute("pid", String(nextPid));
|
||
|
|
property.setAttribute("name", name);
|
||
|
|
|
||
|
|
usedPids.add(nextPid);
|
||
|
|
nextPid++;
|
||
|
|
|
||
|
|
root.appendChild(property);
|
||
|
|
propertyMap.set(name, property);
|
||
|
|
}
|
||
|
|
|
||
|
|
// สร้างค่าใหม่ก่อนแก้ไข Property เดิม
|
||
|
|
const valueElement = createValueElement(doc, value);
|
||
|
|
|
||
|
|
while (property.firstChild) {
|
||
|
|
property.removeChild(property.firstChild);
|
||
|
|
}
|
||
|
|
|
||
|
|
property.appendChild(valueElement);
|
||
|
|
}
|
||
|
|
|
||
|
|
// อัปเดตไฟล์ Custom Properties
|
||
|
|
zip.file(
|
||
|
|
CUSTOM_PATH,
|
||
|
|
new XMLSerializer().serializeToString(doc),
|
||
|
|
);
|
||
|
|
|
||
|
|
// ตรวจสอบส่วนประกอบที่เชื่อมโยงกับ Custom Properties
|
||
|
|
ensureCustomRelationship(zip);
|
||
|
|
ensureCustomContentType(zip);
|
||
|
|
|
||
|
|
return Buffer.from(
|
||
|
|
zip.generate({
|
||
|
|
type: "nodebuffer",
|
||
|
|
compression: "DEFLATE",
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ดึงค่า Custom Document Properties จาก DOCX Buffer
|
||
|
|
*/
|
||
|
|
export function getCustomDocProps(
|
||
|
|
docxBuffer: Buffer | Uint8Array<ArrayBufferLike>,
|
||
|
|
): Record<string, CustomPropertyValue> {
|
||
|
|
const zip = new PizzaZip(docxBuffer);
|
||
|
|
const customFile = zip.file(CUSTOM_PATH);
|
||
|
|
|
||
|
|
if (!customFile) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
|
||
|
|
const doc = parseXml(customFile.asText(), CUSTOM_PATH);
|
||
|
|
const root = assertRoot(
|
||
|
|
doc,
|
||
|
|
CUSTOM_NS,
|
||
|
|
"Properties",
|
||
|
|
CUSTOM_PATH,
|
||
|
|
);
|
||
|
|
|
||
|
|
const properties = Array.from(
|
||
|
|
root.getElementsByTagNameNS(CUSTOM_NS, "property"),
|
||
|
|
) as XmlElement[];
|
||
|
|
|
||
|
|
const result: Record<string, CustomPropertyValue> = {};
|
||
|
|
|
||
|
|
for (const property of properties) {
|
||
|
|
const name = property.getAttribute("name");
|
||
|
|
if (!name) continue;
|
||
|
|
|
||
|
|
// หา child element แรกที่อยู่ใน VT_NS namespace
|
||
|
|
let valueNode: XmlElement | undefined;
|
||
|
|
for (let i = 0; i < property.childNodes.length; i++) {
|
||
|
|
const node = property.childNodes[i] as XmlElement;
|
||
|
|
if (node.nodeType === 1 && node.namespaceURI === VT_NS) {
|
||
|
|
valueNode = node;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!valueNode) {
|
||
|
|
result[name] = "";
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const textContent = valueNode.textContent || "";
|
||
|
|
|
||
|
|
// แปลงค่ากลับตาม VT Data Type
|
||
|
|
switch (valueNode.localName) {
|
||
|
|
case "bool":
|
||
|
|
result[name] = textContent === "true" || textContent === "1";
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "i1":
|
||
|
|
case "i2":
|
||
|
|
case "i4":
|
||
|
|
case "i8":
|
||
|
|
case "int":
|
||
|
|
case "ui1":
|
||
|
|
case "ui2":
|
||
|
|
case "ui4":
|
||
|
|
case "ui8":
|
||
|
|
case "uint":
|
||
|
|
case "r4":
|
||
|
|
case "r8":
|
||
|
|
case "decimal": {
|
||
|
|
const num = Number(textContent);
|
||
|
|
result[name] = Number.isNaN(num) ? textContent : num;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case "filetime":
|
||
|
|
case "date": {
|
||
|
|
const date = new Date(textContent);
|
||
|
|
result[name] = Number.isNaN(date.getTime()) ? textContent : date;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
default:
|
||
|
|
// vt:lpwstr, vt:lpstr, vt:bstr และอื่นๆ
|
||
|
|
result[name] = textContent;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const CORE_NS =
|
||
|
|
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
|
||
|
|
const DC_NS = "http://purl.org/dc/elements/1.1/";
|
||
|
|
const DCTERMS_NS = "http://purl.org/dc/terms/";
|
||
|
|
const APP_NS =
|
||
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
|
||
|
|
|
||
|
|
export interface DocPropsOptions {
|
||
|
|
standard?: boolean;
|
||
|
|
extended?: boolean;
|
||
|
|
custom?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface StandardDocProps {
|
||
|
|
title?: string;
|
||
|
|
subject?: string;
|
||
|
|
creator?: string;
|
||
|
|
keywords?: string;
|
||
|
|
description?: string;
|
||
|
|
lastModifiedBy?: string;
|
||
|
|
revision?: string;
|
||
|
|
created?: Date;
|
||
|
|
modified?: Date;
|
||
|
|
category?: string;
|
||
|
|
contentStatus?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ExtendedDocProps {
|
||
|
|
template?: string;
|
||
|
|
totalTime?: number;
|
||
|
|
pages?: number;
|
||
|
|
words?: number;
|
||
|
|
characters?: number;
|
||
|
|
charactersWithSpaces?: number;
|
||
|
|
lines?: number;
|
||
|
|
paragraphs?: number;
|
||
|
|
application?: string;
|
||
|
|
appVersion?: string;
|
||
|
|
company?: string;
|
||
|
|
manager?: string;
|
||
|
|
docSecurity?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DocPropsResult {
|
||
|
|
standard?: StandardDocProps;
|
||
|
|
extended?: ExtendedDocProps;
|
||
|
|
custom?: Record<string, CustomPropertyValue>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ดึงข้อมูล Properties ของเอกสาร DOCX ตามประเภทที่เลือก
|
||
|
|
* example:
|
||
|
|
* const allProps = getDocProps(docxBuffer);
|
||
|
|
* const selectedProps = getDocProps(docxBuffer, {
|
||
|
|
* standard: true,
|
||
|
|
* custom: true,
|
||
|
|
* extended: false,
|
||
|
|
* });
|
||
|
|
* console.log(allProps);
|
||
|
|
* console.log(selectedProps.standard?.title);
|
||
|
|
*/
|
||
|
|
|
||
|
|
export function getDocProps(
|
||
|
|
docxBuffer: Buffer | Uint8Array<ArrayBufferLike>,
|
||
|
|
options: DocPropsOptions = { standard: true, extended: true, custom: true },
|
||
|
|
): DocPropsResult {
|
||
|
|
const zip = new PizzaZip(docxBuffer);
|
||
|
|
const result: DocPropsResult = {};
|
||
|
|
|
||
|
|
// 1. Standard / Core Properties (docProps/core.xml)
|
||
|
|
if (options.standard) {
|
||
|
|
const coreFile = zip.file("docProps/core.xml");
|
||
|
|
if (coreFile) {
|
||
|
|
const doc = parseXml(coreFile.asText(), "docProps/core.xml");
|
||
|
|
const getText = (ns: string, localName: string) => {
|
||
|
|
const els = doc.getElementsByTagNameNS(ns, localName);
|
||
|
|
return els.length > 0 ? els[0].textContent || "" : undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const parseDate = (dateStr?: string) => {
|
||
|
|
if (!dateStr) return undefined;
|
||
|
|
const d = new Date(dateStr);
|
||
|
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||
|
|
};
|
||
|
|
|
||
|
|
result.standard = {
|
||
|
|
title: getText(DC_NS, "title"),
|
||
|
|
subject: getText(DC_NS, "subject"),
|
||
|
|
creator: getText(DC_NS, "creator"),
|
||
|
|
keywords: getText(CORE_NS, "keywords"),
|
||
|
|
description: getText(DC_NS, "description"),
|
||
|
|
lastModifiedBy: getText(CORE_NS, "lastModifiedBy"),
|
||
|
|
revision: getText(CORE_NS, "revision"),
|
||
|
|
created: parseDate(getText(DCTERMS_NS, "created")),
|
||
|
|
modified: parseDate(getText(DCTERMS_NS, "modified")),
|
||
|
|
category: getText(CORE_NS, "category"),
|
||
|
|
contentStatus: getText(CORE_NS, "contentStatus"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Extended / Automatically Updated Properties (docProps/app.xml)
|
||
|
|
if (options.extended) {
|
||
|
|
const appFile = zip.file("docProps/app.xml");
|
||
|
|
if (appFile) {
|
||
|
|
const doc = parseXml(appFile.asText(), "docProps/app.xml");
|
||
|
|
const getText = (localName: string) => {
|
||
|
|
const els = doc.getElementsByTagNameNS(APP_NS, localName);
|
||
|
|
return els.length > 0 ? els[0].textContent || "" : undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const parseNum = (val?: string) => {
|
||
|
|
if (!val) return undefined;
|
||
|
|
const n = Number(val);
|
||
|
|
return Number.isNaN(n) ? undefined : n;
|
||
|
|
};
|
||
|
|
|
||
|
|
result.extended = {
|
||
|
|
template: getText("Template"),
|
||
|
|
totalTime: parseNum(getText("TotalTime")),
|
||
|
|
pages: parseNum(getText("Pages")),
|
||
|
|
words: parseNum(getText("Words")),
|
||
|
|
characters: parseNum(getText("Characters")),
|
||
|
|
charactersWithSpaces: parseNum(getText("CharactersWithSpaces")),
|
||
|
|
lines: parseNum(getText("Lines")),
|
||
|
|
paragraphs: parseNum(getText("Paragraphs")),
|
||
|
|
application: getText("Application"),
|
||
|
|
appVersion: getText("AppVersion"),
|
||
|
|
company: getText("Company"),
|
||
|
|
manager: getText("Manager"),
|
||
|
|
docSecurity: parseNum(getText("DocSecurity")),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Custom Properties (docProps/custom.xml)
|
||
|
|
if (options.custom) {
|
||
|
|
result.custom = getCustomDocProps(docxBuffer);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
//**************************** dynamic content *******************************************
|
||
|
|
// sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง)
|
||
|
|
type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ฟังก์ชันสร้าง Plain Text Content Control XML String ครอบด้วย ||
|
||
|
|
* ระวังการเกิดหลายรัน จะทำให้ xml เสีย
|
||
|
|
*/
|
||
|
|
function p_cc(title:string,tag:string,text:string='',isMultiLine:boolean=true,lock:LockType='sdtLocked'): string {
|
||
|
|
const lockTag = lock=='none'?"":`<w:lock w:val="${lock}" />`
|
||
|
|
const multiLineTag = isMultiLine ? '<w:text w:multiLine="1" />':''
|
||
|
|
const content = escapeXml(text)
|
||
|
|
const xmlString =`<w:sdt><w:sdtPr><w:alias w:val="${title}" /><w:tag w:val="${tag}" /><w:id w:val="-1948841888" />${lockTag}<w:placeholder><w:docPart w:val="C3E615E9D8424D6ABEA7EADA60C3EFB6" /></w:placeholder>${multiLineTag}</w:sdtPr><w:sdtEndPr /><w:sdtContent><w:r><w:rPr><w:rFonts w:cs="TH SarabunPSK" /></w:rPr><w:t xml:space="preserve">${content}</w:t></w:r></w:sdtContent></w:sdt>`
|
||
|
|
return `||${xmlString}||`;
|
||
|
|
}
|
||
|
|
function br(n: number)
|
||
|
|
{
|
||
|
|
let text=""
|
||
|
|
for(let i=0;i<n;i++) text += "<w:br/>"
|
||
|
|
return `||${text}||`;
|
||
|
|
}
|
||
|
|
function bm(id: number, name: string)
|
||
|
|
{
|
||
|
|
return `||<w:bookmarkStart w:id="${id}" w:name="${name}" /><w:bookmarkEnd w:id="${id}" />||`;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Escapes XML Special Characters
|
||
|
|
*/
|
||
|
|
function escapeXml(unsafe: string): string {
|
||
|
|
return unsafe
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''');
|
||
|
|
}
|
||
|
|
export const templateDynamicContents = async (template: Buffer, data: Record<string, unknown>): Promise<Uint8Array> => {
|
||
|
|
return await createReport({
|
||
|
|
template, data, cmdDelimiter: ['{{', '}}'],
|
||
|
|
additionalJsContext: {
|
||
|
|
bm,br,
|
||
|
|
p_cc,
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|