Multi-thread support for applyTemplate

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

View file

@ -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 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,
}
});
// เช็ค 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.ok && lastestDocxResponse.status !== 404){//เกิดปัญหาที่ไม่ใช่ Not Found
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 });
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
console.log('No version found, generating docx from template and JSON data...');
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)
}

View file

@ -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
View file

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

View file

@ -1,6 +1,26 @@
// สำหรับ run dev ถ้ามีการแก้โค้ดในนี้อย่าลืมเรียก npm run build:worker ก่อน
import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils';
import createReport from 'docx-templates';
import PizzaZip from "pizzip";
// import createReport from 'docx-templates';
import type { createReport as CreateReportType } from 'docx-templates';
const getCreateReport = async () => {
const mod = await import('docx-templates');
const createReportFn =
typeof mod.createReport === 'function'
? mod.createReport
: typeof mod.default === 'function'
? mod.default
: undefined;
if (!createReportFn) {
throw new Error('docx-templates createReport is not available');
}
return createReportFn as typeof CreateReportType;
};
import {
DOMParser,
XMLSerializer,
@ -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,21 +1309,19 @@ 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" />':''
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>`
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/>"
function br(n: number) {
let text = ""
for (let i = 0; i < n; i++) text += "<w:br/>"
return `||${text}||`;
}
function bm(id: number, name: string)
{
function bm(id: number, name: string) {
return `||<w:bookmarkStart w:id="${id}" w:name="${name}" /><w:bookmarkEnd w:id="${id}" />||`;
}
/**
@ -1319,10 +1336,11 @@ function escapeXml(unsafe: string): string {
.replace(/'/g, '&apos;');
}
export const templateDynamicContents = async (template: Buffer, data: Record<string, unknown>): Promise<Uint8Array> => {
const createReport = await getCreateReport();
return await createReport({
template, data, cmdDelimiter: ['{{', '}}'],
additionalJsContext: {
bm,br,
bm, br,
p_cc,
}
});
@ -1334,9 +1352,8 @@ 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>>
{
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)
@ -1360,7 +1377,7 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 })
}
} catch (error) { //just skip
console.error("Set custom property fail",error);
console.error("Set custom property fail", error);
}
}

View file

@ -33,6 +33,10 @@ const nextConfig = {
},
];
},
experimental: {
serverComponentsExternalPackages: ['piscina']
}
};
export default nextConfig;

319
package-lock.json generated
View file

@ -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",

View file

@ -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
View 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
View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
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
View 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);
}