eCourt-note/lib/lib-tmpl.ts

1264 lines
32 KiB
TypeScript
Raw Normal View History

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
export const templateDynamicContents = async (template: Buffer, data: Record<string, unknown>): Promise<Uint8Array> => {
return await createReport({
template, data, cmdDelimiter: ['{{', '}}'],
additionalJsContext: {
bm,br,
p_cc,
}
});
}