新增 .docx 範本支援:jszip 跨 run 佔位符替換

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 14:37:33 +08:00
parent e1fcf3eb77
commit ef770070a7
4 changed files with 160 additions and 6 deletions

112
package-lock.json generated
View File

@@ -8,7 +8,9 @@
"name": "rental-contract-pdf", "name": "rental-contract-pdf",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"express": "^4.21.2" "adm-zip": "^0.5.17",
"express": "^4.21.2",
"jszip": "^3.10.1"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -27,6 +29,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -131,6 +142,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -429,6 +446,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -444,6 +467,33 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -552,6 +602,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -567,6 +623,12 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -619,6 +681,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -690,6 +773,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -777,6 +866,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -808,6 +912,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -8,7 +8,9 @@
"dev": "node --watch server.js" "dev": "node --watch server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.21.2" "adm-zip": "^0.5.17",
"express": "^4.21.2",
"jszip": "^3.10.1"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -4,11 +4,16 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { pathToFileURL } = require('node:url'); const { pathToFileURL } = require('node:url');
const JSZip = require('jszip');
const TEMPLATE_DIR = path.resolve(process.env.TEMPLATE_DIR || path.join(process.cwd(), 'templates')); const TEMPLATE_DIR = path.resolve(process.env.TEMPLATE_DIR || path.join(process.cwd(), 'templates'));
const TEMP_DIR = path.resolve(process.env.TEMP_DIR || path.join(os.tmpdir(), 'rental-contracts')); const TEMP_DIR = path.resolve(process.env.TEMP_DIR || path.join(os.tmpdir(), 'rental-contracts'));
const SOFFICE_BIN = process.env.SOFFICE_BIN || 'soffice'; const SOFFICE_BIN = process.env.SOFFICE_BIN || (
const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc']); process.platform === 'win32'
? 'C:\\Program Files\\LibreOffice\\program\\soffice.exe'
: 'soffice'
);
const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc', '.docx']);
const PLACEHOLDERS = [ const PLACEHOLDERS = [
['{{每月租金}}', 'monthlyRent', '每月租金'], ['{{每月租金}}', 'monthlyRent', '每月租金'],
@@ -37,7 +42,10 @@ async function createContractPdf(input) {
const copiedDocPath = path.join(workDir, `contract-${crypto.randomUUID()}${extension}`); const copiedDocPath = path.join(workDir, `contract-${crypto.randomUUID()}${extension}`);
await fs.copyFile(templatePath, copiedDocPath); await fs.copyFile(templatePath, copiedDocPath);
const replacementCounts = await applyPlaceholdersToDoc(copiedDocPath, values); const ext = path.extname(copiedDocPath).toLowerCase();
const replacementCounts = ext === '.docx'
? await applyPlaceholdersToDocx(copiedDocPath, values)
: await applyPlaceholdersToDoc(copiedDocPath, values);
assertAllPlaceholdersWereFound(replacementCounts); assertAllPlaceholdersWereFound(replacementCounts);
const pdfPath = await convertToPdf(copiedDocPath, workDir); const pdfPath = await convertToPdf(copiedDocPath, workDir);
@@ -93,7 +101,7 @@ async function resolveTemplatePath(templateName) {
} }
if (!SUPPORTED_TEMPLATE_EXTENSIONS.has(path.extname(templatePath).toLowerCase())) { if (!SUPPORTED_TEMPLATE_EXTENSIONS.has(path.extname(templatePath).toLowerCase())) {
throwClientError('目前僅支援 .doc 範本。'); throwClientError('目前僅支援 .doc / .docx 範本。');
} }
try { try {
@@ -121,6 +129,40 @@ async function removeWorkDir(workDir) {
await fs.rm(workDir, { recursive: true, force: true }); await fs.rm(workDir, { recursive: true, force: true });
} }
// Word 會把佔位符拆成多個 run用這個 pattern 允許相鄰字元之間出現 run 邊界 XML
const RUN_BOUNDARY = '(?:</w:t></w:r>(?:<w:bookmarkStart[^/]*/?>|<w:bookmarkEnd[^/]*/?>)*<w:r>(?:<w:rPr>[\\s\\S]*?</w:rPr>)?<w:t[^>]*>)?';
function buildPlaceholderPattern(placeholder) {
const chars = [...placeholder].map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(chars.join(RUN_BOUNDARY), 'g');
}
async function applyPlaceholdersToDocx(docPath, values) {
const data = await fs.readFile(docPath);
const zip = await JSZip.loadAsync(data);
const xmlFiles = ['word/document.xml', 'word/header1.xml', 'word/footer1.xml'];
const counts = new Map(PLACEHOLDERS.map(([p]) => [p, 0]));
for (const xmlFile of xmlFiles) {
const entry = zip.file(xmlFile);
if (!entry) continue;
let xml = await entry.async('string');
for (const [placeholder, key] of PLACEHOLDERS) {
const pattern = buildPlaceholderPattern(placeholder);
xml = xml.replace(pattern, () => {
counts.set(placeholder, counts.get(placeholder) + 1);
return values[key];
});
}
zip.file(xmlFile, xml);
}
const out = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
await fs.writeFile(docPath, out);
return counts;
}
async function applyPlaceholdersToDoc(docPath, values) { async function applyPlaceholdersToDoc(docPath, values) {
const buffer = await fs.readFile(docPath); const buffer = await fs.readFile(docPath);
const counts = new Map(); const counts = new Map();

Binary file not shown.