From bfb07c633d9f73ae0fe67c43fbb9107aa652a779 Mon Sep 17 00:00:00 2001 From: oom Date: Tue, 3 Nov 2026 14:48:37 +0700 Subject: [PATCH] Multi-thread support for applyTemplate --- app/api/template/download-docx/route.ts | 78 +-- app/api/template/generate/route.ts | 18 +- lib/docx-pool.ts | 35 ++ lib/lib-tmpl.ts | 257 +++++----- next.config.mjs | 4 + package-lock.json | 319 ++++++++++++ package.json | 4 + scripts/build-worker.mjs | 29 ++ workers/dist/docx.worker.mjs | 641 ++++++++++++++++++++++++ workers/docx.worker.ts | 29 ++ 10 files changed, 1260 insertions(+), 154 deletions(-) create mode 100644 lib/docx-pool.ts create mode 100644 scripts/build-worker.mjs create mode 100644 workers/dist/docx.worker.mjs create mode 100644 workers/docx.worker.ts diff --git a/app/api/template/download-docx/route.ts b/app/api/template/download-docx/route.ts index bc6148a..d13ae96 100644 --- a/app/api/template/download-docx/route.ts +++ b/app/api/template/download-docx/route.ts @@ -7,34 +7,34 @@ import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs/promises'; -const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY||''; -const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl"; -const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || ''; +const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''; +const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl"; +const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || ''; -async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise> -{ +async function processRequest(fid: string, tid: string, uid: string, uname: string): Promise> { //กัน path traversal attack const TID = path.basename(tid) const FID = path.basename(fid) - const ticketObj = {fid:FID,tid:TID,uid,uname} - const ticket_data = JSON.stringify({fid:FID,tid:TID,uid,uname}) - const filePath = path.join(TMPL_PATH,`${FID}.json`); + const ticketObj = { fid: FID, tid: TID, uid, uname } + const ticket_data = JSON.stringify({ fid: FID, tid: TID, uid, uname }) + const filePath = path.join(TMPL_PATH, `${FID}.json`); // สำหรับการ demo ในแอปเดียวเท่านั้น สามารถคำนวนหา base path ได้ด้วยวิธีนี้ // const host = headers().get("host"); // e.g., localhost:3000 // const proto = headers().get("x-forwarded-proto") || "http"; // const baseUrl = process.env.NEXT_PUBLIC_BASE_PATH || "" // const fullUrl = `${proto}://${host}${baseUrl}`; // const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || fullUrl; - if(!DOCEDIT_BASE_URL || !EXTERNAL_API_KEY) { + if (!DOCEDIT_BASE_URL || !EXTERNAL_API_KEY) { return NextResponse.json({ error: 'Server must set Environment variable DOCEDIT_BASE_URL or EXTERNAL_API_KEY' }, { status: 500 }); } - if (!FID||!TID||!uid||!uname) { + if (!FID || !TID || !uid || !uname) { return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 }); } try { //ตรวจสอบ docx เวอร์ชั่นล่าสุดก่อนถ้าไม่พบค่อยเอาจากฐานข้อมูลมาสร้าง raw Json - const params = new URLSearchParams({type:'docx',ticket_data}); + const params = new URLSearchParams({ type: 'docx', ticket_data }); + console.log(`search ${DOCEDIT_BASE_URL}/api/document/latest?${params.toString()}`); const lastestDocxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/document/latest?${params.toString()}`, { method: 'GET', headers: { @@ -42,34 +42,51 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr 'x-api-key': EXTERNAL_API_KEY, } }); - - if(!lastestDocxResponse.ok && lastestDocxResponse.status !== 404){//เกิดปัญหาที่ไม่ใช่ Not Found + // เช็ค Content-Type และ Status ก่อน + const contentType = lastestDocxResponse.headers.get('content-type') || ''; + if (lastestDocxResponse.ok && !!contentType.includes('text/html')) { + console.log('Fund latest docx from docedit, returning stream to browser'); + const stream = lastestDocxResponse.body; + //ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์ + const download_file = `${FID}_${uid}_latest` + return new NextResponse(stream as unknown as BodyInit, { + status: 200, + headers: { + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`, + 'Cache-Control': 'no-store, max-age=0', + }, + }); + } + + if (lastestDocxResponse.status !== 404) {//เกิดปัญหาที่ไม่ใช่ Not Found น่าจะเป็นปัญหาที่ gateway const errorText = await lastestDocxResponse.text(); console.error('download-docx:Invalid latest docx Response:', errorText); return NextResponse.json( - {error:`Error from docedit latest: Expected docx stream but received ${lastestDocxResponse.status} ${lastestDocxResponse.statusText}`}, + { error: `Error from docedit latest: Expected docx stream but received ${lastestDocxResponse.status} ${lastestDocxResponse.statusText}` }, { status: 502 } ); } } catch (error) { console.error(error) - return NextResponse.json({error:'Internal Server Error'}, { status: 500 }); - } - console.log('No version found, generating docx from template and JSON data...'); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } + console.log(`Not found latest docx from docedit, fallback to generate from template and database: ${filePath}`); try { // ไม่พบเวอร์ชั้นให้สร้าง docx จาก template และ json(ฐานข้อมูล) // อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล const rawJson = await fs.readFile(filePath, 'utf-8'); const jsonData = JSON.parse(rawJson); - if (!jsonData || typeof jsonData !== 'object' ) { - return NextResponse.json({error:'Invalid data format'}, { status: 400 }); + if (!jsonData || typeof jsonData !== 'object') { + return NextResponse.json({ error: 'Invalid data format' }, { status: 400 }); } /////// !! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend !!// /////// ไม่มี session return 401, ถ้าเจอแต่ไม่มีสิทธิ์ให้ return 403 , อ่านจากฐานข้อมูล ถ้าไม่พบข้อมูลให้ return 404, // ข้อมูลจำเป็นต้องมี jsonData.info.ticket_data ถ้าไม่มีให้สร้างขึ้นมาเอง - if(!Object.hasOwn(jsonData, 'info')) - jsonData['info']={} + if (!Object.hasOwn(jsonData, 'info')) + jsonData['info'] = {} jsonData.info['ticket_data'] = JSON.stringify(ticketObj) // sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้ @@ -90,7 +107,7 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr console.error('download-docx:Invalid docx Response:', errorText); return NextResponse.json( - {error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`}, + { error: `Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})` }, { status: 502 } ); } @@ -108,26 +125,25 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr 'Cache-Control': 'no-store, max-age=0', }, }); - } catch (error:any) { - if ( error.code === 'ENOENT') {// ไฟล์ JSON ไม่พบ ถ้าใช้ฐานข้อมูลจริงให้ return 404 แทน + } catch (error: any) { + if (error.code === 'ENOENT') {// ไฟล์ JSON ไม่พบ ถ้าใช้ฐานข้อมูลจริงให้ return 404 ตั้งแต่แต่แรก และเอา ENOENT ออก return NextResponse.json( - { error: `Data not found: ${filePath}`}, + { error: `Data not found: ${filePath}` }, { status: 404 } ); } console.error(error) - return NextResponse.json({error:'Internal Server Error'}, { status: 500 }); - } + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } } -export async function GET(request: NextRequest) -{ +export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const fid = searchParams.get('fid') || ''; const tid = searchParams.get('tid') || ''; const uid = searchParams.get('uid') || ''; const uname = searchParams.get('uname') || ''; - return await processRequest(fid,tid,uid,uname) + return await processRequest(fid, tid, uid, uname) } export async function POST(request: NextRequest) { @@ -136,5 +152,5 @@ export async function POST(request: NextRequest) { const tid = String(formData.get('tid')) const uid = String(formData.get('uid')) const uname = String(formData.get('uname')) - return await processRequest(fid,tid,uid,uname) + return await processRequest(fid, tid, uid, uname) } \ No newline at end of file diff --git a/app/api/template/generate/route.ts b/app/api/template/generate/route.ts index 8f9d3c0..d6884b7 100644 --- a/app/api/template/generate/route.ts +++ b/app/api/template/generate/route.ts @@ -6,8 +6,11 @@ import { NextRequest, NextResponse } from 'next/server'; import {readFile} from 'fs/promises'; import path from 'path'; import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; +import { docxPool } from '@/lib/docx-pool'; +export const runtime = 'nodejs'; -const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234'; + +const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''; const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||'' const SECRET = process.env.SECRET || 'super_secret' const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl"; @@ -64,7 +67,16 @@ export async function POST(request: NextRequest) { const tmplPath = path.join(TMPL_PATH, `${path.basename(tid)}.docx`); let templateBuffer: Buffer = await readFile(tmplPath) - const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); + // const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); + const output = await docxPool.run({ + templateBuffer, + templateData, + secret: SECRET, + privateKeyHex: PRIVATE_KEY_HEX, + ticketSec: TICKET_SEC + }); + + const outputBuffer = Buffer.from(output); // สร้าง ReadableStream ของไฟล์ .docx // ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System @@ -72,7 +84,7 @@ export async function POST(request: NextRequest) { const docxStream = new ReadableStream({ start(controller) { - controller.enqueue(output); + controller.enqueue(outputBuffer); controller.close(); }, }); diff --git a/lib/docx-pool.ts b/lib/docx-pool.ts new file mode 100644 index 0000000..9912b74 --- /dev/null +++ b/lib/docx-pool.ts @@ -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 + }); \ No newline at end of file diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index a62a388..7fd0895 100755 --- a/lib/lib-tmpl.ts +++ b/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; -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, + templateBuffer: Buffer | Uint8Array, 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'?"":`` - const multiLineTag = isMultiLine ? '':'' - const content = escapeXml(text) - const xmlString =`${lockTag}${multiLineTag}${content}` +function p_cc(title: string, tag: string, text: string = '', isMultiLine: boolean = true, lock: LockType = 'sdtLocked'): string { + const lockTag = lock == 'none' ? "" : `` + const multiLineTag = isMultiLine ? '' : '' + const content = escapeXml(text) + const xmlString = `${lockTag}${multiLineTag}${content}` return `||${xmlString}||`; } -function br(n: number) -{ - let text="" - for(let i=0;i||`; +function bm(id: number, name: string) { + return `||||`; } /** * Escapes XML Special Characters @@ -1319,13 +1336,14 @@ function escapeXml(unsafe: string): string { .replace(/'/g, '''); } export const templateDynamicContents = async (template: Buffer, data: Record): Promise => { - 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> -{ - //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> { + //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 } diff --git a/next.config.mjs b/next.config.mjs index fba71db..8d37633 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -33,6 +33,10 @@ const nextConfig = { }, ]; }, + experimental: { + serverComponentsExternalPackages: ['piscina'] + } + }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 466a9f7..7d48f30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "next": "^14.2.35", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.10.38", + "piscina": "^5.3.2", "pizzip": "^3.3.0", "pngjs": "^7.0.0", "postgres": "^3.4.9", @@ -35,6 +36,7 @@ "@types/react-dom": "^18.3.7", "autoprefixer": "^10.5.5", "drizzle-kit": "^0.31.10", + "esbuild": "^0.28.2", "jsdom": "^29.1.1", "office-addin-debugging": "^7.0.1", "office-addin-dev-settings": "^4.0.1", @@ -3673,6 +3675,311 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/env": { "version": "14.2.35", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", @@ -10861,6 +11168,18 @@ "node": ">= 6" } }, + "node_modules/piscina": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.3.2.tgz", + "integrity": "sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==", + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, "node_modules/pizzip": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pizzip/-/pizzip-3.3.0.tgz", diff --git a/package.json b/package.json index aabee40..d04ae82 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "next dev", + "build:worker": "node scripts/build-worker.mjs", + "prebuild": "npm run build:worker", "build": "next build", "test": "vitest run", "start": "next start", @@ -23,6 +25,7 @@ "next": "^14.2.35", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.10.38", + "piscina": "^5.3.2", "pizzip": "^3.3.0", "pngjs": "^7.0.0", "postgres": "^3.4.9", @@ -40,6 +43,7 @@ "@types/react-dom": "^18.3.7", "autoprefixer": "^10.5.5", "drizzle-kit": "^0.31.10", + "esbuild": "^0.28.2", "jsdom": "^29.1.1", "office-addin-debugging": "^7.0.1", "office-addin-dev-settings": "^4.0.1", diff --git a/scripts/build-worker.mjs b/scripts/build-worker.mjs new file mode 100644 index 0000000..a08c52a --- /dev/null +++ b/scripts/build-worker.mjs @@ -0,0 +1,29 @@ +import { build } from 'esbuild'; +import path from 'node:path'; +import fs from 'node:fs'; + +const root = process.cwd(); + +const outputDir = path.join(root, 'workers/dist'); + +fs.mkdirSync(outputDir, { recursive: true }); + +await build({ + entryPoints: [ + path.join(root, 'workers/docx.worker.ts') + ], + outfile: path.join(outputDir, 'docx.worker.mjs'), + + bundle: true, + platform: 'node', + format: 'esm', + packages: 'external', + + alias: { + '@': root + }, + + sourcemap: false +}); + +console.log('DOCX Worker compiled successfully'); \ No newline at end of file diff --git a/workers/dist/docx.worker.mjs b/workers/dist/docx.worker.mjs new file mode 100644 index 0000000..8160eeb --- /dev/null +++ b/workers/dist/docx.worker.mjs @@ -0,0 +1,641 @@ +// lib/lib-utils.ts +import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify, sign } from "node:crypto"; +function generateHmacSha256(data, secretKey) { + const payload = typeof data === "string" ? data : JSON.stringify(sortObjectKeys(data)); + return createHmac("sha256", secretKey).update(payload, "utf8").digest("hex"); +} +function signEd25519(message, privateKeyHex) { + const rawKeyBuffer = Buffer.from(privateKeyHex, "hex"); + const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer; + const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex"); + const pkcs8Buffer = Buffer.concat([pkcs8Header, seed]); + const privateKey = createPrivateKey({ + key: pkcs8Buffer, + format: "der", + type: "pkcs8" + }); + const dataBuffer = typeof message === "string" ? Buffer.from(message, "utf-8") : message; + const signatureBuffer = sign(null, dataBuffer, privateKey); + return signatureBuffer.toString("hex"); +} +function sortObjectKeys(obj) { + return Object.keys(obj).sort().reduce((result, key) => { + result[key] = obj[key]; + return result; + }, {}); +} + +// lib/lib-tmpl.ts +import PizzaZip from "pizzip"; +import { + DOMParser, + XMLSerializer +} from "@xmldom/xmldom"; +import xpath from "xpath"; +var getCreateReport = async () => { + const mod = await import("docx-templates"); + const createReportFn = typeof mod.createReport === "function" ? mod.createReport : typeof mod.default === "function" ? mod.default : void 0; + if (!createReportFn) { + throw new Error("docx-templates createReport is not available"); + } + return createReportFn; +}; +var WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +var asXpathNode = (node) => node; +function createSelector() { + return xpath.useNamespaces({ w: WORD_NS }); +} +function detectSdtControlType(sdt, select) { + const sdtPr = select( + "./w:sdtPr", + asXpathNode(sdt), + true + ); + if (!sdtPr) { + return { type: "other", multiLine: false }; + } + const textEl = select( + "./w:text", + asXpathNode(sdtPr), + true + ); + if (textEl) { + const multiLineAttr = select( + "./@w:multiLine", + asXpathNode(textEl), + true + ); + return { + type: "plain", + multiLine: multiLineAttr?.value === "1" || multiLineAttr?.value === "true" + }; + } + const richTextEl = select( + "./w:richText", + asXpathNode(sdtPr), + true + ); + 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 + ); + if (found) { + return { type: "other", multiLine: false }; + } + } + return { type: "rich", multiLine: true }; +} +function detectSdtStructure(sdtContent, select) { + const directP = select( + "./w:p", + asXpathNode(sdtContent) + ); + return directP.length > 0 ? "block" : "inline"; +} +function buildRunChildren(doc, text) { + const nodes = []; + const tokens = text.split(/(\t|\n)/); + tokens.forEach((token) => { + if (token === "") return; + if (token === " ") { + 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, text, rPr) { + 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, sdtContent, select, text, allowLineBreak) { + const safeText = allowLineBreak ? text : text.replace(/\n/g, " "); + const existingRuns = select( + "./w:r", + asXpathNode(sdtContent) + ); + const firstRPr = existingRuns.length > 0 ? select( + "./w:rPr", + asXpathNode(existingRuns[0]), + true + ) : void 0; + existingRuns.forEach((r) => { + if (r.parentNode) r.parentNode.removeChild(r); + }); + sdtContent.appendChild( + buildRun(doc, safeText, firstRPr) + ); +} +function insertBlockContent(doc, sdtContent, select, text, allowMultiParagraph) { + const allParagraphs = select( + "./w:p", + asXpathNode(sdtContent) + ); + 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 + ); + const firstRun = select( + "./w:r", + asXpathNode(firstP), + true + ); + const firstRPr = firstRun ? select( + "./w:rPr", + asXpathNode(firstRun), + true + ) : void 0; + allParagraphs.forEach((p) => { + if (p !== firstP && p.parentNode) { + p.parentNode.removeChild(p); + } + }); + const existingRuns = select( + "./w:r", + asXpathNode(firstP) + ); + existingRuns.forEach((r) => { + if (r.parentNode) r.parentNode.removeChild(r); + }); + if (!allowMultiParagraph) { + firstP.appendChild(buildRun(doc, text.replace(/\n+/g, "\n"), firstRPr)); + return; + } + const paragraphs = text.split("\n\n"); + paragraphs.forEach((pText, index) => { + let targetP; + 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)); + } + buildRunChildren(doc, pText).forEach( + (node) => run.appendChild(node) + ); + targetP.appendChild(run); + }); +} +function insertFallbackContent(sdtContent, select, text) { + const textNodes = select( + ".//w:t", + asXpathNode(sdtContent) + ); + if (textNodes.length === 0) return; + textNodes[0].textContent = text; + for (let i = 1; i < textNodes.length; i++) { + textNodes[i].textContent = ""; + } +} +function normalizeValue(value) { + if (value === null || value === void 0) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + return JSON.stringify(value); +} +function templateContentControls(templateBuffer, data) { + 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) + ); + sdtNodes.forEach((sdt) => { + const tagAttr = select( + "./w:sdtPr/w:tag/@w:val", + asXpathNode(sdt), + true + ); + 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 + ); + 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" + }); +} +var CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; +var VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; +var REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; +var CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"; +var XMLNS_NS = "http://www.w3.org/2000/xmlns/"; +var CUSTOM_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; +var CUSTOM_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.custom-properties+xml"; +var CUSTOM_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; +var CUSTOM_PATH = "docProps/custom.xml"; +function parseXml(xml, fileName) { + const errors = []; + const parser = new DOMParser(); + const doc = parser.parseFromString( + xml, + "application/xml" + ); + if (errors.length > 0 || !doc.documentElement) { + throw new Error( + `Invalid ${fileName}: ${errors.join("; ")}` + ); + } + return doc; +} +function assertRoot(doc, namespace, localName, fileName) { + 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() { + const xml = [ + '', + `` + ].join(""); + return parseXml(xml, CUSTOM_PATH); +} +function createValueElement(doc, value) { + let tagName; + let text; + if (value == null) { + 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) { + 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") + ); + 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) { + 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" + ) + ); + 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) + ); +} +function setCustomDocProps(docxBuffer, data) { + const zip = new PizzaZip(docxBuffer); + const customFile = zip.file(CUSTOM_PATH); + const doc = customFile ? parseXml(customFile.asText(), CUSTOM_PATH) : createCustomXml(); + const root = assertRoot( + doc, + CUSTOM_NS, + "Properties", + CUSTOM_PATH + ); + root.setAttributeNS(XMLNS_NS, "xmlns:vt", VT_NS); + const properties = Array.from( + root.getElementsByTagNameNS(CUSTOM_NS, "property") + ); + const propertyMap = /* @__PURE__ */ new Map(); + const usedPids = /* @__PURE__ */ new Set(); + 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); + } + const valueElement = createValueElement(doc, value); + while (property.firstChild) { + property.removeChild(property.firstChild); + } + property.appendChild(valueElement); + } + zip.file( + CUSTOM_PATH, + new XMLSerializer().serializeToString(doc) + ); + ensureCustomRelationship(zip); + ensureCustomContentType(zip); + return Buffer.from( + zip.generate({ + type: "nodebuffer", + compression: "DEFLATE" + }) + ); +} +function p_cc(title, tag, text = "", isMultiLine = true, lock = "sdtLocked") { + const lockTag = lock == "none" ? "" : ``; + const multiLineTag = isMultiLine ? '' : ""; + const content = escapeXml(text); + const xmlString = `${lockTag}${multiLineTag}${content}`; + return `||${xmlString}||`; +} +function br(n) { + let text = ""; + for (let i = 0; i < n; i++) text += ""; + return `||${text}||`; +} +function bm(id, name) { + return `||||`; +} +function escapeXml(unsafe) { + return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} +var templateDynamicContents = async (template, data) => { + const createReport = await getCreateReport(); + return await createReport({ + template, + data, + cmdDelimiter: ["{{", "}}"], + additionalJsContext: { + bm, + br, + p_cc + } + }); +}; +async function applyTemplate(templateBuffer, templateData, secret, private_key, ttl) { + const originalTicket_data = templateData?.info?.ticket_data; + if (originalTicket_data && typeof originalTicket_data === "string") { + try { + const originalTicketObj = JSON.parse(originalTicket_data); + if (originalTicketObj && typeof originalTicketObj === "object") { + console.log("Found valid ticket data: hash it"); + const expireTimestamp = Math.floor(Date.now() / 1e3) + ttl; + const fullObj = { ...originalTicketObj, exp: expireTimestamp }; + const sortedObj = sortObjectKeys(fullObj); + const ticket_data = JSON.stringify(sortedObj); + const HmacSha256 = generateHmacSha256(ticket_data, secret); + const Ed25519 = signEd25519(ticket_data, private_key); + templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }); + } + } catch (error) { + console.error("Set custom property fail", error); + } + } + if (templateData?.dc && typeof templateData?.dc === "object") { + templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)); + } + if (templateData?.cc && typeof templateData?.cc === "object") { + templateBuffer = templateContentControls(templateBuffer, templateData.cc); + } + return templateBuffer; +} + +// workers/docx.worker.ts +async function generateDocx(input) { + const templateBuffer = Buffer.from(input.templateBuffer); + const output = await applyTemplate( + templateBuffer, + input.templateData, + input.secret, + input.privateKeyHex, + input.ticketSec + ); + return Buffer.from(output); +} +export { + generateDocx as default +}; diff --git a/workers/docx.worker.ts b/workers/docx.worker.ts new file mode 100644 index 0000000..efeba39 --- /dev/null +++ b/workers/docx.worker.ts @@ -0,0 +1,29 @@ +import { + applyTemplate, + type DocumentTemplate +} from '../lib/lib-tmpl'; + +interface DocxWorkerInput { + templateBuffer: Uint8Array; + templateData: DocumentTemplate; + secret: string; + privateKeyHex: string; + ticketSec: number; +} + +export default async function generateDocx( + input: DocxWorkerInput +): Promise { + + const templateBuffer = Buffer.from(input.templateBuffer); + + const output = await applyTemplate( + templateBuffer, + input.templateData, + input.secret, + input.privateKeyHex, + input.ticketSec + ); + + return Buffer.from(output); +} \ No newline at end of file