Multi-thread support for applyTemplate

This commit is contained in:
oom 2026-11-03 14:48:37 +07:00
parent f7e84e3ee7
commit bfb07c633d
10 changed files with 1260 additions and 154 deletions

35
lib/docx-pool.ts Normal file
View file

@ -0,0 +1,35 @@
import Piscina from 'piscina';
import path from 'node:path';
import { availableParallelism } from 'node:os';
const configuredThreads = Number(
process.env.DOCX_WORKERS || 4
);
const maxThreads = Math.max(
1,
Math.min(
Number.isFinite(configuredThreads) && configuredThreads > 0
? Math.floor(configuredThreads)
: 4,
Math.max(1, availableParallelism() - 1)
)
);
const globalForDocx = globalThis as typeof globalThis & {
docxPool?: Piscina;
};
export const docxPool =
globalForDocx.docxPool ??=
new Piscina({
filename: path.resolve(
process.cwd(),
'workers/dist/docx.worker.mjs'
),
minThreads: 1,
maxThreads,
idleTimeout: 30000,
maxQueue: 'auto',
concurrentTasksPerWorker: 1
});

View file

@ -1,6 +1,26 @@
// สำหรับ run dev ถ้ามีการแก้โค้ดในนี้อย่าลืมเรียก npm run build:worker ก่อน
import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils';
import createReport from 'docx-templates';
import PizzaZip from "pizzip";
// import createReport from 'docx-templates';
import type { createReport as CreateReportType } from 'docx-templates';
const getCreateReport = async () => {
const mod = await import('docx-templates');
const createReportFn =
typeof mod.createReport === 'function'
? mod.createReport
: typeof mod.default === 'function'
? mod.default
: undefined;
if (!createReportFn) {
throw new Error('docx-templates createReport is not available');
}
return createReportFn as typeof CreateReportType;
};
import {
DOMParser,
XMLSerializer,
@ -29,69 +49,69 @@ function createSelector() {
* ฟังก์ชันสำหรับดึงข้อมูลจาก docx ออกมาเป็น JSON โดยอิงจาก Bookmark (w:bookmarkStart)
*/
export function extractBookmark(docxBuffer: Buffer,): ExtractedData {
const zip = new PizzaZip(docxBuffer);
const docXmlFile = zip.file("word/document.xml");
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'
});
if (!docXmlFile) {
throw new Error("Invalid docx file: word/document.xml not found.");
}
const docXmlText: string = docXmlFile.asText();
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[];
// 3. Parse ข้อความ XML ให้เป็น DOM Object
const parser = new DOMParser();
const doc = parser.parseFromString(docXmlText, "text/xml");
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;
// 4. ตั้งค่า Namespace ของ Word XML เพื่อใช้ค้นหาด้วย XPath
const select = xpath.useNamespaces({
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
});
// วนลูปอ่าน 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;
}
const result: ExtractedData = {};
// ดึง 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;
}
});
// ค้นหา 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[];
currentNode = currentNode.nextSibling;
}
bookmarks.forEach((bookmark: XmlElement) => {
const sectionName = bookmark.getAttribute('w:name');
const bookmarkId = bookmark.getAttribute('w:id');
// บันทึกลงผลลัพธ์ JSON
result[sectionName] = extractedText.trim();
// กรองเอาเฉพาะ 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;
}
});
return result;
// ดึง 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;
}
@ -402,10 +422,10 @@ function insertBlockContent(
const firstRPr = firstRun
? (select(
"./w:rPr",
asXpathNode(firstRun),
true,
) as unknown as XmlElement | undefined)
"./w:rPr",
asXpathNode(firstRun),
true,
) as unknown as XmlElement | undefined)
: undefined;
// ลบย่อหน้าเดิมทั้งหมดยกเว้นย่อหน้าแรก
@ -481,7 +501,7 @@ function insertFallbackContent(
}
export type PropData = Record<string, unknown>;
export type DocumentTemplate = {info?:PropData,dc?:PropData,cc:PropData,};
export type DocumentTemplate = { info?: PropData, dc?: PropData, cc: PropData, };
function normalizeValue(value: unknown): string {
if (value === null || value === undefined) {
@ -509,7 +529,7 @@ function normalizeValue(value: unknown): string {
* คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว
*/
export function templateContentControls(
templateBuffer: Buffer|Uint8Array<ArrayBufferLike>,
templateBuffer: Buffer | Uint8Array<ArrayBufferLike>,
data: PropData,
): Buffer {
const zip = new PizzaZip(templateBuffer);
@ -656,7 +676,7 @@ function assertRoot(
): XmlElement {
const root = doc.documentElement;
if (root===null ||
if (root === null ||
root.localName !== localName ||
root.namespaceURI !== namespace
) {
@ -721,8 +741,7 @@ function createValueElement(
text = value.toISOString();
} else {
throw new TypeError(
`Unsupported custom property type: ${
Object.prototype.toString.call(value)
`Unsupported custom property type: ${Object.prototype.toString.call(value)
}`,
);
}
@ -1290,22 +1309,20 @@ 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>`
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 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}" />||`;
function bm(id: number, name: string) {
return `||<w:bookmarkStart w:id="${id}" w:name="${name}" /><w:bookmarkEnd w:id="${id}" />||`;
}
/**
* Escapes XML Special Characters
@ -1319,13 +1336,14 @@ function escapeXml(unsafe: string): string {
.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,
}
});
const createReport = await getCreateReport();
return await createReport({
template, data, cmdDelimiter: ['{{', '}}'],
additionalJsContext: {
bm, br,
p_cc,
}
});
}
// สร้างเอกสารจาก เทมเพลต+data render และ generate ใช้งานเหมือนกัน
@ -1334,47 +1352,46 @@ export const templateDynamicContents = async (template: Buffer, data: Record<str
// - สร้าง 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);
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)
// แปลงกลับเป็น 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);
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
// 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
}