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

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);
}