Multi-thread support for applyTemplate
This commit is contained in:
parent
f7e84e3ee7
commit
bfb07c633d
10 changed files with 1260 additions and 154 deletions
|
|
@ -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<NextResponse<unknown>>
|
||||
{
|
||||
async function processRequest(fid: string, tid: string, uid: string, uname: string): Promise<NextResponse<unknown>> {
|
||||
//กัน 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)
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
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
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ const nextConfig = {
|
|||
},
|
||||
];
|
||||
},
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['piscina']
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
319
package-lock.json
generated
319
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
29
scripts/build-worker.mjs
Normal file
29
scripts/build-worker.mjs
Normal file
|
|
@ -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');
|
||||
641
workers/dist/docx.worker.mjs
vendored
Normal file
641
workers/dist/docx.worker.mjs
vendored
Normal file
|
|
@ -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 = [
|
||||
'<?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, 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" ? "" : `<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) {
|
||||
let text = "";
|
||||
for (let i = 0; i < n; i++) text += "<w:br/>";
|
||||
return `||${text}||`;
|
||||
}
|
||||
function bm(id, name) {
|
||||
return `||<w:bookmarkStart w:id="${id}" w:name="${name}" /><w:bookmarkEnd w:id="${id}" />||`;
|
||||
}
|
||||
function escapeXml(unsafe) {
|
||||
return unsafe.replace(/&/g, "&").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
|
||||
};
|
||||
29
workers/docx.worker.ts
Normal file
29
workers/docx.worker.ts
Normal file
|
|
@ -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<Buffer> {
|
||||
|
||||
const templateBuffer = Buffer.from(input.templateBuffer);
|
||||
|
||||
const output = await applyTemplate(
|
||||
templateBuffer,
|
||||
input.templateData,
|
||||
input.secret,
|
||||
input.privateKeyHex,
|
||||
input.ticketSec
|
||||
);
|
||||
|
||||
return Buffer.from(output);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue