Multi-thread support for applyTemplate
This commit is contained in:
parent
f7e84e3ee7
commit
bfb07c633d
10 changed files with 1260 additions and 154 deletions
35
lib/docx-pool.ts
Normal file
35
lib/docx-pool.ts
Normal 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
|
||||
});
|
||||
257
lib/lib-tmpl.ts
257
lib/lib-tmpl.ts
|
|
@ -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, ''');
|
||||
}
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue