- Removed the `fields` parameter from the version upload form, as it is now extracted from the uploaded .docx file using the `extractContentControls` function. - Updated related types and interfaces to reflect the new structure of version fields. - Modified the OpenAPI specification to document the changes in the versioning process. - Adjusted tests to ensure compliance with the new behavior, including validation for the absence of the `fields` parameter. - Implemented a new function `extractDocumentSnapshot` to handle the extraction of content controls and document properties from .docx files. - Updated metadata and versioning services to align with the new extraction logic.
1379 lines
No EOL
39 KiB
TypeScript
Executable file
1379 lines
No EOL
39 KiB
TypeScript
Executable file
import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils';
|
|
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;
|
|
}
|
|
|
|
|
|
//**************************** document snapshot *******************************************
|
|
|
|
/** DocPropsResult หลังทำ JSON-safe (Date → ISO string) — ชนิดของ `properties` ใน
|
|
* DocumentExtractSnapshot ทั้งฝั่ง response ของ /api/template/extract และ column
|
|
* `fields` jsonb ของ document_versions (ค่าข้าม server→client เป็น string เสมอ) */
|
|
export interface DocPropsJson {
|
|
standard?: Record<string, string | undefined>;
|
|
extended?: Record<string, number | string | undefined>;
|
|
custom?: Record<string, string | number | boolean | null>;
|
|
}
|
|
|
|
/** รูป return ของ /api/template/extract — ใช้ร่วม 2 ที่: route นั้นคืนทั้งก้อนตรง ๆ
|
|
* และ POST /api/documents/versions เก็บทั้งก้อนเป็น fields ของ version (2026-10-30) */
|
|
export interface DocumentExtractSnapshot {
|
|
/** ค่า Content Control `{ [tag]: text }` */
|
|
cc: ExtractedData;
|
|
/** ข้อความใน bookmark `{ [name]: text }` */
|
|
bookmark: ExtractedData;
|
|
/** document properties (standard/extended/custom) */
|
|
properties: DocPropsJson;
|
|
/** ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (ฟอร์แมตไทย) + HmacSha256/Ed25519 เฉพาะเมื่อ
|
|
* ผู้เรียกส่ง secret/publicKeyHex มาให้ใช้ตรวจ */
|
|
verify: Record<string, unknown>;
|
|
}
|
|
|
|
/** แกะข้อมูลจาก docx ทั้งชุด `{cc, bookmark, properties, verify}` — logic ย้ายมาจาก
|
|
* app/api/template/extract/route.ts แบบเดิม (แถวต่อแถว) เพื่อให้ return ของ extract
|
|
* กับ fields ของ version ที่สร้างใหม่ตรงกันเป๊ะโดยการก่อสร้าง
|
|
* · secret/publicKeyHex = ของผู้เรียกเพื่อ "ทดสอบ verify" (extract) หรือ secret ของ
|
|
* add-in ticket ฝั่ง server (ตอนสร้าง version) — ไม่ส่ง = ไม่มี key นั้นใน verify
|
|
* · ticket_data ที่ไม่ใช่ JSON จะ throw ผู้เรียกจัดการเอง (extract = 500 / versions = 400) */
|
|
export function extractDocumentSnapshot(
|
|
docxBuffer: Buffer,
|
|
options: { secret?: string; publicKeyHex?: string } = {},
|
|
): DocumentExtractSnapshot {
|
|
const cc = extractContentControls(docxBuffer);
|
|
const bookmark = extractBookmark(docxBuffer);
|
|
// JSON-safe ทันที (Date → ISO string): response ของ extract กับ jsonb ของ version ได้
|
|
// ค่าชนิดเดียวกัน (NextResponse.json / drizzle serialize Date เป็น ISO อยู่แล้ว —
|
|
// ผลลัพธ์ JSON ไม่ต่างจากเดิม เปลี่ยนแค่ชนิดฝั่ง TS)
|
|
const properties = JSON.parse(JSON.stringify(getDocProps(docxBuffer))) as DocPropsJson;
|
|
|
|
const verify: Record<string, unknown> = {};
|
|
const ticket_data = properties.custom?.ticket_data;
|
|
const HmacSha256 = String(properties.custom?.HmacSha256);
|
|
const Ed25519 = String(properties.custom?.Ed25519);
|
|
|
|
if (ticket_data && typeof ticket_data === "string") {
|
|
const ticketDataObj = JSON.parse(ticket_data);
|
|
const unix_timestamp = Number(ticketDataObj.exp) * 1000;
|
|
if (unix_timestamp) {
|
|
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" });
|
|
}
|
|
if (options.secret && HmacSha256) {
|
|
verify['HmacSha256'] = verifyHmacSha256(ticket_data, options.secret, HmacSha256);
|
|
}
|
|
if (options.publicKeyHex && Ed25519) {
|
|
verify["Ed25519"] = verifyEd25519(ticket_data, Ed25519, options.publicKeyHex);
|
|
}
|
|
}
|
|
|
|
return { cc, bookmark, properties, verify };
|
|
}
|
|
|
|
|
|
//**************************** 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,
|
|
}
|
|
});
|
|
}
|
|
|
|
// สร้างเอกสารจาก เทมเพลต+data render และ generate ใช้งานเหมือนกัน
|
|
// ยังไม่ค่อยแน่ใจว่าควรอยู่ใน library นี้หรือเปล่าเพราะมีการทำ hash/sign ticket_data
|
|
// - ticket_data ต้องมีข้อมูลสมบูรณ์ {fid,tid,uid,uname}
|
|
// - สร้าง signature/hash จาก ticket_data
|
|
// - สร้าง Dynamic Content (docx-templates)
|
|
// - เอาข้อมูลใส่ Content Control
|
|
export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentTemplate,
|
|
secret:string,private_key:string,ttl:number):Promise<Buffer<ArrayBufferLike>>
|
|
{
|
|
//Calculate hash
|
|
const originalTicket_data = templateData?.info?.ticket_data //expect valid json string
|
|
// console.log("templateData",templateData?.info?.ticket_data)
|
|
if (originalTicket_data && typeof originalTicket_data === 'string') {
|
|
try {
|
|
const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data
|
|
if (originalTicketObj && typeof originalTicketObj === 'object') {
|
|
console.log("Found valid ticket data: hash it")
|
|
// คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ)
|
|
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
|
|
const expireTimestamp = Math.floor(Date.now() / 1000) + ttl;
|
|
const fullObj = { ...originalTicketObj, exp: expireTimestamp };
|
|
const sortedObj = sortObjectKeys(fullObj);
|
|
|
|
// แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash
|
|
const ticket_data = JSON.stringify(sortedObj);
|
|
// console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data)
|
|
const HmacSha256 = generateHmacSha256(ticket_data, secret)
|
|
|
|
const Ed25519 = signEd25519(ticket_data, private_key)
|
|
templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 })
|
|
}
|
|
} catch (error) { //just skip
|
|
console.error("Set custom property fail",error);
|
|
}
|
|
}
|
|
|
|
// Apply Dynamic Content
|
|
if (templateData?.dc && typeof templateData?.dc === "object") {
|
|
console.log("Apply Dynamic Content")
|
|
templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc))
|
|
}
|
|
// Apply Content Control
|
|
if (templateData?.cc && typeof templateData?.cc === "object") {
|
|
console.log("Apply Content Control")
|
|
templateBuffer = templateContentControls(templateBuffer, templateData.cc);
|
|
}
|
|
return templateBuffer
|
|
|
|
} |